# Stats band

> Headline numbers that count up in view, each with a tiny visual that proves it and a context line on hover, plain or in a hairline grid.

A shadcn/ui block from the [@uiarc](https://uiarc.dev) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/uiarc/stats-band).

- Page, with a live preview: https://shadcn.exchange/r/uiarc/stats-band
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/uiarc/stats-band-light-bb56db8eb6fe7503.png
- Registry item: https://uiarc.dev/r/stats-band.json
- Categories: blocks
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://uiarc.dev/r/stats-band.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `motion`
- Registry items: `https://uiarc.dev/r/arc-motion-tokens.json`, `https://uiarc.dev/r/segmented-control.json`

## Notes from the registry

Use this under a hero or between sections to back a claim with numbers. Replace the sample stats in stats-band-data.ts.

## Files

### `registry/blocks/stats-band/stats-band.tsx`

```tsx
"use client";

import { forwardRef, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import type { CSSProperties } from "react";
import { animate, useInView } from "motion/react";
import SegmentedControl from "../../segmented-control/segmented-control";
import { motionTokens } from "../../lib/motion-tokens";
import { stats as exampleStats, type Stat, type StatVisual } from "./stats-band-data";
import { WORLD, worldLand } from "./stats-band-world";
import styles from "./stats-band.module.css";

export type { Stat, StatVisual } from "./stats-band-data";
export type StatsBandLayout = "plain" | "divided";

export interface StatsBandProps {
  /** Three or four stats read best. */
  stats?: Stat[];
  /** `plain` lets the stats float on whitespace; `divided` sets them in a hairline grid ruled above and below. */
  layout?: StatsBandLayout;
  title?: string;
  description?: string;
  /** Seconds each number takes to count up. Defaults to 1.6. */
  duration?: number;
  /** Number formatting locale. Fixed by default so server and client agree. */
  locale?: string;
  className?: string;
}

type Bezier = [number, number, number, number];
const enter = [...motionTokens.ease.enter] as Bezier;
const STAGGER = .09;

const REDUCE = "(prefers-reduced-motion: reduce)";
const subscribeReduced = (onChange: () => void) => {
  const query = window.matchMedia(REDUCE);
  query.addEventListener("change", onChange);
  return () => query.removeEventListener("change", onChange);
};
/** Reduced motion, read after hydration so the server and first client render agree. CSS covers the first paint. */
function useReducedMotionSafe() {
  return useSyncExternalStore(subscribeReduced, () => window.matchMedia(REDUCE).matches, () => false);
}

/**
 * Formats a stat's number, split from its compact unit so the unit can sit beside the digits like a suffix. A compact
 * stat counts inside its final unit (0.0M to 2.3M) instead of jumping from K to M halfway through.
 */
function useFormat(stat: Stat, locale: string) {
  return useMemo(() => {
    const decimals = stat.decimals ?? 0;
    const digits = new Intl.NumberFormat(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals, numberingSystem: "latn" });
    if (stat.notation !== "compact") return { unit: "", format: (value: number) => digits.format(value) };
    const size = Math.abs(stat.value);
    const scale = size >= 1e12 ? 1e12 : size >= 1e9 ? 1e9 : size >= 1e6 ? 1e6 : size >= 1e3 ? 1e3 : 1;
    const unit = scale === 1 ? "" : new Intl.NumberFormat(locale, { notation: "compact" }).formatToParts(scale).find(part => part.type === "compact")?.value ?? "";
    return { unit, format: (value: number) => digits.format(value / scale) };
  }, [stat.decimals, stat.notation, stat.value, locale]);
}

/**
 * One number that counts from zero to its value the first time the band is in view. The final value reserves the
 * width underneath, so the layout never moves while digits change. Screen readers only get the final value.
 */
function CountUp({ stat, run, delay, duration, reduced, locale }: { stat: Stat; run: boolean; delay: number; duration: number; reduced: boolean; locale: string }) {
  const live = useRef<HTMLSpanElement>(null);
  const done = useRef(false);
  const { unit, format } = useFormat(stat, locale);
  const final = format(stat.value);
  const suffix = `${unit}${stat.suffix ?? ""}`;

  useLayoutEffect(() => {
    if (reduced || done.current || !live.current) return;
    live.current.textContent = format(0);
  }, [format, reduced]);

  useEffect(() => {
    const node = live.current;
    if (!node) return;
    if (reduced) { node.textContent = final; done.current = true; return; }
    if (!run || done.current) return;
    const controls = animate(0, stat.value, {
      duration,
      delay,
      ease: enter,
      onUpdate: latest => { node.textContent = format(latest); },
      onComplete: () => { node.textContent = final; done.current = true; },
    });
    return () => controls.stop();
  }, [run, reduced, stat.value, delay, duration, format, final]);

  return <>
    <span className={styles.srOnly}>{stat.prefix}{final}{suffix}</span>
    <span className={styles.number} aria-hidden="true">
      {stat.prefix && <span className={styles.prefix}>{stat.prefix}</span>}
      <span className={styles.digits}>
        <span className={styles.sizer}>{final}</span>
        <span ref={live} className={styles.live}>{final}</span>
      </span>
      {suffix && <span className={styles.suffix}>{suffix}</span>}
    </span>
  </>;
}

/* Visuals. Each one is drawn at rest; CSS hides it until the band is in view, then draws it in. */

function Trend({ values }: { values: number[] }) {
  const min = Math.min(...values), max = Math.max(...values);
  const span = max - min || 1;
  const points = values.map((value, index) => [values.length > 1 ? index / (values.length - 1) * 100 : 100, 36 - (value - min) / span * 32] as const);
  const line = points.map(([x, y], index) => `${index ? "L" : "M"}${x.toFixed(2)} ${y.toFixed(2)}`).join(" ");
  const [endX, endY] = points[points.length - 1]!;
  return <div className={styles.trend}>
    <svg className={styles.draw} viewBox="0 0 100 40" preserveAspectRatio="none">
      <path className={styles.area} d={`${line} L100 40 L0 40 Z`} />
      <path className={styles.line} d={line} vectorEffect="non-scaling-stroke" />
    </svg>
    <span className={styles.endDot} style={{ left: `${endX}%`, top: `${endY / 40 * 100}%` }} />
  </div>;
}

function Uptime({ days }: { days: number[] }) {
  return <div className={styles.bars} data-dense="">
    {days.map((uptime, index) => <span key={index} className={styles.bar} data-ink={uptime < 100 ? "" : undefined}
      style={{ "--j": index / days.length, "--h": Math.min(1, Math.max(.3, 1 - (100 - uptime) * .7)) } as CSSProperties} />)}
  </div>;
}

function Distribution({ bins, max, marker }: { bins: number[]; max: number; marker: number }) {
  const peak = Math.max(...bins) || 1;
  const width = max / bins.length;
  return <div className={styles.bars}>
    {bins.map((count, index) => <span key={index} className={styles.bar} data-ink={index * width < marker ? "" : undefined}
      style={{ "--j": index / bins.length, "--h": Math.max(.04, count / peak) } as CSSProperties} />)}
  </div>;
}

const cellOf = (lon: number, lat: number) => {
  const x = Math.floor((((lon - WORLD.west) % 360 + 360) % 360) / (WORLD.east - WORLD.west) * WORLD.columns);
  const y = Math.floor((WORLD.north - lat) / (WORLD.north - WORLD.south) * WORLD.rows);
  return [Math.min(WORLD.columns - 1, Math.max(0, x)), Math.min(WORLD.rows - 1, Math.max(0, y))] as const;
};

/** Snaps a point to the nearest land dot, so a coastal city never lights a dot in the sea. */
function nearestLand(lon: number, lat: number) {
  const [x, y] = cellOf(lon, lat);
  let best = y * WORLD.columns + x, distance = Infinity;
  for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) {
    const cx = x + dx, cy = y + dy;
    if (cx < 0 || cy < 0 || cx >= WORLD.columns || cy >= WORLD.rows || !worldLand[cy * WORLD.columns + cx]) continue;
    const d = dx * dx + dy * dy;
    if (d < distance) { distance = d; best = cy * WORLD.columns + cx; }
  }
  return best;
}

function WorldMap({ points }: { points: [number, number][] }) {
  const lit = useMemo(() => new Set(points.map(([lon, lat]) => nearestLand(lon, lat))), [points]);
  return <div className={styles.map}>
    <svg className={styles.draw} viewBox={`0 0 ${WORLD.columns} ${WORLD.rows}`}>
      {worldLand.map((land, index) => land && !lit.has(index) && <circle key={index} className={styles.land} cx={index % WORLD.columns + .5} cy={Math.floor(index / WORLD.columns) + .5} r={.3} />)}
      {[...lit].map(index => <circle key={index} className={styles.lit} cx={index % WORLD.columns + .5} cy={Math.floor(index / WORLD.columns) + .5} r={.5} />)}
    </svg>
  </div>;
}

function Visual({ visual }: { visual: StatVisual }) {
  switch (visual.kind) {
    case "trend": return <Trend values={visual.values} />;
    case "uptime": return <Uptime days={visual.days} />;
    case "distribution": return <Distribution bins={visual.bins} max={visual.max} marker={visual.marker} />;
    case "map": return <WorldMap points={visual.points} />;
  }
}

/**
 * A band of three or four headline numbers, each with an optional tiny visual that proves it: a trend, an uptime strip,
 * a latency histogram, or a dotted world. The numbers count up in a staggered sequence the first time the band is in
 * view, then the visuals draw in. Hovering or focusing a stat swaps its detail line for one line of context and gives
 * its visual the accent. Numbers use tabular figures and reserve their final width; screen readers get final values.
 */
export const StatsBand = forwardRef<HTMLElement, StatsBandProps>(function StatsBand({
  stats = exampleStats,
  layout = "plain",
  title,
  description,
  duration = 1.6,
  locale = "en-US",
  className,
}, ref) {
  const id = useId();
  const list = useRef<HTMLDListElement>(null);
  const inView = useInView(list, { once: true, amount: .4 });
  const reduced = useReducedMotionSafe();
  const withVisuals = stats.some(stat => stat.visual);
  return <section ref={ref} className={[styles.band, className].filter(Boolean).join(" ")} data-layout={layout} data-in-view={inView || reduced ? "" : undefined}
    aria-labelledby={title ? `${id}-title` : undefined} aria-label={title ? undefined : "Key numbers"}>
    <div className={styles.inner}>
      {(title || description) && <header className={styles.header}>
        {title && <h2 id={`${id}-title`} className={styles.title}>{title}</h2>}
        {description && <p className={styles.description}>{description}</p>}
      </header>}
      <dl ref={list} className={styles.stats} data-count={Math.min(stats.length, 4)} data-visuals={withVisuals ? "" : undefined}
        style={{ "--count": Math.min(stats.length, 4), "--draw": `${Math.round(duration * 420)}ms` } as CSSProperties}>
        {stats.map((stat, index) => <div key={`${stat.label}-${index}`} className={styles.stat} style={{ "--i": index } as CSSProperties}
          tabIndex={stat.context ? 0 : undefined} data-context={stat.context && stat.detail ? "" : undefined}>
          <dt className={styles.label}>{stat.label}</dt>
          <dd className={styles.figure}>
            <CountUp stat={stat} run={inView} delay={index * STAGGER} duration={duration} reduced={reduced} locale={locale} />
          </dd>
          {(stat.detail || stat.context) && <dd className={styles.caption}>
            {stat.detail && <span className={styles.detail}>{stat.detail}</span>}
            {stat.context && <span className={styles.context}>{stat.context}</span>}
          </dd>}
          {withVisuals && <dd className={styles.visual} aria-hidden="true">{stat.visual && <Visual visual={stat.visual} />}</dd>}
        </div>)}
      </dl>
    </div>
  </section>;
});

StatsBand.displayName = "StatsBand";

const layoutOptions = [{ value: "divided", label: "Divided" }, { value: "plain", label: "Plain" }];

/** Preview: the band in either layout. Switching layouts plays the sequence again. */
export function StatsBandBlock() {
  const [layout, setLayout] = useState<StatsBandLayout>("divided");
  return <div className={styles.preview}>
    <SegmentedControl label="Stats layout" options={layoutOptions} value={layout} onValueChange={next => setLayout(next as StatsBandLayout)} />
    <div className={styles.frame}>
      <StatsBand key={layout} layout={layout} title="Fast, steady, and close to everyone" description="Measured across every region from October 2025 to September 2026." />
    </div>
  </div>;
}

export default StatsBandBlock;
```

### `registry/blocks/stats-band/stats-band-data.ts`

```ts
/**
 * A small visual that proves the number beside it. Every kind is optional; a stat without one is just the number.
 * - `trend`: a sparkline of a series, oldest first, such as twelve monthly totals.
 * - `uptime`: one bar per day as a percentage. Full days are full height; a day with downtime is visibly shorter.
 * - `distribution`: a histogram of equal width bins across `0..max`, with the bars below `marker` (the median) inked.
 * - `map`: a dotted world with a dot lit for each [longitude, latitude] point.
 */
export type StatVisual =
  | { kind: "trend"; values: number[] }
  | { kind: "uptime"; days: number[] }
  | { kind: "distribution"; bins: number[]; max: number; marker: number }
  | { kind: "map"; points: [number, number][] };

export interface Stat {
  /** The final number. The band counts up to it. */
  value: number;
  /** Text before the number, such as "$". */
  prefix?: string;
  /** Text after the number, such as "%", "ms", or "+". */
  suffix?: string;
  /** Digits after the decimal point. Defaults to 0. */
  decimals?: number;
  /** `compact` shortens large numbers to 2.3M or 12K. The unit renders like a suffix. Defaults to `standard`. */
  notation?: "standard" | "compact";
  label: string;
  /** One short supporting line under the number. */
  detail?: string;
  /** One line of context that replaces the detail while the stat is hovered or focused, such as "Up from 41% last year". */
  context?: string;
  /** A tiny chart that draws in after the number lands. */
  visual?: StatVisual;
}

/** 90 days of uptime, oldest first: two short incidents, 11 minutes on day 52 and 1 minute on day 24. */
const uptimeDays = Array.from({ length: 90 }, (_, day) => day === 51 ? 99.24 : day === 23 ? 99.93 : 100);

/** 35 edge regions as [longitude, latitude]. */
const regions: [number, number][] = [
  [-122.4, 37.8], [-118.2, 34], [-96.8, 32.8], [-87.6, 41.9], [-77.5, 39], [-74, 40.7], [-79.4, 43.7], [-99.1, 19.4],
  [-74.1, 4.7], [-77, -12], [-46.6, -23.5], [-70.7, -33.4], [-58.4, -34.6],
  [-0.1, 51.5], [-6.3, 53.3], [2.35, 48.9], [4.9, 52.4], [8.7, 50.1], [18, 59.3], [-3.7, 40.4], [9.2, 45.5], [21, 52.2],
  [3.4, 6.5], [28, -26.2], [36.8, -1.3], [55.3, 25.3], [34.8, 32.1],
  [72.9, 19.1], [103.8, 1.35], [114.2, 22.3], [139.7, 35.7], [127, 37.6], [106.8, -6.2], [151.2, -33.9], [174.8, -36.8],
];

export const stats: Stat[] = [
  {
    value: 2_334_000, notation: "compact", decimals: 1, label: "Deploys in the last 12 months",
    detail: "Across 12,400 teams", context: "Up from 1.4M the year before",
    visual: { kind: "trend", values: [141, 148, 139, 162, 171, 184, 196, 203, 221, 238, 257, 274] },
  },
  {
    value: 99.99, decimals: 2, suffix: "%", label: "API uptime over 90 days",
    detail: "Measured every 30 seconds", context: "12 minutes down, 11 of them on July 14",
    visual: { kind: "uptime", days: uptimeDays },
  },
  {
    value: 38, suffix: "ms", label: "Median response time",
    detail: "At the edge, worldwide", context: "p95 is 96 ms, down from 141 ms",
    visual: { kind: "distribution", bins: [2, 8, 18, 26, 15, 10, 7, 5, 3, 2, 1.5, 1, .8, .7, .5, .3], max: 160, marker: 38 },
  },
  {
    value: 35, label: "Edge regions on six continents",
    detail: "Most people are under 20 ms away", context: "Eight added this year, including Lagos",
    visual: { kind: "map", points: regions },
  },
];
```

### `registry/blocks/stats-band/stats-band.module.css`

```css
.band { container: stats / inline-size; background: var(--background); color: var(--foreground); font-family: var(--font-body); letter-spacing: var(--tracking-body); }
.band *, .band *::before, .band *::after { box-sizing: border-box; }
.srOnly { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

.inner { max-width: 1120px; margin: 0 auto; padding: var(--space-24) var(--space-8); }

/* Header: the title on the left, the note on the right, sharing a last baseline. */
.header { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); align-items: last baseline; gap: var(--space-4) var(--space-12); margin-bottom: var(--space-16); }
.title { max-width: 16ch; margin: 0; font-family: var(--font-display); font-size: clamp(1.875rem, 1rem + 2.6cqi, var(--text-4xl)); font-weight: 500; letter-spacing: var(--tracking-display); line-height: var(--leading-display); text-wrap: balance; }
.description { max-width: 34ch; margin: 0; justify-self: end; color: var(--text-secondary); font-size: var(--text-base); line-height: 1.5; text-wrap: pretty; }
.header:not(:has(.title)) { grid-template-columns: minmax(0, 1fr); }
.header:not(:has(.title)) .description { justify-self: start; }

/* The grid. Every stat has the same rows, so numbers, labels, and visuals line up across the band. */
.stats { position: relative; display: grid; grid-template-columns: repeat(var(--count), minmax(0, 1fr)); column-gap: var(--space-10); row-gap: var(--space-16); margin: 0; }
.stat {
  --start: calc(var(--i) * 90ms + var(--draw));
  --ink: var(--text-secondary);
  --soft: var(--border-strong);
  position: relative; display: flex; min-width: 0; flex-direction: column; cursor: default; -webkit-tap-highlight-color: transparent;
}
.stat:focus { outline: none; }
.figure { order: -1; margin: 0 0 var(--space-5); }
.label { color: var(--foreground); font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); text-wrap: pretty; }
.caption { display: grid; margin: 2px 0 0; color: var(--text-muted); font-size: var(--text-sm); line-height: var(--leading-body); }
.detail, .context { grid-area: 1 / 1; min-width: 0; text-wrap: pretty; transition: opacity var(--duration-standard) var(--ease-standard), transform var(--duration-standard) var(--ease-standard); }
.stat[data-context] .context { opacity: 0; transform: translateY(4px); }
.context { color: var(--text-secondary); }

/* The number: display type, tabular digits, the unit smaller and quieter on the same baseline. */
.number { display: inline-flex; align-items: baseline; font-family: var(--font-display); font-size: clamp(2.5rem, 1.4rem + 3cqi, var(--text-4xl)); font-weight: 500; letter-spacing: var(--tracking-display); line-height: 1; font-variant-numeric: tabular-nums; white-space: nowrap; }
.prefix, .suffix { color: var(--text-muted); font-size: .5em; letter-spacing: -.01em; }
.prefix { margin-right: .08em; }
.suffix { margin-left: .1em; }
.digits { display: inline-grid; justify-items: start; }
.sizer, .live { grid-area: 1 / 1; }
.sizer { visibility: hidden; }

/* Visuals sit on the bottom row, all the same height, so they line up whatever the copy above them does. */
.visual { position: relative; display: flex; flex: none; flex-direction: column; justify-content: flex-end; height: calc(56px + var(--space-8)); margin: auto 0 0; padding-top: var(--space-8); }
.visual > * { position: relative; height: 100%; }
.visual > .bars[data-dense] { height: 56%; }
.draw { display: block; width: 100%; height: 100%; overflow: visible; }

.trend { margin-right: 3px; }
.area { fill: var(--soft); opacity: .22; transition: fill var(--duration-standard) var(--ease-standard); }
.line { fill: none; stroke: var(--ink); stroke-width: 1.5; stroke-linejoin: round; stroke-linecap: round; transition: stroke var(--duration-standard) var(--ease-standard); }
.endDot { position: absolute; width: 7px; height: 7px; margin: -3.5px 0 0 -3.5px; border-radius: 50%; background: var(--ink); box-shadow: 0 0 0 2px var(--background); transition: background-color var(--duration-standard) var(--ease-standard), opacity 320ms var(--ease-standard), transform var(--duration-spring) var(--ease-spring); }

.bars { display: flex; align-items: flex-end; gap: 3px; }
/* 90 days never fit 90 fixed gaps in a narrow column, so dense bars carve their gap out of their own slot. */
.bars[data-dense] { gap: 0; }
.bars[data-dense] .bar { border-radius: 0; clip-path: inset(0 22% 0 22%); }
.bar { flex: 1 1 0; height: 100%; border-radius: 1px; background: var(--soft); transform: scaleY(var(--h)); transform-origin: bottom; transition: background-color var(--duration-standard) var(--ease-standard), transform 560ms var(--ease-enter); }
.bar[data-ink] { background: var(--ink); }

.map { display: flex; }
.map .draw { width: auto; aspect-ratio: 80 / 30; }
.land { fill: var(--soft); transition: fill var(--duration-standard) var(--ease-standard); }
.lit { fill: var(--ink); transition: fill var(--duration-standard) var(--ease-standard); }

/* Hover or focus: the context line fades in and the visual takes the accent. The other stats stay neutral. */
.stat:focus-visible { --ink: var(--accent); --soft: color-mix(in oklab, var(--accent) 42%, var(--background)); }
.stat[data-context]:focus-visible .detail { opacity: 0; transform: translateY(-4px); }
.stat[data-context]:focus-visible .context { opacity: 1; transform: none; }
@media (hover: none) {
  .stat:focus { --ink: var(--accent); --soft: color-mix(in oklab, var(--accent) 42%, var(--background)); }
  .stat[data-context]:focus .detail { opacity: 0; transform: translateY(-4px); }
  .stat[data-context]:focus .context { opacity: 1; transform: none; }
}
@media (hover: hover) {
  .stat:hover { --ink: var(--accent); --soft: color-mix(in oklab, var(--accent) 42%, var(--background)); }
  .stat[data-context]:hover .detail { opacity: 0; transform: translateY(-4px); }
  .stat[data-context]:hover .context { opacity: 1; transform: none; }
}

/* Entry: stats rise in sequence while the numbers count, then each visual draws in once its number has landed. */
.stat { opacity: 0; transform: translateY(8px); transition: opacity 480ms var(--ease-enter), transform 640ms var(--ease-enter); transition-delay: calc(var(--i) * 90ms); }
.band[data-in-view] .stat { opacity: 1; transform: none; }
.band:not([data-in-view]) .draw { clip-path: inset(0 100% 0 0); }
.band:not([data-in-view]) .bar { transform: scaleY(0); }
.band:not([data-in-view]) :is(.endDot) { opacity: 0; }
.band:not([data-in-view]) .endDot { transform: scale(.3); }
.draw { clip-path: inset(0 -4px 0 0); transition: clip-path 1100ms var(--ease-in-out) var(--start); }
.band[data-in-view] .bar { transition-delay: 0ms, calc(var(--start) + var(--j) * 420ms); }
.band[data-in-view] .endDot { transition-delay: 0ms, calc(var(--start) + 1000ms), calc(var(--start) + 1000ms); }

/* Divided: a hairline grid. Rules draw across the band, then hairlines grow between stats. */
.band[data-layout="divided"] .stats { column-gap: 0; row-gap: 0; }
.band[data-layout="divided"] .stats::before,
.band[data-layout="divided"] .stats::after { content: ""; position: absolute; right: 0; left: 0; height: 1px; background: var(--border); transform: scaleX(0); transform-origin: left; transition: transform 900ms var(--ease-in-out); }
.band[data-layout="divided"] .stats::before { top: 0; }
.band[data-layout="divided"] .stats::after { bottom: 0; transition-delay: 120ms; }
.band[data-layout="divided"][data-in-view] .stats::before,
.band[data-layout="divided"][data-in-view] .stats::after { transform: scaleX(1); }
.band[data-layout="divided"] .stat { padding: var(--space-10) var(--space-8) var(--space-8); }
.band[data-layout="divided"] .stat:first-child { padding-left: 0; }
.band[data-layout="divided"] .stat:last-child { padding-right: 0; }
.band[data-layout="divided"] .stat:not(:first-child)::before { content: ""; position: absolute; top: 0; bottom: 0; left: 0; width: 1px; background: var(--border); transform: scaleY(0); transform-origin: top; transition: transform 700ms var(--ease-enter); transition-delay: calc(var(--i) * 90ms + 240ms); }
.band[data-layout="divided"][data-in-view] .stat::before { transform: scaleY(1); }

/* Tablet: two by two. A band of three stacks instead of leaving an orphan. */
@container stats (max-width: 800px) {
  .inner { padding: var(--space-20) var(--space-8); }
  .header { grid-template-columns: minmax(0, 1fr); margin-bottom: var(--space-12); }
  .description { justify-self: start; }
  .stats { grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: var(--space-8); row-gap: var(--space-12); }
  .band[data-layout="divided"] .stat { padding: var(--space-8) var(--space-8) var(--space-8); }
  .band[data-layout="divided"] .stat:nth-child(2n + 1) { padding-left: 0; }
  .band[data-layout="divided"] .stat:nth-child(2n) { padding-right: 0; }
  .band[data-layout="divided"] .stat:nth-child(2n + 1)::before { display: none; }
  .band[data-layout="divided"] .stat:nth-child(n + 3)::after { content: ""; position: absolute; top: 0; right: 0; left: 0; height: 1px; background: var(--border); }
}

/* Phone: one stat per row, the visual beside the number so nothing is cramped. */
@container stats (max-width: 520px) {
  .inner { padding: var(--space-16) var(--space-4); }
  .header { margin-bottom: var(--space-10); }
  .stats { grid-template-columns: minmax(0, 1fr); row-gap: var(--space-10); }
  .stat { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 40%); grid-template-areas: "figure visual" "label label" "caption caption"; column-gap: var(--space-6); }
  .figure { grid-area: figure; align-self: end; margin-bottom: var(--space-3); }
  .label { grid-area: label; }
  .caption { grid-area: caption; }
  .visual { grid-area: visual; align-self: end; height: 40px; margin: 0 0 var(--space-3); padding: 0; }
  .map { justify-content: flex-end; }
  .band[data-layout="divided"] .stat,
  .band[data-layout="divided"] .stat:nth-child(n) { padding: var(--space-8) 0; }
  .band[data-layout="divided"] .stat::before { display: none; }
  .band[data-layout="divided"] .stat:nth-child(n + 2)::after { content: ""; position: absolute; top: 0; right: 0; left: 0; height: 1px; background: var(--border); }
}
@container stats (max-width: 800px) {
  .stats[data-count="3"] { grid-template-columns: minmax(0, 1fr); }
  /* Three stacked stats in the divided grid: no column padding or vertical hairlines, a rule between each row instead. */
  .band[data-layout="divided"] .stats[data-count="3"] .stat { padding-right: 0; padding-left: 0; }
  .band[data-layout="divided"] .stats[data-count="3"] .stat::before { display: none; }
  .band[data-layout="divided"] .stats[data-count="3"] .stat:nth-child(n + 2)::after { content: ""; position: absolute; top: 0; right: 0; left: 0; height: 1px; background: var(--border); }
}

.preview { display: grid; width: 100%; justify-items: center; gap: var(--space-4); }
.frame { width: 100%; overflow: hidden; border: 1px solid var(--border); border-radius: 20px; background: var(--background); }

@media (prefers-reduced-motion: reduce) {
  .band .stat, .band .draw, .band .bar, .band .endDot,
  .band[data-layout="divided"] .stats::before, .band[data-layout="divided"] .stats::after, .band[data-layout="divided"] .stat::before { transition-duration: 0ms; transition-delay: 0ms; }
  .band .stat { opacity: 1; transform: none; }
  .band:not([data-in-view]) .draw { clip-path: inset(0 -4px 0 0); }
  .band:not([data-in-view]) .bar { transform: scaleY(var(--h)); }
  .band:not([data-in-view]) :is(.endDot) { opacity: 1; transform: none; }
  .detail, .context { transition-duration: 0ms; }
}
```

### `registry/blocks/stats-band/stats-band-world.ts`

```ts
/**
 * A coarse dotted world for the map visual: 80 columns by 30 rows, from 168° W to 192° E and 76° N to 56° S
 * (Antarctica left out). Each row is hex, four columns per digit, most significant bit first.
 */
const ROWS = [
  "001f007f800020fc0000", "7fc0cc7f00781ffffff8", "7ffffe38c0dffffffff8", "7fff0e1003bfffffff60", "40ffcf0009bffffff0c0",
  "007fff801ffffffff880", "003fff0007fffffff000", "003ffc001c737fffec00", "003ff800181f7fff4800", "001ff0000f07ffff3000",
  "000f80001fff7fff0000", "000f00003ffb9fff0000", "000340003fffc7b80000", "0001c0003ffd82380000", "000020003ffe021c0000",
  "00001f001fff00100000", "00000fc001ff00010000", "00000fe001fe00028000", "00001ff800fc00080c00", "00000ff800fc00000200",
  "000007f800fc00003000", "000003f800fd0000fc00", "000003f000790001fe00", "000007e000780001fe00", "000007c000700001de00",
  "00000780000000000e00", "00000600000000000008", "00000600000000000020", "00000400000000000000", "00000400000000000000",
];

export const WORLD = { columns: 80, rows: 30, west: -168, east: 192, north: 76, south: -56 } as const;

/** Land cells as a flat row-major array of booleans. */
export const worldLand: boolean[] = ROWS.flatMap(row => [...row].flatMap(digit => {
  const bits = parseInt(digit, 16);
  return [8, 4, 2, 1].map(bit => (bits & bit) !== 0);
}));
```
