VisualSite
Hero globe
Wireframe globe with animated paths and optional connected statistics on wide screens.
$ npx context-ds add hero-globeUsage
Use it for
A Hero's background: <HeroGlobe />, plus <HeroGlobeStats /> on screens 90rem and wider. Give the hero room at its foot for the crown (about 15rem).
Avoid
- A globe and a dot field in one hero
- More than four stats
- Stats over the demo
Example
examples/hero-globe.tsx
import { Hero } from "@/components/site/hero";import { Button } from "@/components/ui/button";import { HeroGlobe } from "@/components/visuals/hero-globe";import { HeroGlobeStats } from "@/components/visuals/hero-globe-stats";export function Site() { return ( <Hero className="min-h-160 pb-60" background={ <> <HeroGlobe /> <HeroGlobeStats /> </> } title="Search the whole {web}" accent="{web}" description="Ranked results with clean page content, from every corner of the web." actions={ <Button asChild variant="pill"> <a href="https://context.dev/signup">Start for free</a> </Button> } /> );}Source
components/visuals/hero-globe.tsx
"use client";import * as React from "react";import { cn } from "@/lib/utils";import { GLOBE_PATH, HERO_GLOBE_RISE_CLASS, NODES, RADIUS, TRACE_SHAPES, VIEWBOX_SIZE, WIDTH } from "./hero-globe-geometry";export { HERO_GLOBE_RISE_CLASS };const TRACE_CYCLE_MS = 4800;const TRACE_STAGGER_MS = 900;// Head position along the trace, from its start node (0) to its end node (1); the comet's tail trails this far behind.const COMET_TAIL = 0.45;const COMET_EXIT = 1 + COMET_TAIL;const COMET_TRAVEL_SHARE = 0.4;// When the head reaches the end node (1 of COMET_EXIT) within the eased travel: the end node pulses from here.const ARRIVAL_SHARE = 0.25;// The comet's travel: an ease-out spline, fast off its node and settling into the next.const EASE_OUT = "0.45 0 0.25 1";/** * A hairline wireframe globe rising from the bottom of the hero; the hero's edge cuts it to a dome. Comets run along * its grid lines from node to node and the node they reach pulses. It draws in `currentColor`, pauses off screen, and * holds a still frame (the grid and its traces) under reduced motion. */export function HeroGlobe({ className }: { className?: string }) { const svgRef = React.useRef<SVGSVGElement>(null); const id = React.useId().replace(/:/g, ""); // SMIL keeps ticking (and repainting) whether or not anyone can see it, so the SVG pauses while off screen. React.useEffect(() => { const svg = svgRef.current; if (!svg) return; const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); let onScreen = false; const sync = () => { if (onScreen && !reducedMotion.matches) svg.unpauseAnimations(); else svg.pauseAnimations(); }; sync(); const observer = new IntersectionObserver(([entry]) => { onScreen = entry.isIntersecting; sync(); }); observer.observe(svg); reducedMotion.addEventListener("change", sync); return () => { observer.disconnect(); reducedMotion.removeEventListener("change", sync); }; }, []); return ( <div aria-hidden="true" data-slot="hero-globe" className={cn("pointer-events-none absolute inset-0 overflow-hidden text-brand", HERO_GLOBE_RISE_CLASS, className)}> <svg ref={svgRef} viewBox={`${-RADIUS - 1} ${-RADIUS - 1} ${VIEWBOX_SIZE} ${VIEWBOX_SIZE}`} style={{ width: WIDTH }} className="absolute top-full left-1/2 -translate-x-1/2 -translate-y-(--hero-globe-rise)" > <circle r={RADIUS} fill="none" stroke="currentColor" strokeOpacity={0.35} vectorEffect="non-scaling-stroke" /> <path d={GLOBE_PATH} fill="none" stroke="currentColor" strokeOpacity={0.2} vectorEffect="non-scaling-stroke" /> <defs> {TRACE_SHAPES.map(({ from, to }, index) => ( <linearGradient key={`base-${index}`} id={`${id}-trace-${index}`} gradientUnits="userSpaceOnUse" x1={from.x} y1={from.y} x2={to.x} y2={to.y}> <stop offset="0" stopColor="currentColor" stopOpacity="0" /> <stop offset="1" stopColor="currentColor" stopOpacity="0.55" /> </linearGradient> ))} {TRACE_SHAPES.map(({ from, to }, index) => { const timing = { dur: `${TRACE_CYCLE_MS}ms`, begin: `${index * TRACE_STAGGER_MS}ms`, repeatCount: "indefinite", calcMode: "spline", keyTimes: `0;${COMET_TRAVEL_SHARE};1`, keySplines: `${EASE_OUT};0 0 1 1`, }; return ( <linearGradient key={`comet-${index}`} id={`${id}-comet-${index}`} gradientUnits="userSpaceOnUse" x1={from.x} y1={from.y} x2={to.x} y2={to.y}> <stop offset="0" stopColor="currentColor" stopOpacity="0"> <animate attributeName="offset" values={`${-COMET_TAIL};1;1`} {...timing} /> </stop> <stop offset="0" stopColor="currentColor" stopOpacity="1"> <animate attributeName="offset" values={`0;${COMET_EXIT};${COMET_EXIT}`} {...timing} /> </stop> <stop offset="0" stopColor="currentColor" stopOpacity="0"> <animate attributeName="offset" values={`0;${COMET_EXIT};${COMET_EXIT}`} {...timing} /> </stop> </linearGradient> ); })} </defs> {TRACE_SHAPES.map(({ path }, index) => ( <path key={`base-${index}`} d={path} fill="none" stroke={`url(#${id}-trace-${index})`} strokeWidth={1} vectorEffect="non-scaling-stroke" /> ))} <g> {TRACE_SHAPES.map(({ path }, index) => ( <path key={`comet-${index}`} d={path} fill="none" stroke={`url(#${id}-comet-${index})`} strokeWidth={2} strokeLinecap="round" vectorEffect="non-scaling-stroke" /> ))} {TRACE_SHAPES.map(({ to }, index) => { const timing = { dur: `${TRACE_CYCLE_MS}ms`, begin: `${index * TRACE_STAGGER_MS}ms`, repeatCount: "indefinite", keyTimes: `0;${ARRIVAL_SHARE};${ARRIVAL_SHARE + 0.18};1`, }; return ( <circle key={`pulse-${index}`} cx={to.x} cy={to.y} r={0.45} fill="none" stroke="currentColor" strokeWidth={1} strokeOpacity={0} vectorEffect="non-scaling-stroke"> <animate attributeName="r" values="0.45;0.45;2.6;2.6" {...timing} /> <animate attributeName="stroke-opacity" values="0;0.9;0;0" {...timing} /> </circle> ); })} </g> {NODES.map((node, index) => ( <circle key={index} cx={node.x} cy={node.y} r={0.45} fill="currentColor" /> ))} </svg> </div> );}components/visuals/hero-globe-geometry.ts
export const RADIUS = 100;const MERIDIAN_STEP_DEGREES = 15;// Only the crown shows under the hero, so the parallels are listed for it rather than stepped round the whole sphere.const PARALLEL_LATITUDES = [75, 60, 50];const SAMPLES = 96;export const VIEWBOX_SIZE = RADIUS * 2 + 2;export const WIDTH = "min(106rem,200vw)";/** * How high the globe's crown rises into the hero. Set on the hero (or anything around the globe and its stats) to place * other layers against the crown; HeroGlobe and HeroGlobeStats carry it themselves by default. */export const HERO_GLOBE_RISE_CLASS = "[--hero-globe-rise:11rem] sm:[--hero-globe-rise:15rem]";interface Projected { x: number; y: number; facing: boolean;}// Orthographic projection seen side-on at the equator: the pole sits on the silhouette's crown, the meridians arc down// from it and the parallels flatten into straight lines.function project(latitude: number, longitude: number): Projected { const x = RADIUS * Math.cos(latitude) * Math.sin(longitude); const y = -RADIUS * Math.sin(latitude); return { x, y, facing: Math.cos(longitude) >= 0 };}// Only the side facing the viewer is drawn; each run of visible samples becomes its own subpath.function visiblePath(points: Projected[]) { let path = ""; let drawing = false; for (const point of points) { if (!point.facing) { drawing = false; continue; } path += `${drawing ? "L" : "M"}${point.x.toFixed(2)} ${point.y.toFixed(2)}`; drawing = true; } return path;}function degrees(value: number) { return (value * Math.PI) / 180;}function sample(count: number, from: number, to: number) { return Array.from({ length: count + 1 }, (_, index) => from + ((to - from) * index) / count);}const MERIDIANS = sample(360 / MERIDIAN_STEP_DEGREES - 1, 0, 360 - MERIDIAN_STEP_DEGREES).map((longitude) => visiblePath(sample(SAMPLES, -90, 90).map((latitude) => project(degrees(latitude), degrees(longitude)))),);const PARALLELS = PARALLEL_LATITUDES.map((latitude) => visiblePath(sample(SAMPLES * 2, -180, 180).map((longitude) => project(degrees(latitude), degrees(longitude)))));export const GLOBE_PATH = [...MERIDIANS, ...PARALLELS].join("");export interface GridPoint { latitude: number; longitude: number;}// Each trace runs along one grid line, from its first point to its last, so it lies on the wireframe the whole way.const TRACES: GridPoint[][] = [ [ { latitude: 90, longitude: -15 }, { latitude: 60, longitude: -15 }, ], [ { latitude: 60, longitude: -45 }, { latitude: 60, longitude: -15 }, ], [ { latitude: 60, longitude: 0 }, { latitude: 50, longitude: 0 }, ], [ { latitude: 60, longitude: 45 }, { latitude: 50, longitude: 45 }, ], [ { latitude: 50, longitude: -45 }, { latitude: 60, longitude: -45 }, ], [ { latitude: 60, longitude: 0 }, { latitude: 60, longitude: 45 }, ],];function pointOnGlobe({ latitude, longitude }: GridPoint) { return project(degrees(latitude), degrees(longitude));}function tracePath([from, to]: GridPoint[]) { const points = sample(SAMPLES / 2, 0, 1).map((t) => pointOnGlobe({ latitude: from.latitude + (to.latitude - from.latitude) * t, longitude: from.longitude + (to.longitude - from.longitude) * t }), ); return points.map((point, index) => `${index === 0 ? "M" : "L"}${point.x.toFixed(2)} ${point.y.toFixed(2)}`).join("");}export const TRACE_SHAPES = TRACES.map((trace) => ({ path: tracePath(trace), from: pointOnGlobe(trace[0]), to: pointOnGlobe(trace[1]) }));export const NODES = Array.from(new Map(TRACES.flat().map((point) => [`${point.latitude === 90 ? 90 : `${point.latitude}:${point.longitude}`}`, pointOnGlobe(point)])).values());/** Where a grid point lands in the hero: its distance in from the hero's center line and up from the hero's foot. */export function heroGlobePointPlacement(point: GridPoint) { const { x, y } = pointOnGlobe(point); const unit = `(${WIDTH} / ${VIEWBOX_SIZE})`; return { fromCenter: `(${Math.abs(x).toFixed(2)} * ${unit})`, fromFoot: `(var(--hero-globe-rise) - ${(y + RADIUS + 1).toFixed(2)} * ${unit})`, };}components/visuals/hero-globe-stats.tsx
import type * as React from "react";import { cn } from "@/lib/utils";import { type GridPoint, HERO_GLOBE_RISE_CLASS, heroGlobePointPlacement } from "./hero-globe-geometry";type Side = "left" | "right";type Tier = "upper" | "lower";export interface HeroGlobeStat { value: string; label: string; side: Side; /** `upper` stands level with the demo, beside it; `lower` sits below it, near the hero's foot. */ tier: Tier; /** The globe node its hairline runs to. */ node: GridPoint;}const DEFAULT_STATS: HeroGlobeStat[] = [ { value: "76.0%", label: "WebCode groundedness, highest of six tools", side: "left", tier: "upper", node: { latitude: 60, longitude: -45 } }, { value: "68%", label: "Fewer tokens than Zilliz, at higher accuracy", side: "left", tier: "lower", node: { latitude: 50, longitude: -45 } }, { value: "99.9%", label: "Uptime SLA on Enterprise", side: "right", tier: "upper", node: { latitude: 60, longitude: 45 } }, { value: "1,000", label: "Free credits every month", side: "right", tier: "lower", node: { latitude: 50, longitude: 45 } },];// How far the frame's inner verticals sit in from the screen edge (the ruled column is min(85vw, column-wide) on wide// screens), and how much further out its outer verticals sit.const FRAME_GEOMETRY_CLASS = "[--hero-frame-gutter:calc((100vw_-_min(85vw,var(--ds-layout-column-wide)))_/_2)] [--hero-frame-chamfer:clamp(1.5rem,var(--hero-frame-gutter)_-_1rem,5rem)]";const WINDOW_WIDTH = "13rem";// The upper windows stand level with the demo card, so they keep clear of its half-width (28.125rem) by a gap.const UPPER_INNER_EDGE = "30.125rem";const UPPER_FOOT = "11rem";const LOWER_FOOT = "0.75rem";// The lower windows sit below the demo card, so they can straddle the frame's outer verticals.const LOWER_INSET = `max(1rem, var(--hero-frame-gutter) - var(--hero-frame-chamfer) - ${WINDOW_WIDTH} / 2)`;const RING_RADIUS = "0.3125rem";const RISER_HEIGHT = "1.25rem";function fromSide(side: Side, value: string): React.CSSProperties { return side === "left" ? { left: value } : { right: value };}function windowPlacement({ side, tier }: HeroGlobeStat): React.CSSProperties { const inset = tier === "upper" ? `calc(50% - ${UPPER_INNER_EDGE} - ${WINDOW_WIDTH})` : LOWER_INSET; return { ...fromSide(side, inset), bottom: tier === "upper" ? UPPER_FOOT : LOWER_FOOT };}function Leader({ stat }: { stat: HeroGlobeStat }) { const { fromCenter, fromFoot } = heroGlobePointPlacement(stat.node); const ringTop = `(${fromFoot} + ${RING_RADIUS} - 1px)`; const elbow = `(${ringTop} + ${RISER_HEIGHT})`; const ring = ( <> <span className="absolute size-2.5 rounded-full border border-current" style={{ ...fromSide(stat.side, `calc(50% - ${fromCenter} - ${RING_RADIUS})`), bottom: `calc(${fromFoot} - ${RING_RADIUS})` }} /> <span className="absolute w-px bg-current/50" style={{ ...fromSide(stat.side, `calc(50% - ${fromCenter} - 0.5px)`), bottom: `calc${ringTop}`, height: RISER_HEIGHT }} /> </> ); if (stat.tier === "lower") { const windowEdge = `(${LOWER_INSET} + ${WINDOW_WIDTH})`; return ( <> <span className="absolute h-px bg-current/50" style={{ ...fromSide(stat.side, `calc${windowEdge}`), width: `calc(50% - ${fromCenter} - ${windowEdge} + 0.5px)`, bottom: `calc${elbow}` }} /> {ring} </> ); } const anchorFromCenter = `(${UPPER_INNER_EDGE} + ${WINDOW_WIDTH} / 2)`; return ( <> <span className={cn("absolute border-b border-current/50", stat.side === "left" ? "border-l" : "border-r")} style={{ ...fromSide(stat.side, `calc(50% - ${anchorFromCenter})`), width: `calc(${anchorFromCenter} - ${fromCenter} + 0.5px)`, bottom: `calc${elbow}`, height: `calc(${UPPER_FOOT} - ${elbow})`, }} /> {ring} </> );}/** * Four stat windows flanking the globe's crown, each wired to a node on the globe by an elbowed hairline. Shown only on * screens 90rem and wider, where they clear a centered demo. The lines take `currentColor`. */export function HeroGlobeStats({ stats = DEFAULT_STATS, label = "Context.dev in numbers", className }: { stats?: HeroGlobeStat[]; label?: string; className?: string }) { return ( <div data-slot="hero-globe-stats" className={cn("pointer-events-none absolute inset-0 hidden overflow-hidden text-brand min-[90rem]:block", HERO_GLOBE_RISE_CLASS, FRAME_GEOMETRY_CLASS, className)} > <div aria-hidden="true"> {stats.map((stat) => ( <Leader key={stat.value} stat={stat} /> ))} </div> <ul aria-label={label}> {stats.map((stat) => ( <li key={stat.value} className="absolute" style={{ ...windowPlacement(stat), width: WINDOW_WIDTH }}> <div className="flex flex-col items-center gap-2 rounded-card bg-surface p-3.5 text-center text-fg-brand shadow-[0_0_0_var(--ds-space-2)_var(--ds-color-ring-tint)]"> <span className="font-data text-h2 leading-none font-medium tabular-nums">{stat.value}</span> <span className="text-body-sm text-fg-muted">{stat.label}</span> </div> </li> ))} </ul> </div> );}Reference: src/components/sections/shared/hero-globe.tsx; src/components/sections/shared/hero-globe-geometry.ts; src/components/sections/shared/hero-globe-stats.tsx.