Hero globeDesign system
VisualSite

Hero globe

Wireframe globe with animated paths and optional connected statistics on wide screens.

npx context-ds add hero-globe
On the site

Usage

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.