{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"waffle-chart","type":"registry:ui","title":"Waffle chart","description":"A ten by ten unit chart where every cell is one percent, and cells fly to their new group when the data changes.","categories":["data"],"dependencies":["motion"],"registryDependencies":["https://uiarc.dev/r/arc-motion-tokens.json"],"files":[{"path":"registry/components/waffle-chart/waffle-chart.tsx","type":"registry:component","target":"@components/arc/waffle-chart/waffle-chart.tsx","content":"\"use client\";\n\nimport { useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties, type KeyboardEvent, type PointerEvent, type Ref } from \"react\";\nimport { animate, motion, useInView, useMotionValue, useReducedMotion, useSpring, useTransform } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./waffle-chart.module.css\";\n\nexport interface WaffleCategory {\n /** Stable identity. Cells keep following their category when the data changes, so a new dataset flows instead of repainting. */\n key: string;\n /** Name in the legend, the tooltip, and the data table. */\n label: string;\n /** Raw amount. Shares are computed from the total; negatives count as zero. */\n value: number;\n /** Any CSS color. Defaults to the first series for accentKey, the supporting series for the next three, then neutral steps. */\n color?: string;\n}\n\n/** A unit chart: every cell is one share of the whole, so parts of a total read as countable squares. */\nexport interface WaffleChartProps {\n /** Categories in fill order. The first fills from the bottom-left corner, column by column. */\n data: WaffleCategory[];\n /** What the whole is, such as \"Electricity generation, 2023\". Names the chart for assistive technology. */\n label: string;\n /** Unit after each raw value in the tooltip and table, such as \"TWh\". */\n unit?: string;\n formatValue?: (value: number, category: WaffleCategory) => string;\n /** Grid rows. rows × columns cells make the whole; 10 × 10 means one cell per percent. */\n rows?: number;\n columns?: number;\n /** The category painted in the accent. Pass null to keep every category neutral. Defaults to the first category. */\n accentKey?: string | null;\n /** Controlled focused category. Others dim while one is focused. */\n activeKey?: string | null;\n defaultActiveKey?: string | null;\n onActiveChange?: (key: string | null) => void;\n /** Category list with shares that roll to their new value. */\n legend?: boolean;\n /** Decimal places for shares. */\n decimals?: number;\n emptyLabel?: string;\n ref?: Ref;\n className?: string;\n}\n\ntype Cell = { id: number; key: string | null; slot: number };\ntype Pointer = { key: string; slot: number } | null;\n\nconst { spring } = motionTokens;\nconst GAP = 3;\n/** accentKey takes the first series and the next three categories the supporting series. Anything past that falls back to neutral steps that alternate in lightness. */\nconst SHADES = [40, 20, 30, 14];\n/** A second, colour-free channel: neutral categories cycle through square, round, and framed cells, so neighbours differ in shape as well as lightness. */\nconst SHAPES = [\"square\", \"round\", \"framed\"] as const;\nconst clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));\nconst grouped = new Intl.NumberFormat(\"en-US\", { maximumFractionDigits: 1 });\n\nconst subscribeNothing = () => () => {};\nfunction useReducedMotionSafe() {\n const hydrated = useSyncExternalStore(subscribeNothing, () => true, () => false);\n return !!useReducedMotion() && hydrated;\n}\n\n/** Whole cells per category by largest remainder, so the counts always add up to the grid. */\nfunction allocate(data: WaffleCategory[], total: number, cells: number): number[] {\n if (total <= 0) return data.map(() => 0);\n const exact = data.map(item => Math.max(0, item.value) / total * cells);\n const counts = exact.map(Math.floor);\n let left = cells - counts.reduce((sum, count) => sum + count, 0);\n const order = exact.map((value, index) => ({ index, rest: value - Math.floor(value) })).sort((a, b) => b.rest - a.rest);\n for (let k = 0; left > 0 && k < order.length; k++, left--) counts[order[k]!.index]!++;\n return counts;\n}\n\n/**\n * Hands cells to categories while moving as few as possible: each category keeps the cells it already had,\n * surplus cells join the categories that grew, and every block takes its cells in their previous order so paths never cross.\n */\nfunction reassign(previous: Cell[], data: WaffleCategory[], counts: number[]): Cell[] {\n const bySlot = [...previous].sort((a, b) => a.slot - b.slot);\n const kept = new Map();\n const pool: Cell[] = [];\n const want = new Map(data.map((item, index) => [item.key, counts[index]]));\n for (const cell of bySlot) {\n const list = cell.key !== null ? kept.get(cell.key) ?? [] : [];\n if (cell.key !== null && list.length < (want.get(cell.key) ?? 0)) { list.push(cell); kept.set(cell.key, list); } else pool.push(cell);\n }\n const next: Cell[] = [];\n let slot = 0;\n data.forEach((item, index) => {\n const own = kept.get(item.key) ?? [];\n while (own.length < counts[index]! && pool.length) own.push(pool.shift()!);\n own.sort((a, b) => a.slot - b.slot).forEach(cell => next.push({ id: cell.id, key: item.key, slot: slot++ }));\n });\n pool.forEach(cell => next.push({ id: cell.id, key: null, slot: slot++ }));\n return next.sort((a, b) => a.id - b.id);\n}\n\n/** A share that rolls to its new value on a spring and holds its width with tabular figures. */\nfunction Rolling({ value, decimals, reduced }: { value: number; decimals: number; reduced: boolean }) {\n const shown = useMotionValue(value);\n const text = useTransform(shown, current => `${current.toFixed(decimals)}%`);\n useEffect(() => {\n if (reduced) { shown.jump(value); return; }\n const controls = animate(shown, value, spring.smooth);\n return () => controls.stop();\n }, [reduced, shown, value]);\n return {text};\n}\n\nexport function WaffleChart({ data, label, unit = \"\", formatValue, rows = 10, columns = 10, accentKey, activeKey, defaultActiveKey = null, onActiveChange, legend = true, decimals = 0, emptyLabel = \"No data\", ref, className }: WaffleChartProps) {\n const reduced = useReducedMotionSafe();\n const figure = useRef(null);\n const grid = useRef(null);\n const tip = useRef(null);\n useImperativeHandle(ref, () => figure.current as HTMLElement);\n const inView = useInView(figure, { once: true, amount: .35 });\n const count = Math.max(1, rows * columns);\n const total = data.reduce((sum, item) => sum + Math.max(0, item.value), 0);\n const empty = !data.length || total <= 0;\n const accent = accentKey === undefined ? data[0]?.key ?? null : accentKey;\n\n // Cells are persistent objects; each data change reassigns them so they travel instead of repainting.\n const signature = `${count}|${data.map(item => `${item.key}:${item.value}`).join(\",\")}`;\n const counts = useMemo(() => allocate(data, total, count), [signature]); // eslint-disable-line react-hooks/exhaustive-deps\n const [layout, setLayout] = useState(() => ({ cells: reassign(Array.from({ length: count }, (_, id) => ({ id, key: null, slot: id })), data, counts), moved: new Set(), version: 0 }));\n const [seen, setSeen] = useState(signature);\n if (seen !== signature) {\n setSeen(signature);\n const base = Array.from({ length: count }, (_, id) => layout.cells[id] ?? { id, key: null, slot: id });\n const next = reassign(base, data, counts);\n setLayout({ cells: next, moved: new Set(next.filter(cell => base[cell.id]!.slot !== cell.slot || base[cell.id]!.key !== cell.key).map(cell => cell.id)), version: layout.version + 1 });\n }\n const { cells, moved, version } = layout;\n\n const [ownActive, setOwnActive] = useState(defaultActiveKey);\n const pinned = activeKey !== undefined ? activeKey : ownActive;\n const setPinned = (key: string | null) => { if (activeKey === undefined) setOwnActive(key); onActiveChange?.(key); };\n const [pointer, setPointer] = useState(null);\n const [preview, setPreview] = useState(null);\n const focusKey = pointer?.key ?? preview ?? pinned;\n const active = focusKey && data.some(item => item.key === focusKey) ? focusKey : null;\n\n const [side, setSide] = useState(0);\n useLayoutEffect(() => {\n const node = grid.current;\n if (!node) return;\n const read = () => setSide(node.clientWidth);\n read();\n if (typeof ResizeObserver === \"undefined\") return;\n const observer = new ResizeObserver(read);\n observer.observe(node);\n return () => observer.disconnect();\n }, []);\n const size = side ? (side - GAP * (columns - 1)) / columns : 0;\n const height = size ? size * rows + GAP * (rows - 1) : 0;\n // Column by column from the bottom-left corner, like a glass filling up.\n const place = (slot: number) => ({ x: Math.floor(slot / rows) * (size + GAP), y: (rows - 1 - (slot % rows)) * (size + GAP) });\n\n const colorOf = (key: string | null) => {\n if (key === null) return \"transparent\";\n const index = data.findIndex(item => item.key === key), item = data[index];\n if (!item) return \"transparent\";\n if (item.color) return item.color;\n if (key === accent) return \"var(--series-1)\";\n const rank = data.filter(entry => entry.key !== accent).findIndex(entry => entry.key === key);\n return rank < 3 ? `var(--series-${rank + 2})` : `color-mix(in oklab, var(--foreground) ${SHADES[(rank - 3) % SHADES.length]}%, var(--surface))`;\n };\n\n const shapeOf = (key: string | null) => {\n if (key === null || key === accent) return \"square\";\n const rank = data.filter(entry => entry.key !== accent).findIndex(entry => entry.key === key);\n return rank < 0 ? \"square\" : SHAPES[rank % SHAPES.length];\n };\n\n // The tooltip glides between cells on its own spring and stays inside the figure.\n const tipX = useMotionValue(0), tipY = useMotionValue(0);\n const follow = { stiffness: 520, damping: 44, restDelta: .01 };\n const glideX = useSpring(tipX, follow), glideY = useSpring(tipY, follow);\n const wasShown = useRef(false);\n useLayoutEffect(() => {\n const bubble = tip.current, host = figure.current, board = grid.current;\n if (!pointer || !bubble || !host || !board || !size) { wasShown.current = false; return; }\n const hostBox = host.getBoundingClientRect(), boardBox = board.getBoundingClientRect();\n const { x, y } = place(pointer.slot);\n const cx = boardBox.left - hostBox.left + x + size / 2, cy = boardBox.top - hostBox.top + y;\n const tw = bubble.offsetWidth, th = bubble.offsetHeight;\n let top = cy - th - 10;\n if (top < 0) top = cy + size + 10;\n const left = clamp(cx - tw / 2, 0, Math.max(0, hostBox.width - tw));\n tipX.set(left); tipY.set(clamp(top, 0, Math.max(0, hostBox.height - th)));\n if (!wasShown.current || reduced) { glideX.jump(tipX.get()); glideY.jump(tipY.get()); }\n wasShown.current = true;\n });\n\n const slotAt = (clientX: number, clientY: number) => {\n const box = grid.current?.getBoundingClientRect();\n if (!box || !size) return null;\n const col = clamp(Math.floor((clientX - box.left) / (size + GAP)), 0, columns - 1);\n const row = clamp(Math.floor((clientY - box.top) / (size + GAP)), 0, rows - 1);\n return col * rows + (rows - 1 - row);\n };\n const pointAt = (slot: number | null): Pointer => {\n if (slot === null) return null;\n const cell = cells.find(item => item.slot === slot);\n return cell?.key ? { key: cell.key, slot } : null;\n };\n const onPointerMove = (event: PointerEvent) => { if (event.pointerType === \"mouse\" || event.buttons) setPointer(pointAt(slotAt(event.clientX, event.clientY))); };\n const onPointerDown = (event: PointerEvent) => { if (event.pointerType !== \"mouse\") event.currentTarget.setPointerCapture?.(event.pointerId); setPointer(pointAt(slotAt(event.clientX, event.clientY))); };\n\n // Arrows walk the grid cell by cell; Page keys jump between categories.\n const onKeyDown = (event: KeyboardEvent) => {\n if (empty) return;\n if (event.key === \"Escape\" && pointer) { event.preventDefault(); setPointer(null); return; }\n const filled = cells.filter(cell => cell.key !== null).length;\n const at = pointer?.slot ?? 0;\n const col = Math.floor(at / rows), row = at % rows;\n const blocks = counts.map((size, index) => ({ start: counts.slice(0, index).reduce((sum, value) => sum + value, 0), size })).filter(block => block.size > 0);\n const block = Math.max(0, blocks.findIndex(item => at >= item.start && at < item.start + item.size));\n const moves: Record number> = {\n ArrowUp: () => col * rows + Math.min(rows - 1, row + 1),\n ArrowDown: () => col * rows + Math.max(0, row - 1),\n ArrowRight: () => Math.min(columns - 1, col + 1) * rows + row,\n ArrowLeft: () => Math.max(0, col - 1) * rows + row,\n PageDown: () => blocks[Math.min(blocks.length - 1, block + 1)]!.start,\n PageUp: () => at > blocks[block]!.start ? blocks[block]!.start : blocks[Math.max(0, block - 1)]!.start,\n Home: () => 0,\n End: () => filled - 1,\n };\n const move = moves[event.key];\n if (!move) return;\n event.preventDefault();\n setPointer(pointAt(clamp(pointer ? move() : 0, 0, Math.max(0, filled - 1))));\n };\n\n const format = (value: number, item: WaffleCategory) => `${formatValue ? formatValue(value, item) : grouped.format(value)}${unit ? ` ${unit}` : \"\"}`;\n const share = (item: WaffleCategory) => total > 0 ? Math.max(0, item.value) / total * 100 : 0;\n const reading = pointer ? data.find(item => item.key === pointer.key) ?? null : null;\n const announce = reading ? `${reading.label}, ${share(reading).toFixed(decimals)}%, ${format(reading.value, reading)}` : \"\";\n const summary = empty ? `${label}. ${emptyLabel}.` : `${label}. ${data.map(item => `${item.label} ${share(item).toFixed(decimals)}%`).join(\", \")}.`;\n const shownX = reduced ? tipX : glideX, shownY = reduced ? tipY : glideY;\n const settle = reduced ? { duration: 0 } : spring.morph;\n\n return
\n
\n
\n
{ if (event.pointerType !== \"mouse\") setPointer(null); }} onPointerCancel={() => setPointer(null)} onPointerLeave={event => { if (event.pointerType === \"mouse\") setPointer(null); }}\n onKeyDown={onKeyDown} onBlur={() => setPointer(null)} onFocus={event => { if (event.currentTarget.matches(\":focus-visible\") && !empty) setPointer(current => current ?? pointAt(0)); }}>\n {size > 0 && cells.map(cell => {\n const { x, y } = place(cell.slot);\n const travel = moved.has(cell.id) && !reduced;\n const delay = reduced ? 0 : (cell.slot / count) * .32;\n const on = cell.key !== null && cell.key === active;\n const shown = inView || reduced;\n // A travelling cell dips while it flies, so the flow reads as cells lifting out of one block and landing in another.\n const dip = travel ? [1, .62 + (version % 2) * .001, 1] : 1;\n return ;\n })}\n
\n {!empty && \n

\n \n {reading?.label ?? \"\"}\n {reading ? `${share(reading).toFixed(decimals)}%` : \"\"}\n

\n

{reading ? format(reading.value, reading) : \"\"}

\n
}\n {empty &&

{emptyLabel}

}\n
\n {legend && !empty &&
    setPreview(null)}>\n {data.map(item =>
  • \n \n
  • )}\n
}\n
\n

{announce}

\n

{summary}

\n {!empty &&
\n \n \n {data.map((item, index) => )}\n
{label}
CategoryValueShareCells
{item.label}{format(item.value, item)}{share(item).toFixed(decimals)}%{counts[index]}
}\n
;\n}\n\nexport default WaffleChart;\n"},{"path":"registry/components/waffle-chart/waffle-chart.module.css","type":"registry:component","target":"@components/arc/waffle-chart/waffle-chart.module.css","content":".figure { position: relative; min-width: 0; margin: 0; color: var(--foreground); font-family: var(--font-body); letter-spacing: var(--tracking-body); container-type: inline-size; }\n\n/* Series colors (--series-1 to --series-4) come from registry/foundation.css. */\n.body { display: grid; min-width: 0; grid-template-columns: minmax(0, 1fr); align-items: center; gap: var(--space-6); }\n@container (min-width: 420px) {\n .figure[data-legend] .body { grid-template-columns: minmax(0, 232px) minmax(0, 1fr); gap: var(--space-6); }\n}\n\n.board { min-width: 0; }\n.grid { position: relative; width: 100%; max-width: 300px; margin-inline: auto; outline: none; touch-action: pan-y; user-select: none; -webkit-user-select: none; -webkit-tap-highlight-color: transparent; cursor: default; }\n\n/* Each cell is one unit. Its colour changes mid-flight with the same stagger as its travel. */\n.cell { --inner: 0 0 0 0 transparent; --outer: 0 0 0 0 transparent; position: absolute; top: 0; left: 0; border-radius: 22%; background: var(--cell); box-shadow: var(--inner), var(--outer); will-change: transform; transition: background-color var(--duration-considered) var(--ease-standard) var(--delay, 0s), border-radius var(--duration-considered) var(--ease-standard) var(--delay, 0s), box-shadow var(--duration-considered) var(--ease-standard) var(--delay, 0s), opacity var(--duration-fast) var(--ease-standard); }\n.grid[data-active] .cell:not([data-on]) { opacity: .26 !important; transition: background-color var(--duration-considered) var(--ease-standard) var(--delay, 0s), border-radius var(--duration-considered) var(--ease-standard) var(--delay, 0s), opacity var(--duration-standard) var(--ease-standard); }\n.cell[data-cursor] { --outer: 0 0 0 2px var(--surface), 0 0 0 3.5px color-mix(in oklab, var(--foreground) 55%, transparent); }\n\n/* Shape is the colour-free second channel: a cell morphs between these as it changes category. */\n.cell[data-shape=\"round\"], .swatch[data-shape=\"round\"], .tipSwatch[data-shape=\"round\"] { border-radius: 50%; }\n.cell[data-shape=\"framed\"] { --inner: inset 0 0 0 2.5px var(--cell), inset 0 0 0 3.25px var(--surface); }\n.swatch[data-shape=\"framed\"], .tipSwatch[data-shape=\"framed\"] { box-shadow: inset 0 0 0 2px var(--cell), inset 0 0 0 3px var(--surface); }\n\n.tooltip { position: absolute; top: 0; left: 0; z-index: 2; display: grid; min-width: 150px; max-width: min(240px, 100%); gap: 2px; padding: 8px 12px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface-raised); box-shadow: var(--shadow-floating); opacity: 0; pointer-events: none; scale: .96; transition: opacity var(--duration-fast) var(--ease-standard), scale var(--duration-fast) var(--ease-standard); }\n.tooltip[data-shown] { opacity: 1; scale: 1; }\n.tipRow { display: flex; min-width: 0; align-items: center; gap: 8px; margin: 0; font-size: var(--text-sm); line-height: 1.3; }\n.tipSwatch { width: 10px; height: 10px; flex: none; border-radius: 3px; background: var(--cell); transition: background-color var(--duration-fast) var(--ease-standard); }\n.tipName { min-width: 0; flex: 1; overflow: hidden; color: var(--text-secondary); text-overflow: ellipsis; white-space: nowrap; }\n.tipValue { flex: none; margin-left: 8px; font-weight: 500; font-variant-numeric: tabular-nums; }\n.tipMeta { margin: 0 0 0 18px; color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); font-variant-numeric: tabular-nums; white-space: nowrap; }\n\n.message { display: grid; min-height: 160px; place-items: center; margin: 0; color: var(--text-muted); font-size: var(--text-sm); }\n\n/* The legend doubles as the reading: each share rolls to its new value. */\n.legend { display: grid; min-width: 0; grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); gap: 2px var(--space-2); margin: 0; padding: 0; list-style: none; }\n@container (min-width: 420px) { .figure[data-legend] .legend { grid-template-columns: minmax(0, 1fr); } }\n/* Below the side-by-side layout two columns would clip names like \"Wind and solar\", so the legend reads as one list. */\n/* Stacked, the legend shares the grid's measure and its row padding hangs outside, so swatches and shares line up with the cell edges. */\n@container (max-width: 419px) { .legend { grid-template-columns: minmax(0, 1fr); margin-inline: max(-10px, calc((100% - 320px) / 2)); } }\n.item { display: grid; width: 100%; min-height: 36px; grid-template-columns: 10px minmax(0, 1fr) auto; align-items: center; gap: 10px; padding: 4px 10px; border: 0; border-radius: 12px; background: transparent; color: var(--text-secondary); cursor: pointer; font: inherit; font-size: var(--text-sm); line-height: var(--leading-body); text-align: left; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard), opacity var(--duration-fast) var(--ease-standard), transform var(--duration-instant) var(--ease-standard); }\n.item:active { transform: scale(.98); }\n.item[data-on] { color: var(--foreground); background: color-mix(in oklab, var(--foreground) 5%, transparent); }\n.item[data-dim] { opacity: .5; }\n.item[aria-pressed=\"true\"] { color: var(--foreground); }\n@media (hover: hover) and (pointer: fine) { .item:hover { color: var(--foreground); } }\n.swatch { width: 10px; height: 10px; border-radius: 3px; background: var(--cell); }\n.itemLabel { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.itemShare { color: var(--foreground); font-size: var(--text-base); font-weight: 500; font-variant-numeric: tabular-nums; text-align: right; min-width: 3.2em; }\n\n.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n\n@media (prefers-reduced-motion: reduce) {\n .cell, .grid[data-active] .cell:not([data-on]), .tooltip, .tipSwatch, .item { transition: none; }\n}\n"}],"docs":"Docs and live preview: https://uiarc.dev/components/waffle-chart","meta":{"tier":"free","kind":"component","docs":"https://uiarc.dev/components/waffle-chart","markdown":"https://uiarc.dev/components/waffle-chart/markdown","tags":["data","chart","new"]}}