{"$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://uiarc.dev/r/arc-motion-tokens.json","https://uiarc.dev/r/segmented-control.json"],"files":[{"path":"registry/blocks/stats-band/stats-band.tsx","type":"registry:component","target":"@components/arc/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 styles from \"./stats-band.module.css\";\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 = .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(subscribeReduced, () => window.matchMedia(REDUCE).matches, () => false);\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, { minimumFractionDigits: decimals, maximumFractionDigits: decimals, numberingSystem: \"latn\" });\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 = scale === 1 ? \"\" : new Intl.NumberFormat(locale, { notation: \"compact\" }).formatToParts(scale).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/**\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({ stat, run, delay, duration, reduced, locale }: { stat: Stat; run: boolean; delay: number; duration: number; reduced: boolean; locale: string }) {\n const live = useRef(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) { node.textContent = final; done.current = true; return; }\n if (!run || done.current) return;\n const controls = animate(0, stat.value, {\n duration,\n delay,\n ease: enter,\n onUpdate: latest => { node.textContent = format(latest); },\n onComplete: () => { node.textContent = final; done.current = true; },\n });\n return () => controls.stop();\n }, [run, reduced, stat.value, delay, duration, format, final]);\n\n return <>\n {stat.prefix}{final}{suffix}\n \n {stat.prefix && {stat.prefix}}\n \n {final}\n {final}\n \n {suffix && {suffix}}\n \n ;\n}\n\n/* Visuals. Each one is drawn at rest; CSS hides it until the band is in view, then draws it in. */\n\nfunction Trend({ values }: { values: number[] }) {\n const min = Math.min(...values), max = Math.max(...values);\n const span = max - min || 1;\n const points = values.map((value, index) => [values.length > 1 ? index / (values.length - 1) * 100 : 100, 36 - (value - min) / span * 32] as const);\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 \n \n \n \n \n
;\n}\n\nfunction Uptime({ days }: { days: number[] }) {\n return
\n {days.map((uptime, index) => )}\n
;\n}\n\nfunction Distribution({ bins, max, marker }: { bins: number[]; max: number; marker: number }) {\n const peak = Math.max(...bins) || 1;\n const width = max / bins.length;\n return
\n {bins.map((count, index) => )}\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, distance = Infinity;\n for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) {\n const cx = x + dx, 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) { distance = d; best = cy * WORLD.columns + cx; }\n }\n return best;\n}\n\nfunction WorldMap({ points }: { points: [number, number][] }) {\n const lit = useMemo(() => new Set(points.map(([lon, lat]) => nearestLand(lon, lat))), [points]);\n return
\n \n {worldLand.map((land, index) => land && !lit.has(index) && )}\n {[...lit].map(index => )}\n \n
;\n}\n\nfunction Visual({ visual }: { visual: StatVisual }) {\n switch (visual.kind) {\n case \"trend\": return ;\n case \"uptime\": return ;\n case \"distribution\": return ;\n case \"map\": return ;\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(function StatsBand({\n stats = exampleStats,\n layout = \"plain\",\n title,\n description,\n duration = 1.6,\n locale = \"en-US\",\n className,\n}, ref) {\n const id = useId();\n const list = useRef(null);\n const inView = useInView(list, { once: true, amount: .4 });\n const reduced = useReducedMotionSafe();\n const withVisuals = stats.some(stat => stat.visual);\n return
\n
\n {(title || description) &&
\n {title &&

{title}

}\n {description &&

{description}

}\n
}\n
\n {stats.map((stat, index) =>
\n
{stat.label}
\n
\n \n
\n {(stat.detail || stat.context) &&
\n {stat.detail && {stat.detail}}\n {stat.context && {stat.context}}\n
}\n {withVisuals &&
{stat.visual && }
}\n
)}\n
\n
\n
;\n});\n\nStatsBand.displayName = \"StatsBand\";\n\nconst layoutOptions = [{ value: \"divided\", label: \"Divided\" }, { value: \"plain\", label: \"Plain\" }];\n\n/** Preview: the band in either layout. Switching layouts plays the sequence again. */\nexport function StatsBandBlock() {\n const [layout, setLayout] = useState(\"divided\");\n return
\n setLayout(next as StatsBandLayout)} />\n
\n \n
\n
;\n}\n\nexport default StatsBandBlock;\n"},{"path":"registry/blocks/stats-band/stats-band-data.ts","type":"registry:component","target":"@components/arc/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 uptime, oldest first: two short incidents, 11 minutes on day 52 and 1 minute on day 24. */\nconst uptimeDays = Array.from({ length: 90 }, (_, day) => day === 51 ? 99.24 : day === 23 ? 99.93 : 100);\n\n/** 35 edge regions as [longitude, latitude]. */\nconst regions: [number, number][] = [\n [-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],\n [-74.1, 4.7], [-77, -12], [-46.6, -23.5], [-70.7, -33.4], [-58.4, -34.6],\n [-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],\n [3.4, 6.5], [28, -26.2], [36.8, -1.3], [55.3, 25.3], [34.8, 32.1],\n [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],\n];\n\nexport const stats: Stat[] = [\n {\n value: 2_334_000, notation: \"compact\", decimals: 1, label: \"Deploys in the last 12 months\",\n detail: \"Across 12,400 teams\", context: \"Up from 1.4M the year before\",\n visual: { kind: \"trend\", values: [141, 148, 139, 162, 171, 184, 196, 203, 221, 238, 257, 274] },\n },\n {\n value: 99.99, decimals: 2, suffix: \"%\", label: \"API uptime over 90 days\",\n detail: \"Measured every 30 seconds\", context: \"12 minutes down, 11 of them on July 14\",\n visual: { kind: \"uptime\", days: uptimeDays },\n },\n {\n value: 38, suffix: \"ms\", label: \"Median response time\",\n detail: \"At the edge, worldwide\", context: \"p95 is 96 ms, down from 141 ms\",\n 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 },\n },\n {\n value: 35, label: \"Edge regions on six continents\",\n detail: \"Most people are under 20 ms away\", context: \"Eight added this year, including Lagos\",\n visual: { kind: \"map\", points: regions },\n },\n];\n"},{"path":"registry/blocks/stats-band/stats-band.module.css","type":"registry:component","target":"@components/arc/blocks/stats-band/stats-band.module.css","content":".band { container: stats / inline-size; background: var(--background); color: var(--foreground); font-family: var(--font-body); letter-spacing: var(--tracking-body); }\n.band *, .band *::before, .band *::after { box-sizing: border-box; }\n.srOnly { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n\n.inner { max-width: 1120px; margin: 0 auto; padding: var(--space-24) var(--space-8); }\n\n/* Header: the title on the left, the note on the right, sharing a last baseline. */\n.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); }\n.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; }\n.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; }\n.header:not(:has(.title)) { grid-template-columns: minmax(0, 1fr); }\n.header:not(:has(.title)) .description { justify-self: start; }\n\n/* The grid. Every stat has the same rows, so numbers, labels, and visuals line up across the band. */\n.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; }\n.stat {\n --start: calc(var(--i) * 90ms + var(--draw));\n --ink: var(--text-secondary);\n --soft: var(--border-strong);\n position: relative; display: flex; min-width: 0; flex-direction: column; cursor: default; -webkit-tap-highlight-color: transparent;\n}\n.stat:focus { outline: none; }\n.figure { order: -1; margin: 0 0 var(--space-5); }\n.label { color: var(--foreground); font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); text-wrap: pretty; }\n.caption { display: grid; margin: 2px 0 0; color: var(--text-muted); font-size: var(--text-sm); line-height: var(--leading-body); }\n.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); }\n.stat[data-context] .context { opacity: 0; transform: translateY(4px); }\n.context { color: var(--text-secondary); }\n\n/* The number: display type, tabular digits, the unit smaller and quieter on the same baseline. */\n.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; }\n.prefix, .suffix { color: var(--text-muted); font-size: .5em; letter-spacing: -.01em; }\n.prefix { margin-right: .08em; }\n.suffix { margin-left: .1em; }\n.digits { display: inline-grid; justify-items: start; }\n.sizer, .live { grid-area: 1 / 1; }\n.sizer { visibility: hidden; }\n\n/* Visuals sit on the bottom row, all the same height, so they line up whatever the copy above them does. */\n.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); }\n.visual > * { position: relative; height: 100%; }\n.visual > .bars[data-dense] { height: 56%; }\n.draw { display: block; width: 100%; height: 100%; overflow: visible; }\n\n.trend { margin-right: 3px; }\n.area { fill: var(--soft); opacity: .22; transition: fill var(--duration-standard) var(--ease-standard); }\n.line { fill: none; stroke: var(--ink); stroke-width: 1.5; stroke-linejoin: round; stroke-linecap: round; transition: stroke var(--duration-standard) var(--ease-standard); }\n.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); }\n\n.bars { display: flex; align-items: flex-end; gap: 3px; }\n/* 90 days never fit 90 fixed gaps in a narrow column, so dense bars carve their gap out of their own slot. */\n.bars[data-dense] { gap: 0; }\n.bars[data-dense] .bar { border-radius: 0; clip-path: inset(0 22% 0 22%); }\n.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); }\n.bar[data-ink] { background: var(--ink); }\n\n.map { display: flex; }\n.map .draw { width: auto; aspect-ratio: 80 / 30; }\n.land { fill: var(--soft); transition: fill var(--duration-standard) var(--ease-standard); }\n.lit { fill: var(--ink); transition: fill var(--duration-standard) var(--ease-standard); }\n\n/* Hover or focus: the context line fades in and the visual takes the accent. The other stats stay neutral. */\n.stat:focus-visible { --ink: var(--accent); --soft: color-mix(in oklab, var(--accent) 42%, var(--background)); }\n.stat[data-context]:focus-visible .detail { opacity: 0; transform: translateY(-4px); }\n.stat[data-context]:focus-visible .context { opacity: 1; transform: none; }\n@media (hover: none) {\n .stat:focus { --ink: var(--accent); --soft: color-mix(in oklab, var(--accent) 42%, var(--background)); }\n .stat[data-context]:focus .detail { opacity: 0; transform: translateY(-4px); }\n .stat[data-context]:focus .context { opacity: 1; transform: none; }\n}\n@media (hover: hover) {\n .stat:hover { --ink: var(--accent); --soft: color-mix(in oklab, var(--accent) 42%, var(--background)); }\n .stat[data-context]:hover .detail { opacity: 0; transform: translateY(-4px); }\n .stat[data-context]:hover .context { opacity: 1; transform: none; }\n}\n\n/* Entry: stats rise in sequence while the numbers count, then each visual draws in once its number has landed. */\n.stat { opacity: 0; transform: translateY(8px); transition: opacity 480ms var(--ease-enter), transform 640ms var(--ease-enter); transition-delay: calc(var(--i) * 90ms); }\n.band[data-in-view] .stat { opacity: 1; transform: none; }\n.band:not([data-in-view]) .draw { clip-path: inset(0 100% 0 0); }\n.band:not([data-in-view]) .bar { transform: scaleY(0); }\n.band:not([data-in-view]) :is(.endDot) { opacity: 0; }\n.band:not([data-in-view]) .endDot { transform: scale(.3); }\n.draw { clip-path: inset(0 -4px 0 0); transition: clip-path 1100ms var(--ease-in-out) var(--start); }\n.band[data-in-view] .bar { transition-delay: 0ms, calc(var(--start) + var(--j) * 420ms); }\n.band[data-in-view] .endDot { transition-delay: 0ms, calc(var(--start) + 1000ms), calc(var(--start) + 1000ms); }\n\n/* Divided: a hairline grid. Rules draw across the band, then hairlines grow between stats. */\n.band[data-layout=\"divided\"] .stats { column-gap: 0; row-gap: 0; }\n.band[data-layout=\"divided\"] .stats::before,\n.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); }\n.band[data-layout=\"divided\"] .stats::before { top: 0; }\n.band[data-layout=\"divided\"] .stats::after { bottom: 0; transition-delay: 120ms; }\n.band[data-layout=\"divided\"][data-in-view] .stats::before,\n.band[data-layout=\"divided\"][data-in-view] .stats::after { transform: scaleX(1); }\n.band[data-layout=\"divided\"] .stat { padding: var(--space-10) var(--space-8) var(--space-8); }\n.band[data-layout=\"divided\"] .stat:first-child { padding-left: 0; }\n.band[data-layout=\"divided\"] .stat:last-child { padding-right: 0; }\n.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); }\n.band[data-layout=\"divided\"][data-in-view] .stat::before { transform: scaleY(1); }\n\n/* Tablet: two by two. A band of three stacks instead of leaving an orphan. */\n@container stats (max-width: 800px) {\n .inner { padding: var(--space-20) var(--space-8); }\n .header { grid-template-columns: minmax(0, 1fr); margin-bottom: var(--space-12); }\n .description { justify-self: start; }\n .stats { grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: var(--space-8); row-gap: var(--space-12); }\n .band[data-layout=\"divided\"] .stat { padding: var(--space-8) var(--space-8) var(--space-8); }\n .band[data-layout=\"divided\"] .stat:nth-child(2n + 1) { padding-left: 0; }\n .band[data-layout=\"divided\"] .stat:nth-child(2n) { padding-right: 0; }\n .band[data-layout=\"divided\"] .stat:nth-child(2n + 1)::before { display: none; }\n .band[data-layout=\"divided\"] .stat:nth-child(n + 3)::after { content: \"\"; position: absolute; top: 0; right: 0; left: 0; height: 1px; background: var(--border); }\n}\n\n/* Phone: one stat per row, the visual beside the number so nothing is cramped. */\n@container stats (max-width: 520px) {\n .inner { padding: var(--space-16) var(--space-4); }\n .header { margin-bottom: var(--space-10); }\n .stats { grid-template-columns: minmax(0, 1fr); row-gap: var(--space-10); }\n .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); }\n .figure { grid-area: figure; align-self: end; margin-bottom: var(--space-3); }\n .label { grid-area: label; }\n .caption { grid-area: caption; }\n .visual { grid-area: visual; align-self: end; height: 40px; margin: 0 0 var(--space-3); padding: 0; }\n .map { justify-content: flex-end; }\n .band[data-layout=\"divided\"] .stat,\n .band[data-layout=\"divided\"] .stat:nth-child(n) { padding: var(--space-8) 0; }\n .band[data-layout=\"divided\"] .stat::before { display: none; }\n .band[data-layout=\"divided\"] .stat:nth-child(n + 2)::after { content: \"\"; position: absolute; top: 0; right: 0; left: 0; height: 1px; background: var(--border); }\n}\n@container stats (max-width: 800px) {\n .stats[data-count=\"3\"] { grid-template-columns: minmax(0, 1fr); }\n /* Three stacked stats in the divided grid: no column padding or vertical hairlines, a rule between each row instead. */\n .band[data-layout=\"divided\"] .stats[data-count=\"3\"] .stat { padding-right: 0; padding-left: 0; }\n .band[data-layout=\"divided\"] .stats[data-count=\"3\"] .stat::before { display: none; }\n .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); }\n}\n\n.preview { display: grid; width: 100%; justify-items: center; gap: var(--space-4); }\n.frame { width: 100%; overflow: hidden; border: 1px solid var(--border); border-radius: 20px; background: var(--background); }\n\n@media (prefers-reduced-motion: reduce) {\n .band .stat, .band .draw, .band .bar, .band .endDot,\n .band[data-layout=\"divided\"] .stats::before, .band[data-layout=\"divided\"] .stats::after, .band[data-layout=\"divided\"] .stat::before { transition-duration: 0ms; transition-delay: 0ms; }\n .band .stat { opacity: 1; transform: none; }\n .band:not([data-in-view]) .draw { clip-path: inset(0 -4px 0 0); }\n .band:not([data-in-view]) .bar { transform: scaleY(var(--h)); }\n .band:not([data-in-view]) :is(.endDot) { opacity: 1; transform: none; }\n .detail, .context { transition-duration: 0ms; }\n}\n"},{"path":"registry/blocks/stats-band/stats-band-world.ts","type":"registry:component","target":"@components/arc/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\", \"7fc0cc7f00781ffffff8\", \"7ffffe38c0dffffffff8\", \"7fff0e1003bfffffff60\", \"40ffcf0009bffffff0c0\",\n \"007fff801ffffffff880\", \"003fff0007fffffff000\", \"003ffc001c737fffec00\", \"003ff800181f7fff4800\", \"001ff0000f07ffff3000\",\n \"000f80001fff7fff0000\", \"000f00003ffb9fff0000\", \"000340003fffc7b80000\", \"0001c0003ffd82380000\", \"000020003ffe021c0000\",\n \"00001f001fff00100000\", \"00000fc001ff00010000\", \"00000fe001fe00028000\", \"00001ff800fc00080c00\", \"00000ff800fc00000200\",\n \"000007f800fc00003000\", \"000003f800fd0000fc00\", \"000003f000790001fe00\", \"000007e000780001fe00\", \"000007c000700001de00\",\n \"00000780000000000e00\", \"00000600000000000008\", \"00000600000000000020\", \"00000400000000000000\", \"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 => [...row].flatMap(digit => {\n const bits = parseInt(digit, 16);\n return [8, 4, 2, 1].map(bit => (bits & bit) !== 0);\n}));\n"},{"path":"registry/components/segmented-control/segmented-control.tsx","type":"registry:component","target":"@components/arc/segmented-control/segmented-control.tsx","content":"\"use client\";\n\nimport { useEffect, useId, useLayoutEffect, useRef, type KeyboardEvent, type ReactNode } from \"react\";\nimport { LayoutGroup, motion, useReducedMotion } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./segmented-control.module.css\";\n\nexport interface Segment { value: string; label: string; /** Optional content after the label, such as a badge. */ accessory?: ReactNode; }\nexport interface SegmentedControlProps {\n options: Segment[];\n value: string;\n onValueChange: (value: string) => void;\n label?: string;\n /** Called when the pointer or focus reaches an option, before it is chosen. Use it to start loading what that option shows. */\n onOptionIntent?: (value: string) => void;\n className?: string;\n}\n\nexport default function SegmentedControl({ options, value, onValueChange, label, onOptionIntent, className }: SegmentedControlProps) {\n const id = useId();\n const reduced = useReducedMotion();\n const track = useRef(null);\n\n // When the options are wider than the container, the track scrolls inside itself. Edges fade only on the side with more to see.\n useLayoutEffect(() => {\n const node = track.current;\n if (!node) return;\n const edges = () => {\n const rest = node.scrollWidth - node.clientWidth - node.scrollLeft;\n node.toggleAttribute(\"data-fade-start\", node.scrollLeft > 1);\n node.toggleAttribute(\"data-fade-end\", rest > 1);\n };\n edges();\n node.addEventListener(\"scroll\", edges, { passive: true });\n const observer = typeof ResizeObserver === \"undefined\" ? null : new ResizeObserver(edges);\n observer?.observe(node);\n return () => { node.removeEventListener(\"scroll\", edges); observer?.disconnect(); };\n }, [options.length]);\n\n // The selected option is always scrolled fully into view, with a little room so it clears the fade.\n const first = useRef(true);\n useEffect(() => {\n const node = track.current;\n const button = node?.querySelector('[aria-pressed=\"true\"]');\n if (!node || !button || node.scrollWidth <= node.clientWidth) { first.current = false; return; }\n const room = 20, start = button.offsetLeft - room, end = button.offsetLeft + button.offsetWidth + room - node.clientWidth;\n const left = node.scrollLeft > start ? start : node.scrollLeft < end ? end : node.scrollLeft;\n if (left !== node.scrollLeft) node.scrollTo({ left: Math.max(0, left), behavior: first.current || reduced ? \"auto\" : \"smooth\" });\n first.current = false;\n }, [value, reduced]);\n\n // Arrow keys, Home and End move the selection like a tab list; only the selected option is a tab stop.\n const selectedIndex = Math.max(0, options.findIndex(option => option.value === value));\n const onKeyDown = (event: KeyboardEvent) => {\n const last = options.length - 1;\n const target = event.key === \"ArrowRight\" || event.key === \"ArrowDown\" ? (selectedIndex === last ? 0 : selectedIndex + 1)\n : event.key === \"ArrowLeft\" || event.key === \"ArrowUp\" ? (selectedIndex === 0 ? last : selectedIndex - 1)\n : event.key === \"Home\" ? 0 : event.key === \"End\" ? last : -1;\n if (target < 0 || !options[target]) return;\n event.preventDefault();\n onValueChange(options[target].value);\n track.current?.querySelector(`[data-value=\"${CSS.escape(options[target].value)}\"]`)?.focus({ preventScroll: true });\n };\n\n return
\n \n {options.map((option, index) => )}\n \n
;\n}\n"},{"path":"registry/components/segmented-control/segmented-control.module.css","type":"registry:component","target":"@components/arc/segmented-control/segmented-control.module.css","content":"/* The frame can shrink below its content inside flex and grid parents; the track then scrolls inside it. */\n.root { display: inline-flex; min-width: 0; max-width: 100%; flex-shrink: 1; border: 1px solid var(--border); border-radius: var(--radius-control); background: var(--surface-muted); }\n/* The track owns the stacking context so the gliding highlight passes under every label, not over earlier ones. */\n.track { --fade-start: 0px; --fade-end: 0px; isolation: isolate; display: flex; min-width: 0; gap: 2px; overflow-x: auto; overscroll-behavior-x: contain; padding: 3px; border-radius: inherit; scrollbar-width: none; -webkit-mask-image: linear-gradient(to right, transparent, #000 var(--fade-start), #000 calc(100% - var(--fade-end)), transparent); mask-image: linear-gradient(to right, transparent, #000 var(--fade-start), #000 calc(100% - var(--fade-end)), transparent); }\n.track::-webkit-scrollbar { display: none; }\n.track[data-fade-start] { --fade-start: 20px; }\n.track[data-fade-end] { --fade-end: 20px; }\n/* Items never scale or change weight; only the highlight travels and the label color follows it. */\n.button { position: relative; flex: 0 0 auto; min-height: var(--control-height-sm); border: 0; border-radius: calc(var(--radius-control) - 4px); padding: 0 12px; background: transparent; color: var(--text-muted); font: inherit; font-size: var(--text-sm); font-weight: 500; white-space: nowrap; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard); }\n@media (hover: hover) and (pointer: fine) { .button:hover { color: var(--foreground); } }\n.button:active, .button[aria-pressed=\"true\"] { color: var(--foreground); }\n.selection { position: absolute; z-index: -1; inset: 0; border: 1px solid var(--border); border-radius: inherit; background: var(--surface); box-shadow: var(--shadow-resting); }\n.label { position: relative; z-index: 1; display: inline-flex; align-items: center; gap: 6px; }\n@media (max-width: 380px) { .button { padding: 0 10px; } }\n@media (prefers-reduced-motion: reduce) { .button { transition: none; } }\n"}],"docs":"Docs and live preview: https://uiarc.dev/components/blocks/stats-band","meta":{"tier":"free","kind":"block","docs":"https://uiarc.dev/components/blocks/stats-band","markdown":"https://uiarc.dev/components/blocks/stats-band/markdown"}}