Hero stat morphsDesign system
VisualSite

Hero stat morphs

Animated hero figures that transition through a sequence of dot shapes on wide screens.

npx context-ds add hero-stat-morphs
On the site

Usage

Use it for

A Hero's background on screens 80rem and wider, beside a centered title. Shapes are built-in icons or { src } logos.

Avoid

  • Stat morphs and a globe in one hero
  • Stats without a source for the claim
  • Morphs inside a card

Dependencies: dot-engine, hero-pattern-hole, logo-dot-sampling

Example

examples/hero-stat-morphs.tsx
import { Hero } from "@/components/site/hero";import { Button } from "@/components/ui/button";import { HeroStatMorphs } from "@/components/visuals/hero-stat-morphs";export function Site() {  return (    <Hero      className="min-h-160"      background={<HeroStatMorphs />}      title="The web {context} API for agents"      accent="{context}"      description="One API for agents to scrape, search, monitor, and collect data from the web."      actions={        <Button asChild variant="pill">          <a href="https://context.dev/signup">Start for free</a>        </Button>      }    />  );}

Source

components/visuals/hero-stat-morphs.tsx
"use client";import * as React from "react";import { createDotField, DOT_ABSENT, DOT_HOLLOW, type DotField } from "@/components/visuals/dot-engine/dot-morph-dots";import { cn } from "@/lib/utils";import { HERO_PATTERN_HOLE_ATTRIBUTE } from "@/components/visuals/hero-pattern-hole";import { iconStates, isStatIcon, logoStates, numberStates, STAT_GRID, type StatIcon } from "./hero-stat-shapes";/** A built-in icon, or a logo image (sampled into dots, so a remote one must allow CORS). */export type HeroStatShape = StatIcon | { src: string };type Sweep = "fromLeft" | "fromRight" | "fromTop" | "fromCenter";export interface HeroStatMorph {  value: string;  label: string;  shapes: HeroStatShape[];  /** Where the figure sits in the hero, as classes (the inset variable keeps it just outside the ruled column). */  position: string;  /** Each figure keeps its own time, so the four drift in and out of step rather than ticking together. */  timing: { numberHoldsMs: number; shapeHoldsMs: number; sweepMs: number; startsAfterMs: number; sweep: Sweep };}// Each figure has its own run of shapes, so no two corners show the same thing at once. The site also runs the coding// agents' logos through them; pass `{ src }` shapes to add logos.const DEFAULT_STATS: HeroStatMorph[] = [  {    value: "76.0%",    label: "WebCode groundedness, highest of six tools",    shapes: ["monitor", "chat", "envelope", "pdf", "code"],    position: "left-[var(--stat-inset)] top-[22%]",    timing: { numberHoldsMs: 4200, shapeHoldsMs: 2800, sweepMs: 1100, startsAfterMs: 0, sweep: "fromLeft" },  },  {    value: "68%",    label: "Fewer tokens than Zilliz, at higher accuracy",    shapes: ["search", "document", "lightning", "monitor", "code"],    position: "left-[var(--stat-inset)] top-[72%]",    timing: { numberHoldsMs: 5000, shapeHoldsMs: 3400, sweepMs: 1400, startsAfterMs: 1900, sweep: "fromCenter" },  },  {    value: "99.9%",    label: "Uptime SLA on Enterprise",    shapes: ["pdf", "chat", "search", "monitor", "lightning"],    position: "right-[var(--stat-inset)] top-[22%]",    timing: { numberHoldsMs: 4600, shapeHoldsMs: 3000, sweepMs: 1250, startsAfterMs: 1000, sweep: "fromTop" },  },  {    value: "1,000",    label: "Free credits every month",    shapes: ["envelope", "lightning", "code", "chat", "document"],    position: "right-[var(--stat-inset)] top-[72%]",    timing: { numberHoldsMs: 5400, shapeHoldsMs: 3700, sweepMs: 1500, startsAfterMs: 2800, sweep: "fromRight" },  },];const PITCH = 4;const DOT = { pitch: PITCH, dotRadius: PITCH * 0.4, ringStroke: 0.8, haloRadius: 0, easeSeconds: 0.08 };// A little scatter on top of the sweep, so its leading edge isn't a ruler line.const SWEEP_SCATTER_MS = 220;// The sweep's front turns every dot it crosses into a ring for a beat before it settles, so a change reads as a// band of rings traveling across the figure rather than dots quietly swapping.const RING_MS = 170;const RING_SCATTER_MS = 160;const SETTLE_ALLOWANCE_MS = 500;const MAX_PIXEL_RATIO = 2;const LONGEST_FRAME_SECONDS = 0.05;function scatter(cell: number) {  const h = Math.imul(cell ^ 0x9e3779b9, 2654435761) >>> 0;  return (h % 1000) / 1000;}function sweepShare(sweep: Sweep, cell: number) {  const column = cell % STAT_GRID.columns;  const row = Math.floor(cell / STAT_GRID.columns);  if (sweep === "fromLeft") return column / (STAT_GRID.columns - 1);  if (sweep === "fromRight") return 1 - column / (STAT_GRID.columns - 1);  if (sweep === "fromTop") return row / (STAT_GRID.rows - 1);  const dx = (column - (STAT_GRID.columns - 1) / 2) / STAT_GRID.columns;  const dy = (row - (STAT_GRID.rows - 1) / 2) / STAT_GRID.columns;  return Math.min(1, Math.hypot(dx, dy) * 2);}const easeInOut = (share: number) => (share < 0.5 ? 2 * share * share : 1 - (-2 * share + 2) ** 2 / 2);function cellDelays({ sweep, sweepMs }: HeroStatMorph["timing"]) {  return Float32Array.from({ length: STAT_GRID.columns * STAT_GRID.rows }, (_, cell) => easeInOut(sweepShare(sweep, cell)) * sweepMs + scatter(cell) * SWEEP_SCATTER_MS);}function cellRingHolds() {  return Float32Array.from({ length: STAT_GRID.columns * STAT_GRID.rows }, (_, cell) => RING_MS + scatter(cell * 31 + 7) * RING_SCATTER_MS);}function ringedTarget(from: number, to: number, sinceFront: number, ringHold: number) {  if (sinceFront < 0) return from;  if (sinceFront < ringHold && (from !== DOT_ABSENT || to !== DOT_ABSENT)) return DOT_HOLLOW;  return to;}interface StatTrack {  timing: HeroStatMorph["timing"];  delays: Float32Array;  ringHolds: Float32Array;  canvas: HTMLCanvasElement;  label: HTMLElement;  field: DotField;  frames: Uint8Array[];  index: number;  from: Uint8Array;  targets: Uint8Array;  changedAt: number;  nextChangeAt: number;  isMoving: boolean;}// A logo that fails to load is left out of the run rather than stopping the figure.async function loadShapes(shapes: HeroStatShape[]) {  const states = await Promise.all(shapes.map((shape) => (typeof shape === "string" ? (isStatIcon(shape) ? iconStates(shape) : null) : logoStates(shape.src).catch(() => null))));  return states.filter((state): state is Uint8Array => state !== null);}function fitCanvas(canvas: HTMLCanvasElement) {  const pixelRatio = Math.min(window.devicePixelRatio || 1, MAX_PIXEL_RATIO);  canvas.width = Math.round(STAT_GRID.columns * PITCH * pixelRatio);  canvas.height = Math.round(STAT_GRID.rows * PITCH * pixelRatio);  return pixelRatio;}/** * The hero's figures in the brand dot grid, set in its open space with no card. Each holds, then melts across into a * run of shapes (a monitor, an envelope, a logo) and back, pausing briefly on every one. Shown on screens 80rem and * wider, drawn in `currentColor` with figures set in the heading face. It sleeps between changes, pauses off screen, * and holds the numbers still under reduced motion; screen readers get the figures as a list. */export function HeroStatMorphs({ stats = DEFAULT_STATS, label = "Context.dev in numbers", className }: { stats?: HeroStatMorph[]; label?: string; className?: string }) {  const rootRef = React.useRef<HTMLDivElement>(null);  const canvasRefs = React.useRef<(HTMLCanvasElement | null)[]>([]);  const labelRefs = React.useRef<(HTMLParagraphElement | null)[]>([]);  const statsRef = React.useRef(stats);  React.useEffect(() => {    statsRef.current = stats;  }, [stats]);  const statsKey = JSON.stringify(stats);  React.useEffect(() => {    const STATS = statsRef.current;    const root = rootRef.current;    const canvases = canvasRefs.current.slice(0, STATS.length);    const labels = labelRefs.current.slice(0, STATS.length);    if (!root || canvases.some((canvas) => !canvas) || labels.some((label) => !label)) return;    const color = getComputedStyle(root).color;    const palette = { dots: color, background: "transparent" };    let pixelRatio = 1;    const drawTrack = (track: StatTrack) => {      const context = track.canvas.getContext("2d");      if (!context) return;      context.setTransform(1, 0, 0, 1, 0, 0);      context.clearRect(0, 0, track.canvas.width, track.canvas.height);      context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);      track.field.draw(context, palette);    };    const fitAll = () => {      for (const canvas of canvases) pixelRatio = fitCanvas(canvas as HTMLCanvasElement);    };    if (STATS.length === 0) return;    fitAll();    let isDisposed = false;    let tracks: StatTrack[] = [];    let isVisible = true;    let frame = 0;    let lastFrameAt = 0;    let wakeTimer = 0;    const holdFor = (track: StatTrack) => (track.index === 0 ? track.timing.numberHoldsMs : track.timing.shapeHoldsMs);    const step = (now: number) => {      frame = 0;      const seconds = Math.min(LONGEST_FRAME_SECONDS, lastFrameAt ? (now - lastFrameAt) / 1000 : 0);      lastFrameAt = now;      for (const track of tracks) {        if (!track.isMoving && now >= track.nextChangeAt) {          track.from = track.frames[track.index];          track.index = (track.index + 1) % track.frames.length;          // The label names the figure, so it only shows while the figure is the number rather than a shape.          track.label.style.opacity = track.index === 0 ? "1" : "0";          track.changedAt = now;          track.isMoving = true;        }        if (!track.isMoving) continue;        const next = track.frames[track.index];        const sinceChange = now - track.changedAt;        for (let cell = 0; cell < next.length; cell++) track.targets[cell] = ringedTarget(track.from[cell], next[cell], sinceChange - track.delays[cell], track.ringHolds[cell]);        const isSettled = track.field.advance(track.targets, seconds);        drawTrack(track);        if (isSettled && sinceChange >= track.timing.sweepMs + SWEEP_SCATTER_MS + RING_MS + RING_SCATTER_MS + SETTLE_ALLOWANCE_MS) {          track.isMoving = false;          track.nextChangeAt = now + holdFor(track);        }      }      schedule(now);    };    // Between changes nothing moves, so the loop sleeps until the next corner is due rather than drawing still frames.    const schedule = (now: number) => {      if (isDisposed || !isVisible || document.hidden) return;      if (tracks.some((track) => track.isMoving)) {        frame = requestAnimationFrame(step);        return;      }      lastFrameAt = 0;      const due = Math.min(...tracks.map((track) => track.nextChangeAt));      window.clearTimeout(wakeTimer);      wakeTimer = window.setTimeout(() => (frame = requestAnimationFrame(step)), Math.max(0, due - now));    };    const wake = () => {      cancelAnimationFrame(frame);      window.clearTimeout(wakeTimer);      // Picking up after a pause, every corner waits out a fresh hold instead of all changing at once.      const now = performance.now();      for (const track of tracks) {        if (!track.isMoving) track.nextChangeAt = now + holdFor(track) + track.timing.startsAfterMs;      }      schedule(now);    };    const visibility = new IntersectionObserver(([entry]) => {      isVisible = entry.isIntersecting;      if (isVisible) wake();    });    const onVisibilityChange = () => {      if (!document.hidden) wake();    };    const onResize = () => {      fitAll();      tracks.forEach(drawTrack);    };    // The figures are set in the heading face, so they wait for it rather than rasterizing a fallback.    void document.fonts.ready      .then(async () => {        const fontFamily = getComputedStyle(root).fontFamily;        const numbers = STATS.map((stat) => numberStates(stat.value, fontFamily));        tracks = STATS.map((stat, index) => {          const field = createDotField({ ...STAT_GRID, ...DOT });          field.seed(numbers[index]);          return {            timing: stat.timing,            delays: cellDelays(stat.timing),            ringHolds: cellRingHolds(),            canvas: canvases[index] as HTMLCanvasElement,            label: labels[index] as HTMLElement,            field,            frames: [numbers[index]],            index: 0,            from: numbers[index],            targets: new Uint8Array(numbers[index]),            changedAt: 0,            nextChangeAt: Infinity,            isMoving: false,          };        });        tracks.forEach(drawTrack);        if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;        const shapeSets = await Promise.all(STATS.map((stat) => loadShapes(stat.shapes)));        if (isDisposed) return;        shapeSets.forEach((shapes, index) => (tracks[index].frames = [numbers[index], ...shapes]));        visibility.observe(root);        document.addEventListener("visibilitychange", onVisibilityChange);        window.addEventListener("resize", onResize);      })      .catch(() => {        // Without its shapes each figure simply stays put.      });    return () => {      isDisposed = true;      cancelAnimationFrame(frame);      window.clearTimeout(wakeTimer);      visibility.disconnect();      document.removeEventListener("visibilitychange", onVisibilityChange);      window.removeEventListener("resize", onResize);    };  }, [statsKey]);  return (    <div      ref={rootRef}      data-slot="hero-stat-morphs"      className={cn(        "pointer-events-none absolute inset-0 hidden font-sans text-brand min-[80rem]:block",        "[--stat-inset:calc((100vw_-_min(85vw,var(--ds-layout-column-wide)))_/_2_+_2.5rem)]",        className,      )}    >      <ul className="sr-only" aria-label={label}>        {stats.map((stat) => (          <li key={stat.value}>            {stat.value}: {stat.label}          </li>        ))}      </ul>      {stats.map((stat, index) => (        <div          key={stat.value}          aria-hidden="true"          {...{ [HERO_PATTERN_HOLE_ATTRIBUTE]: "box" }}          className={cn("absolute flex flex-col items-center gap-2", stat.position)}          style={{ width: STAT_GRID.columns * PITCH }}        >          <canvas            ref={(canvas) => {              canvasRefs.current[index] = canvas;            }}            style={{ width: STAT_GRID.columns * PITCH, height: STAT_GRID.rows * PITCH }}          />          <p            ref={(label) => {              labelRefs.current[index] = label;            }}            className="text-center text-caption leading-snug text-balance text-fg-subtle transition-opacity duration-300 ease-out motion-reduce:transition-none"          >            {stat.label}          </p>        </div>      ))}    </div>  );}
components/visuals/hero-stat-shapes.ts
import { DOT_ABSENT, DOT_FILLED, DOT_HOLLOW } from "@/components/visuals/dot-engine/dot-morph-dots";import { sampleLogoCells } from "@/components/visuals/logo-dot-sampling";export const STAT_GRID = { columns: 44, rows: 20 };// Each cell is read from a small block of pixels, so a stroke that half-covers a cell still lands as a dot.const SUPERSAMPLE = 4;const STROKE_COVERAGE = 0.34;const FILL_COVERAGE = 0.5;/** * Icons in a 24-unit box, as SVG path data, drawn as dot outlines. Filling their insides with rings read as a gray * texture at this dot size and buried the inner strokes (an envelope's flap, a page's lines). */interface IconArt {  outline: string[];  solid?: string[];}const rect = (x: number, y: number, width: number, height: number, radius: number) =>  `M${x + radius} ${y}h${width - 2 * radius}a${radius} ${radius} 0 0 1 ${radius} ${radius}v${height - 2 * radius}a${radius} ${radius} 0 0 1 -${radius} ${radius}h-${width - 2 * radius}a${radius} ${radius} 0 0 1 -${radius} -${radius}v-${height - 2 * radius}a${radius} ${radius} 0 0 1 ${radius} -${radius}z`;const circle = (cx: number, cy: number, r: number) => `M${cx - r} ${cy}a${r} ${r} 0 1 0 ${2 * r} 0a${r} ${r} 0 1 0 -${2 * r} 0z`;export const STAT_ICONS = {  envelope: { outline: [rect(2, 4, 20, 16, 2), "M22 7l-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"] },  code: { outline: ["M16 18l6-6-6-6", "M8 6l-6 6 6 6", "M14 4l-4 16"] },  lightning: { outline: [], solid: ["M13 2L3 14h9l-1 8 10-12h-9l1-8z"] },  search: { outline: [circle(11, 11, 8), "M21 21l-4.3-4.3"] },  document: { outline: ["M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7z", "M14 2v4a2 2 0 0 0 2 2h4", "M16 13H8", "M16 17H8", "M10 9H8"] },  chat: { outline: ["M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"] },} satisfies Record<string, IconArt>;/** * Hand-placed dot art, one string per grid row: `#` a solid dot, `o` a ring, `.` no dot. Some shapes only read with * their rings placed by hand (a bezel's inner edge, a keyboard's keys), which no stroke sampling gets right at 20 rows. */const STAT_DOT_ART = {  monitor: [    "..######################..",    ".###oooooooooooooooooo###.",    ".##o..................o##.",    ".##o.#................o##.",    ".##o..#.#o#o#o#.......o##.",    ".##o.#................o##.",    ".##o..................o##.",    ".##o.o#o#o#o.##.......o##.",    ".##o..................o##.",    ".##oooooooooooooooooooo##.",    ".##oo#oo#oo#oo#oo#oo#oo##.",    "..######################..",    "..........######..........",    "......##############......",    "..........................",    ".....################.....",    "....#o.o.o.o.o.o.o.o.#....",    "...##.o.o.o.o.o.o.o.o##...",    ".##o.o.o.o.o.o.o.o.o.o.##.",    "##########################",  ],  pdf: [    "############.....",    "#...........##...",    "#.#######...###..",    "#.#######...####.",    "#...........#####",    "#.############..#",    "#...............#",    "#.############..#",    "#...............#",    "#.######..#####.#",    "#.######........#",    "#.######..####..#",    "#.##o#o#..oooo..#",    "#...............#",    "#.####.####.###.#",    "#.oooo.oooo.ooo.#",    "#.oooo.oooo.ooo.#",    "#.oooo.oooo.ooo.#",    "#...............#",    "#################",  ],} satisfies Record<string, string[]>;export type StatIcon = keyof typeof STAT_ICONS | keyof typeof STAT_DOT_ART;const ICON_UNITS = 24;const ICON_STROKE = 2.1;const ICON_RING_STROKE = ICON_STROKE + 1.6;function blankStates() {  return new Uint8Array(STAT_GRID.columns * STAT_GRID.rows).fill(DOT_ABSENT);}function createRaster() {  const canvas = document.createElement("canvas");  canvas.width = STAT_GRID.columns * SUPERSAMPLE;  canvas.height = STAT_GRID.rows * SUPERSAMPLE;  const context = canvas.getContext("2d", { willReadFrequently: true });  if (!context) throw new Error("2D canvas is unavailable");  return context;}function coverage(context: CanvasRenderingContext2D) {  const { data } = context.getImageData(0, 0, context.canvas.width, context.canvas.height);  const cells = new Float32Array(STAT_GRID.columns * STAT_GRID.rows);  for (let row = 0; row < STAT_GRID.rows; row++) {    for (let column = 0; column < STAT_GRID.columns; column++) {      let alpha = 0;      for (let y = 0; y < SUPERSAMPLE; y++) {        for (let x = 0; x < SUPERSAMPLE; x++) alpha += data[((row * SUPERSAMPLE + y) * context.canvas.width + column * SUPERSAMPLE + x) * 4 + 3];      }      cells[row * STAT_GRID.columns + column] = alpha / (255 * SUPERSAMPLE * SUPERSAMPLE);    }  }  return cells;}function iconCoverage(art: IconArt, lineWidth: number) {  const context = createRaster();  const scale = (STAT_GRID.rows * SUPERSAMPLE) / ICON_UNITS;  const offsetX = (context.canvas.width - ICON_UNITS * scale) / 2;  context.setTransform(scale, 0, 0, scale, offsetX, 0);  context.lineWidth = lineWidth;  context.lineCap = "round";  context.lineJoin = "round";  for (const path of art.outline) context.stroke(new Path2D(path));  for (const path of art.solid ?? []) {    context.fill(new Path2D(path));    if (lineWidth > ICON_STROKE) context.stroke(new Path2D(path));  }  return coverage(context);}/** Strokes land as solid dots with a band of rings alongside, the two-tone line the brand's dot art is drawn in. */function dotArtStates(art: string[]) {  const states = blankStates();  const top = Math.floor((STAT_GRID.rows - art.length) / 2);  for (const [rowIndex, row] of art.entries()) {    const left = Math.floor((STAT_GRID.columns - row.length) / 2);    for (const [columnIndex, mark] of [...row].entries()) {      const cell = (top + rowIndex) * STAT_GRID.columns + left + columnIndex;      if (mark === "#") states[cell] = DOT_FILLED;      else if (mark === "o") states[cell] = DOT_HOLLOW;    }  }  return states;}export function isStatIcon(shape: string): shape is StatIcon {  return shape in STAT_ICONS || shape in STAT_DOT_ART;}export function iconStates(icon: StatIcon): Uint8Array {  if (icon in STAT_DOT_ART) return dotArtStates(STAT_DOT_ART[icon as keyof typeof STAT_DOT_ART]);  const art: IconArt = STAT_ICONS[icon as keyof typeof STAT_ICONS];  const stroke = iconCoverage(art, ICON_STROKE);  const ring = iconCoverage(art, ICON_RING_STROKE);  const states = blankStates();  for (let cell = 0; cell < states.length; cell++) {    if (stroke[cell] >= STROKE_COVERAGE) states[cell] = DOT_FILLED;    else if (ring[cell] >= STROKE_COVERAGE) states[cell] = DOT_HOLLOW;  }  return states;}/** Sets a figure in the page's heading face at the grid's full height, then reads it back as solid dots. */export function numberStates(value: string, fontFamily: string): Uint8Array {  const context = createRaster();  const height = context.canvas.height;  let size = height * 0.95;  context.textBaseline = "alphabetic";  const setFont = () => (context.font = `700 ${size}px ${fontFamily}`);  setFont();  const fits = () => {    const metrics = context.measureText(value);    return { width: metrics.width, ascent: metrics.actualBoundingBoxAscent, descent: metrics.actualBoundingBoxDescent };  };  let box = fits();  const scale = Math.min((context.canvas.width * 0.96) / box.width, (height * 0.92) / (box.ascent + box.descent));  size *= scale;  setFont();  box = fits();  context.fillText(value, (context.canvas.width - box.width) / 2, (height + box.ascent - box.descent) / 2);  const cells = coverage(context);  const states = blankStates();  for (let cell = 0; cell < states.length; cell++) if (cells[cell] >= FILL_COVERAGE) states[cell] = DOT_FILLED;  return states;}const LOGO_CELLS = STAT_GRID.rows - 2;export async function logoStates(src: string): Promise<Uint8Array> {  const cells = await sampleLogoCells(src, { cellsPerSide: LOGO_CELLS });  const states = blankStates();  const left = Math.floor((STAT_GRID.columns - LOGO_CELLS) / 2);  const top = Math.floor((STAT_GRID.rows - LOGO_CELLS) / 2);  for (const cell of cells) states[(top + cell.row) * STAT_GRID.columns + left + cell.column] = cell.filled ? DOT_FILLED : DOT_HOLLOW;  return states;}

Reference: src/components/sections/shared/hero-stat-morphs.tsx; src/components/sections/shared/hero-stat-shapes.ts.