Block
Stats band
A band of key numbers that count up as they come into view.
- #arc
- #block
- #stats
- #metrics
- #counter
- #animation
- #marketing
1280px
Install
One command installs the files and packages this item needs.
TerminalTerminalTerminalbash
npx shadcn@latest add https://dsui.vercel.app/r/stats-band.jsonFiles
components/arc/blocks/stats-band/stats-band-data.tscomponents/arc/blocks/stats-band/stats-band-world.tscomponents/arc/blocks/stats-band/stats-band.module.csscomponents/arc/blocks/stats-band/stats-band.tsx
npm packages
motion@^14.2.0
Registry items it pulls in
Source
View as Markdownstats-band-data.tsstats-band-data.tsstats-band-data.tsts
/**
* 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 233만 or 1.2억. 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 "지난해보다 늘었어요". */
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: "최근 12개월 배포 건수",
detail: "1만 2,400개 팀이 함께해요", context: "지난해 140만 건에서 늘었어요",
visual: { kind: "trend", values: [141, 148, 139, 162, 171, 184, 196, 203, 221, 238, 257, 274] },
},
{
value: 99.99, decimals: 2, suffix: "%", label: "최근 90일 API 가동률",
detail: "30초마다 측정해요", context: "12분 중단됐고, 11분은 7월 14일이었어요",
visual: { kind: "uptime", days: uptimeDays },
},
{
value: 38, suffix: "ms", label: "중간 응답 시간",
detail: "전 세계 엣지 기준이에요", context: "상위 5% 응답은 96ms로, 141ms에서 줄었어요",
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: "여섯 대륙의 엣지 리전",
detail: "대부분 20ms 안에 닿아요", context: "올해 라고스를 포함해 8곳을 더했어요",
visual: { kind: "map", points: regions },
},
];
stats-band-world.tsstats-band-world.tsstats-band-world.tsts
/**
* 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);
}));
stats-band.module.cssstats-band.module.cssstats-band.module.csscss
.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 var(--duration-moderate) 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 var(--duration-considered) 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; }
}
stats-band.tsxstats-band.tsxstats-band.tsxtsx
"use client";
import { forwardRef, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, } 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";
import { useReducedMotion } from "../../lib/use-reduced-motion";
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;
/** 숫자 서식 로캘이에요. 서버와 클라이언트가 같게 보이도록 기본값을 고정해 둬요. */
locale?: string;
className?: string;
}
type Bezier = [number, number, number, number];
const enter = [...motionTokens.ease.enter] as Bezier;
const STAGGER = .09;
/**
* 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.0만 to 233.4만) instead of jumping between units 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;
if (scale === 1) return { unit: "", format: (value: number) => digits.format(value) };
// 로캘마다 단위 크기가 달라요(한국어는 만·억·조). 대표 숫자를 줄인 결과에서 실제 나눌 크기를 거꾸로 구해요.
const parts = new Intl.NumberFormat(locale, { notation: "compact" }).formatToParts(scale);
const unit = parts.find(part => part.type === "compact")?.value ?? "";
const lead = Number(parts.filter(part => part.type === "integer").map(part => part.value).join("")) || 1;
const divisor = unit ? scale / lead : 1;
return { unit, format: (value: number) => digits.format(value / divisor) };
}, [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 = "ko-KR",
className,
}, ref) {
const id = useId();
const list = useRef<HTMLDListElement>(null);
const inView = useInView(list, { once: true, amount: .4 });
const reduced = useReducedMotion();
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 : "주요 수치"}>
<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: "구분선" }, { value: "plain", label: "기본" }];
/** 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="수치 레이아웃" options={layoutOptions} value={layout} onValueChange={next => setLayout(next as StatsBandLayout)} />
<div className={styles.frame}>
<StatsBand key={layout} layout={layout} title="빠르고 꾸준하게, 모두와 가까이" description="2025년 10월부터 2026년 9월까지 모든 리전에서 측정했어요." />
</div>
</div>;
}
export default StatsBandBlock;
Used items
- Segmented controlComponent
A row of mutually exclusive options with a highlight that moves to the chosen segment.