{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "stats-band",
  "type": "registry:block",
  "title": "Stats band",
  "description": "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.",
  "categories": [
    "blocks"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/segmented-control.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/blocks/stats-band/stats-band-data.ts",
      "content": "/**\n * A small visual that proves the number beside it. Every kind is optional; a stat without one is just the number.\n * - `trend`: a sparkline of a series, oldest first, such as twelve monthly totals.\n * - `uptime`: one bar per day as a percentage. Full days are full height; a day with downtime is visibly shorter.\n * - `distribution`: a histogram of equal width bins across `0..max`, with the bars below `marker` (the median) inked.\n * - `map`: a dotted world with a dot lit for each [longitude, latitude] point.\n */\nexport type StatVisual =\n  | { kind: \"trend\"; values: number[] }\n  | { kind: \"uptime\"; days: number[] }\n  | { kind: \"distribution\"; bins: number[]; max: number; marker: number }\n  | { kind: \"map\"; points: [number, number][] };\n\nexport interface Stat {\n  /** The final number. The band counts up to it. */\n  value: number;\n  /** Text before the number, such as \"€\". */\n  prefix?: string;\n  /** Text after the number, such as \"%\", \"ms\", or \"+\". */\n  suffix?: string;\n  /** Digits after the decimal point. Defaults to 0. */\n  decimals?: number;\n  /** `compact` shortens large numbers to 2.3M or 12K. The unit renders like a suffix. Defaults to `standard`. */\n  notation?: \"standard\" | \"compact\";\n  label: string;\n  /** One short supporting line under the number. */\n  detail?: string;\n  /** One line of context that replaces the detail while the stat is hovered or focused, such as \"Up from 41% last year\". */\n  context?: string;\n  /** A tiny chart that draws in after the number lands. */\n  visual?: StatVisual;\n}\n\n/** 90 days of feed health, oldest first: the share of sources fetched each day, with two days of failed fetches. */\nconst feedDays = Array.from({ length: 90 }, (_, day) => (day === 51 ? 99.24 : day === 23 ? 99.93 : 100));\n\n/** The countries of ErmisAI's 10 article languages as [longitude, latitude]. */\nconst languages: [number, number][] = [\n  [-0.1, 51.5],\n  [23.7, 38],\n  [12.5, 41.9],\n  [-3.7, 40.4],\n  [-9.1, 38.7],\n  [21, 52.2],\n  [18, 59.3],\n  [12.6, 55.7],\n  [10.75, 59.9],\n  [24.9, 60.2],\n];\n\n/** Sample stats: one week on an ErmisAI newsroom desk. */\nexport const stats: Stat[] = [\n  {\n    value: 46,\n    label: \"Stories on the desk today\",\n    detail: \"Aegean evening desk\",\n    context: \"18 of 20 delivered, 3 queued\",\n    visual: { kind: \"trend\", values: [31, 28, 35, 40, 33, 38, 42, 37, 44, 39, 41, 46] },\n  },\n  {\n    value: 342,\n    label: \"Sources monitored\",\n    detail: \"Of 350 on the Pro plan\",\n    context: \"Two days with failed fetches in 90 days\",\n    visual: { kind: \"uptime\", days: feedDays },\n  },\n  {\n    value: 2,\n    suffix: \"min\",\n    label: \"From signal to draft\",\n    detail: \"Median across today's stories\",\n    context: \"The desk decides what runs\",\n    visual: {\n      kind: \"distribution\",\n      bins: [3, 9, 17, 22, 16, 11, 8, 5, 4, 3, 2, 1.5, 1, 0.8, 0.6, 0.4],\n      max: 8,\n      marker: 2,\n    },\n  },\n  {\n    value: 10,\n    label: \"Article languages\",\n    detail: \"Read and written on one desk\",\n    context: \"Greek, Italian and Polish among them\",\n    visual: { kind: \"map\", points: languages },\n  },\n];\n",
      "type": "registry:component",
      "target": "@components/iris/blocks/stats-band/stats-band-data.ts"
    },
    {
      "path": "src/registry/blocks/stats-band/stats-band-world.ts",
      "content": "/**\n * 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\n * (Antarctica left out). Each row is hex, four columns per digit, most significant bit first.\n */\nconst ROWS = [\n  \"001f007f800020fc0000\",\n  \"7fc0cc7f00781ffffff8\",\n  \"7ffffe38c0dffffffff8\",\n  \"7fff0e1003bfffffff60\",\n  \"40ffcf0009bffffff0c0\",\n  \"007fff801ffffffff880\",\n  \"003fff0007fffffff000\",\n  \"003ffc001c737fffec00\",\n  \"003ff800181f7fff4800\",\n  \"001ff0000f07ffff3000\",\n  \"000f80001fff7fff0000\",\n  \"000f00003ffb9fff0000\",\n  \"000340003fffc7b80000\",\n  \"0001c0003ffd82380000\",\n  \"000020003ffe021c0000\",\n  \"00001f001fff00100000\",\n  \"00000fc001ff00010000\",\n  \"00000fe001fe00028000\",\n  \"00001ff800fc00080c00\",\n  \"00000ff800fc00000200\",\n  \"000007f800fc00003000\",\n  \"000003f800fd0000fc00\",\n  \"000003f000790001fe00\",\n  \"000007e000780001fe00\",\n  \"000007c000700001de00\",\n  \"00000780000000000e00\",\n  \"00000600000000000008\",\n  \"00000600000000000020\",\n  \"00000400000000000000\",\n  \"00000400000000000000\",\n];\n\nexport const WORLD = { columns: 80, rows: 30, west: -168, east: 192, north: 76, south: -56 } as const;\n\n/** Land cells as a flat row-major array of booleans. */\nexport const worldLand: boolean[] = ROWS.flatMap((row) =>\n  row.split(\"\").flatMap((digit) => {\n    const bits = parseInt(digit, 16);\n    return [8, 4, 2, 1].map((bit) => (bits & bit) !== 0);\n  }),\n);\n",
      "type": "registry:component",
      "target": "@components/iris/blocks/stats-band/stats-band-world.ts"
    },
    {
      "path": "src/registry/blocks/stats-band/stats-band.tsx",
      "content": "\"use client\";\n\nimport { forwardRef, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from \"react\";\nimport type { CSSProperties } from \"react\";\nimport { animate, useInView } from \"motion/react\";\nimport SegmentedControl from \"../../segmented-control/segmented-control\";\nimport { motionTokens } from \"../../lib/motion-tokens\";\nimport { stats as exampleStats, type Stat, type StatVisual } from \"./stats-band-data\";\nimport { WORLD, worldLand } from \"./stats-band-world\";\nimport { cn } from \"../../lib/utils\";\n\nexport type { Stat, StatVisual } from \"./stats-band-data\";\nexport type StatsBandLayout = \"plain\" | \"divided\";\n\nexport interface StatsBandProps {\n  /** Three or four stats read best. */\n  stats?: Stat[];\n  /** `plain` lets the stats float on whitespace; `divided` sets them in a hairline grid ruled above and below. */\n  layout?: StatsBandLayout;\n  title?: string;\n  description?: string;\n  /** Seconds each number takes to count up. Defaults to 1.6. */\n  duration?: number;\n  /** Number formatting locale. Fixed by default so server and client agree. */\n  locale?: string;\n  className?: string;\n}\n\ntype Bezier = [number, number, number, number];\nconst enter = [...motionTokens.ease.enter] as Bezier;\nconst STAGGER = 0.09;\n\nconst REDUCE = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(REDUCE);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\n/** Reduced motion, read after hydration so the server and first client render agree. CSS covers the first paint. */\nfunction useReducedMotionSafe() {\n  return useSyncExternalStore(\n    subscribeReduced,\n    () => window.matchMedia(REDUCE).matches,\n    () => false,\n  );\n}\n\n/**\n * Formats a stat's number, split from its compact unit so the unit can sit beside the digits like a suffix. A compact\n * stat counts inside its final unit (0.0M to 2.3M) instead of jumping from K to M halfway through.\n */\nfunction useFormat(stat: Stat, locale: string) {\n  return useMemo(() => {\n    const decimals = stat.decimals ?? 0;\n    const digits = new Intl.NumberFormat(locale, {\n      minimumFractionDigits: decimals,\n      maximumFractionDigits: decimals,\n      numberingSystem: \"latn\",\n    });\n    if (stat.notation !== \"compact\") return { unit: \"\", format: (value: number) => digits.format(value) };\n    const size = Math.abs(stat.value);\n    const scale = size >= 1e12 ? 1e12 : size >= 1e9 ? 1e9 : size >= 1e6 ? 1e6 : size >= 1e3 ? 1e3 : 1;\n    const unit =\n      scale === 1\n        ? \"\"\n        : (new Intl.NumberFormat(locale, { notation: \"compact\" })\n            .formatToParts(scale)\n            .find((part) => part.type === \"compact\")?.value ?? \"\");\n    return { unit, format: (value: number) => digits.format(value / scale) };\n  }, [stat.decimals, stat.notation, stat.value, locale]);\n}\n\n/** Visually hidden but read by screen readers. */\nconst srOnly = \"absolute -m-px h-px w-px overflow-hidden whitespace-nowrap [clip-path:inset(50%)]\";\n\n/**\n * One number that counts from zero to its value the first time the band is in view. The final value reserves the\n * width underneath, so the layout never moves while digits change. Screen readers only get the final value.\n */\nfunction CountUp({\n  stat,\n  run,\n  delay,\n  duration,\n  reduced,\n  locale,\n}: {\n  stat: Stat;\n  run: boolean;\n  delay: number;\n  duration: number;\n  reduced: boolean;\n  locale: string;\n}) {\n  const live = useRef<HTMLSpanElement>(null);\n  const done = useRef(false);\n  const { unit, format } = useFormat(stat, locale);\n  const final = format(stat.value);\n  const suffix = `${unit}${stat.suffix ?? \"\"}`;\n\n  useLayoutEffect(() => {\n    if (reduced || done.current || !live.current) return;\n    live.current.textContent = format(0);\n  }, [format, reduced]);\n\n  useEffect(() => {\n    const node = live.current;\n    if (!node) return;\n    if (reduced) {\n      node.textContent = final;\n      done.current = true;\n      return;\n    }\n    if (!run || done.current) return;\n    const controls = animate(0, stat.value, {\n      duration,\n      delay,\n      ease: enter,\n      onUpdate: (latest) => {\n        node.textContent = format(latest);\n      },\n      onComplete: () => {\n        node.textContent = final;\n        done.current = true;\n      },\n    });\n    return () => controls.stop();\n  }, [run, reduced, stat.value, delay, duration, format, final]);\n\n  /* The number: display type, tabular digits, the unit smaller and quieter on the same baseline. */\n  return (\n    <>\n      <span className={srOnly}>\n        {stat.prefix}\n        {final}\n        {suffix}\n      </span>\n      <span\n        className=\"inline-flex items-baseline font-display text-[length:clamp(2.5rem,1.4rem_+_3cqi,var(--text-4xl))] leading-none font-medium tracking-display whitespace-nowrap tabular-nums\"\n        aria-hidden=\"true\"\n      >\n        {stat.prefix && <span className=\"mr-[.08em] text-[.5em] tracking-[-.01em] text-ink-3\">{stat.prefix}</span>}\n        <span className=\"inline-grid justify-items-start\">\n          <span className=\"invisible [grid-area:1/1]\">{final}</span>\n          <span ref={live} className=\"[grid-area:1/1]\">\n            {final}\n          </span>\n        </span>\n        {suffix && <span className=\"ml-[.1em] text-[.5em] tracking-[-.01em] text-ink-3\">{suffix}</span>}\n      </span>\n    </>\n  );\n}\n\n/*\n * Visuals. Each one is drawn at rest and hidden until the band is in view (`shown`), then draws in once its number has\n * landed: `--start` on the stat delays it by the stat's place in the sequence. Under reduced motion they show at once.\n * They paint with `--stat-ink` and `--stat-soft`, which the stat switches to the accent while hovered or focused.\n */\nconst draw = (shown: boolean) =>\n  cn(\n    \"block h-full w-full overflow-visible [clip-path:inset(0_-4px_0_0)] [transition:clip-path_1100ms_var(--ease-in-out)_var(--start)] motion-reduce:[transition-delay:0ms] motion-reduce:[transition-duration:0ms]\",\n    !shown && \"[clip-path:inset(0_100%_0_0)] motion-reduce:[clip-path:inset(0_-4px_0_0)]\",\n  );\n\nfunction Trend({ values, shown }: { values: number[]; shown: boolean }) {\n  const min = Math.min(...values),\n    max = Math.max(...values);\n  const span = max - min || 1;\n  const points = values.map(\n    (value, index) =>\n      [values.length > 1 ? (index / (values.length - 1)) * 100 : 100, 36 - ((value - min) / span) * 32] as const,\n  );\n  const line = points.map(([x, y], index) => `${index ? \"L\" : \"M\"}${x.toFixed(2)} ${y.toFixed(2)}`).join(\" \");\n  const [endX, endY] = points[points.length - 1]!;\n  return (\n    <div className=\"relative mr-[3px] h-full\">\n      <svg className={draw(shown)} viewBox=\"0 0 100 40\" preserveAspectRatio=\"none\">\n        <path\n          className=\"fill-(--stat-soft) opacity-[.22] transition-[fill] duration-standard ease-standard\"\n          d={`${line} L100 40 L0 40 Z`}\n        />\n        <path\n          className=\"fill-none stroke-(--stat-ink) [stroke-width:1.5] [stroke-linecap:round] [stroke-linejoin:round] transition-[stroke] duration-standard ease-standard\"\n          d={line}\n          vectorEffect=\"non-scaling-stroke\"\n        />\n      </svg>\n      <span\n        className={cn(\n          \"absolute -mt-[3.5px] -ml-[3.5px] size-[7px] rounded-full bg-(--stat-ink) shadow-[0_0_0_2px_var(--page)] [transition:background-color_var(--duration-standard)_var(--ease-standard),opacity_320ms_var(--ease-standard),transform_var(--duration-spring)_var(--ease-spring)] motion-reduce:[transition-delay:0ms] motion-reduce:[transition-duration:0ms]\",\n          shown\n            ? \"[transition-delay:0ms,calc(var(--start)+1000ms),calc(var(--start)+1000ms)]\"\n            : \"opacity-0 [transform:scale(.3)] motion-reduce:opacity-100 motion-reduce:[transform:none]\",\n        )}\n        style={{ left: `${endX}%`, top: `${(endY / 40) * 100}%` }}\n      />\n    </div>\n  );\n}\n\n/** One bar of a strip. `--h` is its height as a share of the strip, `--j` its place along it (0 to 1) for the stagger. */\nconst bar = (shown: boolean, inked: boolean, dense?: boolean) =>\n  cn(\n    \"h-full flex-[1_1_0] origin-bottom rounded-[1px] bg-(--stat-soft) [transform:scaleY(var(--h))] [transition:background-color_var(--duration-standard)_var(--ease-standard),transform_560ms_var(--ease-enter)] motion-reduce:[transition-delay:0ms] motion-reduce:[transition-duration:0ms]\",\n    inked && \"bg-(--stat-ink)\",\n    dense && \"rounded-none [clip-path:inset(0_22%_0_22%)]\",\n    shown\n      ? \"[transition-delay:0ms,calc(var(--start)+var(--j)*420ms)]\"\n      : \"[transform:scaleY(0)] motion-reduce:[transform:scaleY(var(--h))]\",\n  );\n\nfunction Uptime({ days, shown }: { days: number[]; shown: boolean }) {\n  /* 90 days never fit 90 fixed gaps in a narrow column, so dense bars carve their gap out of their own slot. */\n  return (\n    <div className=\"relative flex h-[56%] items-end gap-0\" data-dense=\"\">\n      {days.map((uptime, index) => (\n        <span\n          key={index}\n          className={bar(shown, uptime < 100, true)}\n          data-ink={uptime < 100 ? \"\" : undefined}\n          style={\n            { \"--j\": index / days.length, \"--h\": Math.min(1, Math.max(0.3, 1 - (100 - uptime) * 0.7)) } as CSSProperties\n          }\n        />\n      ))}\n    </div>\n  );\n}\n\nfunction Distribution({ bins, max, marker, shown }: { bins: number[]; max: number; marker: number; shown: boolean }) {\n  const peak = Math.max(...bins) || 1;\n  const width = max / bins.length;\n  return (\n    <div className=\"relative flex h-full items-end gap-[3px]\">\n      {bins.map((count, index) => (\n        <span\n          key={index}\n          className={bar(shown, index * width < marker)}\n          data-ink={index * width < marker ? \"\" : undefined}\n          style={{ \"--j\": index / bins.length, \"--h\": Math.max(0.04, count / peak) } as CSSProperties}\n        />\n      ))}\n    </div>\n  );\n}\n\nconst cellOf = (lon: number, lat: number) => {\n  const x = Math.floor((((((lon - WORLD.west) % 360) + 360) % 360) / (WORLD.east - WORLD.west)) * WORLD.columns);\n  const y = Math.floor(((WORLD.north - lat) / (WORLD.north - WORLD.south)) * WORLD.rows);\n  return [Math.min(WORLD.columns - 1, Math.max(0, x)), Math.min(WORLD.rows - 1, Math.max(0, y))] as const;\n};\n\n/** Snaps a point to the nearest land dot, so a coastal city never lights a dot in the sea. */\nfunction nearestLand(lon: number, lat: number) {\n  const [x, y] = cellOf(lon, lat);\n  let best = y * WORLD.columns + x,\n    distance = Infinity;\n  for (let dy = -2; dy <= 2; dy++)\n    for (let dx = -2; dx <= 2; dx++) {\n      const cx = x + dx,\n        cy = y + dy;\n      if (cx < 0 || cy < 0 || cx >= WORLD.columns || cy >= WORLD.rows || !worldLand[cy * WORLD.columns + cx]) continue;\n      const d = dx * dx + dy * dy;\n      if (d < distance) {\n        distance = d;\n        best = cy * WORLD.columns + cx;\n      }\n    }\n  return best;\n}\n\n/** The detail and the context share one grid cell and cross-fade. */\nconst captionLine =\n  \"min-w-0 text-pretty [grid-area:1/1] [transition:opacity_var(--duration-standard)_var(--ease-standard),transform_var(--duration-standard)_var(--ease-standard)] motion-reduce:[transition-duration:0ms]\";\n\nfunction WorldMap({ points, shown }: { points: [number, number][]; shown: boolean }) {\n  const lit = useMemo(() => new Set(points.map(([lon, lat]) => nearestLand(lon, lat))), [points]);\n  return (\n    <div className=\"relative flex h-full @max-[520px]/stats:justify-end\">\n      <svg className={cn(draw(shown), \"w-auto aspect-[80/30]\")} viewBox={`0 0 ${WORLD.columns} ${WORLD.rows}`}>\n        {worldLand.map(\n          (land, index) =>\n            land &&\n            !lit.has(index) && (\n              <circle\n                key={index}\n                className=\"fill-(--stat-soft) transition-[fill] duration-standard ease-standard\"\n                cx={(index % WORLD.columns) + 0.5}\n                cy={Math.floor(index / WORLD.columns) + 0.5}\n                r={0.3}\n              />\n            ),\n        )}\n        {[...lit].map((index) => (\n          <circle\n            key={index}\n            className=\"fill-(--stat-ink) transition-[fill] duration-standard ease-standard\"\n            cx={(index % WORLD.columns) + 0.5}\n            cy={Math.floor(index / WORLD.columns) + 0.5}\n            r={0.5}\n          />\n        ))}\n      </svg>\n    </div>\n  );\n}\n\nfunction Visual({ visual, shown }: { visual: StatVisual; shown: boolean }) {\n  switch (visual.kind) {\n    case \"trend\":\n      return <Trend values={visual.values} shown={shown} />;\n    case \"uptime\":\n      return <Uptime days={visual.days} shown={shown} />;\n    case \"distribution\":\n      return <Distribution bins={visual.bins} max={visual.max} marker={visual.marker} shown={shown} />;\n    case \"map\":\n      return <WorldMap points={visual.points} shown={shown} />;\n  }\n}\n\n/**\n * A band of three or four headline numbers, each with an optional tiny visual that proves it: a trend, an uptime strip,\n * a latency histogram, or a dotted world. The numbers count up in a staggered sequence the first time the band is in\n * view, then the visuals draw in. Hovering or focusing a stat swaps its detail line for one line of context and gives\n * its visual the accent. Numbers use tabular figures and reserve their final width; screen readers get final values.\n */\nexport const StatsBand = forwardRef<HTMLElement, StatsBandProps>(function StatsBand(\n  { stats = exampleStats, layout = \"plain\", title, description, duration = 1.6, locale = \"en-US\", className },\n  ref,\n) {\n  const id = useId();\n  const list = useRef<HTMLDListElement>(null);\n  const inView = useInView(list, { once: true, amount: 0.4 });\n  const reduced = useReducedMotionSafe();\n  const withVisuals = stats.some((stat) => stat.visual);\n  const shown = inView || reduced;\n  const count = Math.min(stats.length, 4);\n  const divided = layout === \"divided\";\n  return (\n    <section\n      ref={ref}\n      className={cn(\"group/band @container/stats bg-page font-body tracking-body text-ink\", className)}\n      data-layout={layout}\n      data-in-view={shown ? \"\" : undefined}\n      aria-labelledby={title ? `${id}-title` : undefined}\n      aria-label={title ? undefined : \"Key numbers\"}\n    >\n      <div className=\"mx-auto max-w-[1120px] px-8 py-24 @max-[800px]/stats:py-20 @max-[520px]/stats:px-4 @max-[520px]/stats:py-16\">\n        {/* Header: the title on the left, the note on the right, sharing a last baseline. */}\n        {(title || description) && (\n          <header\n            className={cn(\n              \"mb-16 grid grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)] [align-items:last_baseline] gap-x-12 gap-y-4 @max-[800px]/stats:mb-12 @max-[800px]/stats:grid-cols-[minmax(0,1fr)] @max-[520px]/stats:mb-10\",\n              !title && \"grid-cols-[minmax(0,1fr)]\",\n            )}\n          >\n            {title && (\n              <h2\n                id={`${id}-title`}\n                className=\"m-0 max-w-[16ch] font-display text-[length:clamp(1.875rem,1rem_+_2.6cqi,var(--text-4xl))] leading-display font-medium tracking-display text-balance\"\n              >\n                {title}\n              </h2>\n            )}\n            {description && (\n              <p\n                className={cn(\n                  \"m-0 max-w-[34ch] justify-self-end text-base/[1.5] text-ink-2 text-pretty @max-[800px]/stats:justify-self-start\",\n                  !title && \"justify-self-start\",\n                )}\n              >\n                {description}\n              </p>\n            )}\n          </header>\n        )}\n        {/* The grid. Every stat has the same rows, so numbers, labels, and visuals line up across the band. Tablet widths\n         * set two by two, and a band of three stacks instead of leaving an orphan; phones get one stat per row. Divided:\n         * a hairline grid whose rules draw across the band, then hairlines grow between the stats. */}\n        <dl\n          ref={list}\n          className={cn(\n            \"relative m-0 grid grid-cols-[repeat(var(--count),minmax(0,1fr))] @max-[800px]/stats:grid-cols-[repeat(2,minmax(0,1fr))] @max-[520px]/stats:grid-cols-[minmax(0,1fr)]\",\n            count === 3 && \"@max-[800px]/stats:grid-cols-[minmax(0,1fr)]\",\n            divided\n              ? cn(\n                  \"gap-0 before:absolute before:inset-x-0 before:top-0 before:h-px before:origin-left before:bg-line before:[transform:scaleX(0)] before:[transition:transform_900ms_var(--ease-in-out)] before:content-[''] after:absolute after:inset-x-0 after:bottom-0 after:h-px after:origin-left after:bg-line after:[transform:scaleX(0)] after:[transition:transform_900ms_var(--ease-in-out)_120ms] after:content-[''] motion-reduce:before:[transition-delay:0ms] motion-reduce:before:[transition-duration:0ms] motion-reduce:after:[transition-delay:0ms] motion-reduce:after:[transition-duration:0ms]\",\n                  shown && \"before:[transform:scaleX(1)] after:[transform:scaleX(1)]\",\n                )\n              : \"gap-x-10 gap-y-16 @max-[800px]/stats:gap-x-8 @max-[800px]/stats:gap-y-12 @max-[520px]/stats:gap-y-10\",\n          )}\n          data-count={count}\n          data-visuals={withVisuals ? \"\" : undefined}\n          style={{ \"--count\": count, \"--draw\": `${Math.round(duration * 420)}ms` } as CSSProperties}\n        >\n          {stats.map((stat, index) => {\n            const hasContext = Boolean(stat.context && stat.detail);\n            const first = index === 0,\n              last = index === stats.length - 1,\n              odd = index % 2 === 0;\n            return (\n              <div\n                key={`${stat.label}-${index}`}\n                className={cn(\n                  /* Hover or focus: the context line fades in and the visual takes the accent. The other stats stay neutral. */\n                  \"group/stat relative flex min-w-0 cursor-default flex-col [-webkit-tap-highlight-color:transparent] focus:outline-none [--start:calc(var(--i)*90ms+var(--draw))] [--stat-ink:var(--ink-2)] [--stat-soft:var(--line-strong)]\",\n                  \"hover:[--stat-ink:var(--accent)] hover:[--stat-soft:color-mix(in_oklab,var(--accent)_42%,var(--page))] focus-visible:[--stat-ink:var(--accent)] focus-visible:[--stat-soft:color-mix(in_oklab,var(--accent)_42%,var(--page))] [@media(hover:none)]:focus:[--stat-ink:var(--accent)] [@media(hover:none)]:focus:[--stat-soft:color-mix(in_oklab,var(--accent)_42%,var(--page))]\",\n                  /* Entry: stats rise in sequence while the numbers count. */\n                  \"[transform:translateY(8px)] opacity-0 [transition:opacity_480ms_var(--ease-enter),transform_640ms_var(--ease-enter)] [transition-delay:calc(var(--i)*90ms)] motion-reduce:[transform:none] motion-reduce:opacity-100 motion-reduce:[transition-delay:0ms] motion-reduce:[transition-duration:0ms]\",\n                  shown && \"[transform:none] opacity-100\",\n                  /* Phone: the visual sits beside the number so nothing is cramped. */\n                  \"@max-[520px]/stats:grid @max-[520px]/stats:grid-cols-[minmax(0,1fr)_minmax(0,40%)] @max-[520px]/stats:gap-x-6 @max-[520px]/stats:[grid-template-areas:'figure_visual'_'label_label'_'caption_caption']\",\n                  divided &&\n                    cn(\n                      \"pt-10 pb-8 @max-[800px]/stats:py-8 @max-[520px]/stats:px-0\",\n                      first ? \"pl-0\" : \"pl-8\",\n                      last ? \"pr-0\" : \"pr-8\",\n                      count === 3 || first || odd ? \"@max-[800px]/stats:pl-0\" : \"@max-[800px]/stats:pl-8\",\n                      count === 3 || last || !odd ? \"@max-[800px]/stats:pr-0\" : \"@max-[800px]/stats:pr-8\",\n                      !first &&\n                        cn(\n                          \"before:absolute before:inset-y-0 before:left-0 before:w-px before:origin-top before:bg-line before:[transform:scaleY(0)] before:[transition:transform_700ms_var(--ease-enter)] before:[transition-delay:calc(var(--i)*90ms+240ms)] before:content-[''] motion-reduce:before:[transition-delay:0ms] motion-reduce:before:[transition-duration:0ms] @max-[520px]/stats:before:hidden\",\n                          shown && \"before:[transform:scaleY(1)]\",\n                          (count === 3 || odd) && \"@max-[800px]/stats:before:hidden\",\n                        ),\n                      (count === 3 ? index >= 1 : index >= 2) &&\n                        \"@max-[800px]/stats:after:absolute @max-[800px]/stats:after:inset-x-0 @max-[800px]/stats:after:top-0 @max-[800px]/stats:after:h-px @max-[800px]/stats:after:bg-line @max-[800px]/stats:after:content-['']\",\n                      index >= 1 &&\n                        \"@max-[520px]/stats:after:absolute @max-[520px]/stats:after:inset-x-0 @max-[520px]/stats:after:top-0 @max-[520px]/stats:after:h-px @max-[520px]/stats:after:bg-line @max-[520px]/stats:after:content-['']\",\n                    ),\n                )}\n                style={{ \"--i\": index } as CSSProperties}\n                // oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- a stat with context is focusable so keyboard users can reveal its context line, as hover does\n                tabIndex={stat.context ? 0 : undefined}\n                data-context={hasContext ? \"\" : undefined}\n              >\n                <dt className=\"text-sm leading-body font-medium text-pretty text-ink @max-[520px]/stats:[grid-area:label]\">\n                  {stat.label}\n                </dt>\n                <dd className=\"-order-1 m-0 mb-5 @max-[520px]/stats:mb-3 @max-[520px]/stats:self-end @max-[520px]/stats:[grid-area:figure]\">\n                  <CountUp\n                    stat={stat}\n                    run={inView}\n                    delay={index * STAGGER}\n                    duration={duration}\n                    reduced={reduced}\n                    locale={locale}\n                  />\n                </dd>\n                {(stat.detail || stat.context) && (\n                  <dd className=\"m-0 mt-0.5 grid text-sm leading-body text-ink-3 @max-[520px]/stats:[grid-area:caption]\">\n                    {stat.detail && (\n                      <span\n                        className={cn(\n                          captionLine,\n                          hasContext &&\n                            \"group-hover/stat:[transform:translateY(-4px)] group-hover/stat:opacity-0 group-focus-visible/stat:[transform:translateY(-4px)] group-focus-visible/stat:opacity-0 [@media(hover:none)]:group-focus/stat:[transform:translateY(-4px)] [@media(hover:none)]:group-focus/stat:opacity-0\",\n                        )}\n                      >\n                        {stat.detail}\n                      </span>\n                    )}\n                    {stat.context && (\n                      <span\n                        className={cn(\n                          captionLine,\n                          \"text-ink-2\",\n                          hasContext &&\n                            \"[transform:translateY(4px)] opacity-0 group-hover/stat:[transform:none] group-hover/stat:opacity-100 group-focus-visible/stat:[transform:none] group-focus-visible/stat:opacity-100 [@media(hover:none)]:group-focus/stat:[transform:none] [@media(hover:none)]:group-focus/stat:opacity-100\",\n                        )}\n                      >\n                        {stat.context}\n                      </span>\n                    )}\n                  </dd>\n                )}\n                {/* Visuals sit on the bottom row, all the same height, so they line up whatever the copy above them does. */}\n                {withVisuals && (\n                  <dd\n                    className=\"relative m-0 mt-auto flex h-[calc(56px+var(--space-8))] flex-none flex-col justify-end pt-8 @max-[520px]/stats:m-0 @max-[520px]/stats:mb-3 @max-[520px]/stats:h-10 @max-[520px]/stats:self-end @max-[520px]/stats:p-0 @max-[520px]/stats:[grid-area:visual]\"\n                    aria-hidden=\"true\"\n                  >\n                    {stat.visual && <Visual visual={stat.visual} shown={shown} />}\n                  </dd>\n                )}\n              </div>\n            );\n          })}\n        </dl>\n      </div>\n    </section>\n  );\n});\n\nStatsBand.displayName = \"StatsBand\";\n\nconst layoutOptions = [\n  { value: \"divided\", label: \"Divided\" },\n  { value: \"plain\", label: \"Plain\" },\n];\n\n/** Preview: the band in either layout. Switching layouts plays the sequence again. */\nexport function StatsBandBlock() {\n  const [layout, setLayout] = useState<StatsBandLayout>(\"divided\");\n  return (\n    <div className=\"grid w-full justify-items-center gap-4\">\n      <SegmentedControl\n        label=\"Stats layout\"\n        options={layoutOptions}\n        value={layout}\n        onValueChange={(next) => setLayout(next as StatsBandLayout)}\n      />\n      <div className=\"w-full overflow-hidden rounded-[20px] border border-line bg-page\">\n        <StatsBand\n          key={layout}\n          layout={layout}\n          title=\"Read the world. File the story.\"\n          description=\"Aegean evening desk, 16 to 22 Oct 2026.\"\n        />\n      </div>\n    </div>\n  );\n}\n\nexport default StatsBandBlock;\n",
      "type": "registry:component",
      "target": "@components/iris/blocks/stats-band/stats-band.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/stats-band",
  "meta": {
    "kind": "block",
    "group": "Blocks",
    "export": "StatsBand",
    "docs": "https://irisui.dev/components/stats-band"
  }
}
