{"slug":"chart","name":"Chart","description":"Animated SVG charts in the shadcn style with zero chart-library dependencies: area and line charts with monotone, linear or stepped curves that reveal left to right (and again when the range changes), grouped, stacked or horizontal bars that grow from the baseline, donut and pie slices that sweep in clockwise, and radial progress rings. Every chart shares one tooltip with dot, line or dashed indicators, arrow-key reading, a legend and a screen-reader table. Colors come from the theme's --chart-1…5 tokens, validated for color-blind separation; reduced-motion safe.","category":"motion","source_url":"https://karaui.kamisama-v0.my.id/r/chart/raw","detail_url":"https://karaui.kamisama-v0.my.id/r/chart","raw_url":"https://karaui.kamisama-v0.my.id/r/chart/raw","page_url":"https://karaui.kamisama-v0.my.id/components/motion/chart","markdown_url":"https://karaui.kamisama-v0.my.id/components/motion/chart.md","published_at":"2026-10-14","updated_at":"2026-10-14","dependencies":["clsx","motion","react","tailwind-merge"],"internal":["@/components/motion/chart/cartesian","@/components/motion/chart/shared","@/lib/ease","@/lib/utils"],"files":[{"path":"components/motion/chart/area-chart.tsx","type":"component","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/motion/chart\n\nimport {\n  CartesianChart,\n  type CartesianChartProps,\n} from \"@/components/motion/chart/cartesian\";\n\nexport interface AreaChartProps extends CartesianChartProps {\n  /** Gradient fades toward the baseline; solid keeps one translucent tone. */\n  fill?: \"gradient\" | \"solid\";\n}\n\n/**\n * Volume over time: filled areas (overlapping or stacked) that rise from the\n * baseline as their lines draw in, on scroll and whenever the range changes,\n * with a snapping cursor and one tooltip for every series.\n */\nexport function AreaChart(props: AreaChartProps) {\n  return <CartesianChart type=\"area\" {...props} />;\n}\n"},{"path":"components/motion/chart/cartesian.tsx","type":"util","content":"\"use client\";\n\nimport { motion, useInView, useReducedMotion } from \"motion/react\";\nimport { type PointerEvent, useId, useRef, useState } from \"react\";\nimport {\n  type ChartCurve,\n  type ChartDatum,\n  ChartLegend,\n  type ChartSeries,\n  ChartTable,\n  ChartTooltip,\n  curvePath,\n  formatCompact,\n  formatFull,\n  GROW,\n  gutterFor,\n  labelStride,\n  niceTicks,\n  seriesColor,\n  stepIndex,\n  type TooltipIndicator,\n  useChartWidth,\n} from \"@/components/motion/chart/shared\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface CartesianChartProps {\n  data: ChartDatum[];\n  /** Key of the x value in each datum (dates, months…). */\n  index: string;\n  series: ChartSeries[];\n  /** Plot height in px. Default 250. */\n  height?: number;\n  /** Default \"natural\" (smooth, never overshoots a point). */\n  curve?: ChartCurve;\n  /** Stack series on top of each other instead of overlapping them. */\n  stacked?: boolean;\n  /** Draw a dot on every point. */\n  dots?: boolean;\n  /** Print each point's value above it. Best for short series. */\n  showValues?: boolean;\n  /** Show the y axis labels. Default false: gridlines carry the scale. */\n  yAxis?: boolean;\n  /** Show the legend. Default: when there are two or more series. */\n  legend?: boolean;\n  indicator?: TooltipIndicator;\n  /** Formats x labels and the tooltip title. */\n  formatIndex?: (value: string) => string;\n  formatValue?: (value: number) => string;\n  formatAxis?: (value: number) => string;\n  /** Names the chart for assistive tech and the table view. */\n  title: string;\n  className?: string;\n}\n\nconst AXIS = 28; // x labels\nconst PAD_X = 12;\nconst PAD_TOP = 12;\n\n/**\n * Shared engine for line and area charts: monotone, linear or stepped curves,\n * optional stacking and gradient fills; lines draw in left to right and areas\n * rise from the baseline when scrolled into view (replayed when the range changes), a hairline cursor that\n * snaps to the nearest x, and one tooltip listing every series there.\n */\nexport function CartesianChart({\n  type,\n  fill = \"gradient\",\n  data,\n  index,\n  series,\n  height = 250,\n  curve = \"natural\",\n  stacked = false,\n  dots = false,\n  showValues = false,\n  yAxis = false,\n  legend,\n  indicator = \"dot\",\n  formatIndex = (v) => v,\n  formatValue = formatFull,\n  formatAxis = formatCompact,\n  title,\n  className,\n}: CartesianChartProps & {\n  type: \"line\" | \"area\";\n  fill?: \"gradient\" | \"solid\";\n}) {\n  const uid = useId().replace(/:/g, \"\");\n  const reduce = useReducedMotion();\n  const [measureRef, width] = useChartWidth();\n  const viewRef = useRef<HTMLDivElement>(null);\n  const inView = useInView(viewRef, { once: true, amount: 0.3 });\n  const [active, setActive] = useState<number | null>(null);\n  const shown = reduce || inView;\n\n  const value = (d: ChartDatum, key: string) => Number(d[key]) || 0;\n\n  // Per-series [lower, upper] for every point; lower is 0 unless stacked.\n  const bands = series.map((_, si) =>\n    data.map((d) => {\n      const lower = stacked\n        ? series.slice(0, si).reduce((sum, s) => sum + value(d, s.key), 0)\n        : 0;\n      return [lower, lower + value(d, series[si].key)] as const;\n    }),\n  );\n  const tops = bands.flatMap((b) => b.map(([, up]) => up));\n  const ticks = niceTicks(Math.min(0, ...tops), Math.max(...tops, 0));\n  const lo = ticks[0];\n  const hi = ticks[ticks.length - 1];\n\n  const left = yAxis ? gutterFor(ticks.map(formatAxis)) : PAD_X;\n  const plotW = Math.max(0, width - left - PAD_X);\n  const step = data.length > 1 ? plotW / (data.length - 1) : 0;\n  const x = (i: number) => left + i * step;\n  const y = (v: number) => PAD_TOP + (1 - (v - lo) / (hi - lo)) * (height - PAD_TOP);\n  const stride = labelStride(data.length, plotW, 64);\n  const last = data.length - 1;\n  const showLegend = legend ?? series.length > 1;\n\n  const linePts = (si: number) =>\n    bands[si].map(([, up], i) => [x(i), y(up)] as [number, number]);\n  const areaPath = (si: number) => {\n    const top = curvePath(linePts(si), curve);\n    const bottom = curvePath(\n      bands[si].map(([low], i) => [x(i), y(low)] as [number, number]).reverse(),\n      curve,\n    ).replace(/^M/, \"L\");\n    return `${top}${bottom}Z`;\n  };\n\n  // Replays the reveal when the visible range changes (e.g. 7d → 90d).\n  const revealKey = `${data.length}-${String(data[0]?.[index] ?? \"\")}`;\n\n  const onPointerMove = (e: PointerEvent<SVGSVGElement>) => {\n    const box = e.currentTarget.getBoundingClientRect();\n    const i = Math.round((e.clientX - box.left - left) / (step || 1));\n    setActive(Math.min(last, Math.max(0, i)));\n  };\n\n  const activeTop =\n    active === null\n      ? 0\n      : Math.min(...series.map((_, si) => y(bands[si][active][1])));\n\n  return (\n    <div ref={viewRef} className={cn(\"w-full\", className)}>\n      <div ref={measureRef} className=\"relative w-full\">\n        {width > 0 && data.length > 0 && (\n          // biome-ignore lint/a11y/useSemanticElements: a chart is not a form; group labels the plot\n          <svg\n            width={width}\n            height={height + AXIS}\n            role=\"group\"\n            aria-label={`${title}. Use arrow keys to read each point.`}\n            // biome-ignore lint/a11y/noNoninteractiveTabindex: focusable chart widget; arrow keys read values\n            tabIndex={0}\n            onKeyDown={(e) => {\n              const next = stepIndex(e.key, active, data.length);\n              if (next === undefined) return;\n              e.preventDefault();\n              setActive(next);\n            }}\n            onPointerMove={onPointerMove}\n            onPointerLeave={() => setActive(null)}\n            onBlur={() => setActive(null)}\n            className=\"block touch-pan-y overflow-visible rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n          >\n            <defs>\n              {type === \"area\" &&\n                series.map((s, si) => (\n                  <linearGradient\n                    key={s.key}\n                    id={`${uid}-fill-${si}`}\n                    x1=\"0\"\n                    x2=\"0\"\n                    y1=\"0\"\n                    y2=\"1\"\n                  >\n                    <stop\n                      offset=\"5%\"\n                      stopColor={seriesColor(s, si)}\n                      stopOpacity={fill === \"gradient\" ? 0.6 : 0.4}\n                    />\n                    <stop\n                      offset=\"95%\"\n                      stopColor={seriesColor(s, si)}\n                      stopOpacity={fill === \"gradient\" ? 0.05 : 0.4}\n                    />\n                  </linearGradient>\n                ))}\n            </defs>\n\n            {ticks.map((t) => (\n              <g key={t}>\n                <line\n                  x1={yAxis ? left : 0}\n                  x2={width}\n                  y1={y(t)}\n                  y2={y(t)}\n                  stroke=\"var(--border-strong)\"\n                  shapeRendering=\"crispEdges\"\n                />\n                {yAxis && (\n                  <text\n                    x={left - 8}\n                    y={y(t)}\n                    dy=\"0.32em\"\n                    textAnchor=\"end\"\n                    className=\"fill-muted-foreground text-xs tabular-nums\"\n                  >\n                    {formatAxis(t)}\n                  </text>\n                )}\n              </g>\n            ))}\n\n            {data.map((d, i) =>\n              // Every stride-th label plus the last, minus a strided one that\n              // would crowd the last.\n              i === last || (i % stride === 0 && (last - i) * step >= 64) ? (\n                <text\n                  // biome-ignore lint/suspicious/noArrayIndexKey: x positions keep their order.\n                  key={i}\n                  x={x(i)}\n                  y={height + 20}\n                  textAnchor={i === 0 ? \"start\" : i === last ? \"end\" : \"middle\"}\n                  className=\"fill-muted-foreground text-xs\"\n                >\n                  {formatIndex(String(d[index]))}\n                </text>\n              ) : null,\n            )}\n\n            {active !== null && (\n              <line\n                x1={x(active)}\n                x2={x(active)}\n                y1={PAD_TOP}\n                y2={height}\n                stroke=\"var(--muted-foreground)\"\n                strokeOpacity={0.4}\n                shapeRendering=\"crispEdges\"\n              />\n            )}\n\n            {/* Remounted per range so the entrance replays when the data\n                window changes (e.g. 7 days → 3 months). */}\n            <g key={revealKey}>\n              {type === \"area\" &&\n                series.map((s, si) => (\n                  <motion.path\n                    key={s.key}\n                    d={areaPath(si)}\n                    fill={`url(#${uid}-fill-${si})`}\n                    initial={reduce ? false : { opacity: 0, scaleY: 0.4 }}\n                    animate={shown ? { opacity: 1, scaleY: 1 } : { opacity: 0, scaleY: 0.4 }}\n                    transition={reduce ? { duration: 0 } : { ...GROW, duration: 0.9, delay: 0.15 }}\n                    style={{ transformBox: \"view-box\", transformOrigin: `0px ${y(lo)}px` }}\n                  />\n                ))}\n              {series.map((s, si) => (\n                <motion.path\n                  key={s.key}\n                  d={curvePath(linePts(si), curve)}\n                  fill=\"none\"\n                  stroke={seriesColor(s, si)}\n                  strokeWidth={2}\n                  strokeLinejoin=\"round\"\n                  strokeLinecap=\"round\"\n                  initial={reduce ? false : { pathLength: 0 }}\n                  animate={{ pathLength: shown ? 1 : 0 }}\n                  transition={reduce ? { duration: 0 } : { duration: 1.1, ease: GROW.ease, delay: si * 0.08 }}\n                />\n              ))}\n              {(dots || showValues) && (\n                <motion.g\n                  initial={reduce ? false : { opacity: 0 }}\n                  animate={{ opacity: shown ? 1 : 0 }}\n                  transition={reduce ? { duration: 0 } : { duration: 0.3, delay: 0.9 }}\n                >\n                  {dots &&\n                    series.map((s, si) =>\n                      linePts(si).map(([cx, cy], i) => (\n                        <circle\n                          // biome-ignore lint/suspicious/noArrayIndexKey: points keep their order.\n                          key={`${s.key}-${i}`}\n                          cx={cx}\n                          cy={cy}\n                          r={3.5}\n                          fill={seriesColor(s, si)}\n                          stroke=\"var(--card)\"\n                          strokeWidth={2}\n                        />\n                      )),\n                    )}\n                  {showValues &&\n                    series.map((s, si) =>\n                      linePts(si).map(([cx, cy], i) => (\n                        <text\n                          // biome-ignore lint/suspicious/noArrayIndexKey: points keep their order.\n                          key={`${s.key}-v${i}`}\n                          x={cx}\n                          y={cy - 12}\n                          textAnchor=\"middle\"\n                          className=\"fill-foreground text-xs font-medium tabular-nums\"\n                        >\n                          {formatValue(value(data[i], s.key))}\n                        </text>\n                      )),\n                    )}\n                </motion.g>\n              )}\n            </g>\n\n            {active !== null &&\n              series.map((s, si) => (\n                <motion.circle\n                  key={s.key}\n                  initial={reduce ? false : { scale: 0.4 }}\n                  animate={{ scale: 1, cx: x(active), cy: y(bands[si][active][1]) }}\n                  transition={\n                    reduce\n                      ? { duration: 0 }\n                      : { type: \"spring\", stiffness: 520, damping: 40 }\n                  }\n                  r={5}\n                  fill={seriesColor(s, si)}\n                  stroke=\"var(--card)\"\n                  strokeWidth={2}\n                  style={{ transformBox: \"fill-box\", transformOrigin: \"center\" }}\n                />\n              ))}\n          </svg>\n        )}\n        <ChartTooltip\n          open={active !== null}\n          x={active === null ? 0 : x(active)}\n          y={activeTop}\n          width={width}\n          indicator={indicator}\n          label={active === null ? \"\" : formatIndex(String(data[active][index]))}\n          rows={\n            active === null\n              ? []\n              : (() => {\n                  const rows = series.map((s, si) => ({\n                    label: s.label,\n                    value: formatValue(value(data[active], s.key)),\n                    color: seriesColor(s, si),\n                  }));\n                  // Stacked: read top-down, the way the bands sit.\n                  return stacked ? rows.reverse() : rows;\n                })()\n          }\n        />\n      </div>\n      {showLegend && (\n        <ChartLegend\n          items={series.map((s, si) => ({ label: s.label, color: seriesColor(s, si) }))}\n        />\n      )}\n      <ChartTable\n        caption={title}\n        head={[index, ...series.map((s) => s.label)]}\n        rows={data.map((d) => [\n          formatIndex(String(d[index])),\n          ...series.map((s) => formatValue(value(d, s.key))),\n        ])}\n      />\n    </div>\n  );\n}\n"},{"path":"components/motion/chart/shared.tsx","type":"util","content":"\"use client\";\n\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { type ReactNode, useEffect, useState } from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ChartSeries = {\n  /** Key of the value in each datum. */\n  key: string;\n  label: string;\n  /** Any CSS color. Default: the next `--chart-N` slot, in fixed order. */\n  color?: string;\n};\n\nexport type ChartDatum = Record<string, string | number>;\n\n/** \"natural\" is a monotone curve: smooth, but never overshoots a data point. */\nexport type ChartCurve = \"natural\" | \"linear\" | \"step\";\n\nexport type TooltipIndicator = \"dot\" | \"line\" | \"dashed\";\n\n// Fixed categorical order, validated for colorblind and normal-vision\n// separation (light and dark steps live in the theme's --chart-* tokens).\n// Fallbacks are the light steps, for projects without the theme layer.\nconst SLOT_FALLBACKS = [\"#2a78d6\", \"#eb6834\", \"#1baf7a\", \"#eda100\", \"#e87ba4\"];\n\n/** Color for slot `index`; color follows the series, never its rank. */\nexport function seriesColor(series: { color?: string }, index: number) {\n  if (series.color) return series.color;\n  const slot = index % SLOT_FALLBACKS.length;\n  return `var(--chart-${slot + 1}, ${SLOT_FALLBACKS[slot]})`;\n}\n\n/**\n * Layout width of the element given the returned ref. ResizeObserver reports\n * the untransformed box, unlike getBoundingClientRect (react-use-measure), so a\n * chart inside a scaled container (thumbnails, zoomed stages) still fills it.\n */\nexport function useChartWidth<T extends Element = HTMLDivElement>() {\n  const [el, setEl] = useState<T | null>(null);\n  const [width, setWidth] = useState(0);\n  useEffect(() => {\n    if (!el) return;\n    const observer = new ResizeObserver(([entry]) =>\n      setWidth(entry.contentRect.width),\n    );\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [el]);\n  return [setEl, width] as const;\n}\n\n// A fixed locale keeps server and client output identical for hydration.\nconst FULL = new Intl.NumberFormat(\"en-US\", { maximumFractionDigits: 2 });\nconst COMPACT = new Intl.NumberFormat(\"en-US\", {\n  notation: \"compact\",\n  maximumFractionDigits: 1,\n});\nexport const formatFull = (n: number) => FULL.format(n);\nexport const formatCompact = (n: number) => COMPACT.format(n);\n\n/** Round tick values covering [min, max], about `count` of them. */\nexport function niceTicks(min: number, max: number, count = 4) {\n  if (min === max) max = min + 1;\n  const raw = (max - min) / count;\n  const mag = 10 ** Math.floor(Math.log10(raw));\n  const step =\n    [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => s >= raw) ?? 10 * mag;\n  const start = Math.floor(min / step) * step;\n  const end = Math.ceil(max / step) * step;\n  const ticks: number[] = [];\n  for (let v = start; v <= end + step / 2; v += step) {\n    ticks.push(Number(v.toFixed(10)));\n  }\n  return ticks;\n}\n\n/** Keep every k-th label so neighbours stay at least `minGap` px apart. */\nexport function labelStride(count: number, width: number, minGap = 56) {\n  return Math.max(1, Math.ceil(count / Math.max(1, Math.floor(width / minGap))));\n}\n\n/** Approximate gutter for axis labels at 12px. */\nexport function gutterFor(labels: string[]) {\n  return Math.max(...labels.map((l) => l.length), 1) * 7 + 12;\n}\n\ntype Point = [number, number];\n\n/** SVG path through `points` (sorted by x) with the given curve. */\nexport function curvePath(points: Point[], curve: ChartCurve) {\n  if (points.length === 0) return \"\";\n  const [x0, y0] = points[0];\n  if (points.length === 1) return `M${x0},${y0}`;\n  if (curve === \"step\") {\n    // Changes value halfway between points, like a stepped series.\n    let d = `M${x0},${y0}`;\n    for (let i = 1; i < points.length; i++) {\n      const [px] = points[i - 1];\n      const [x, y] = points[i];\n      const mid = (px + x) / 2;\n      d += `H${mid}V${y}H${x}`;\n    }\n    return d;\n  }\n  if (curve === \"linear\" || points.length < 3) {\n    return points.map(([x, y], i) => `${i ? \"L\" : \"M\"}${x},${y}`).join(\"\");\n  }\n  // Monotone cubic (Fritsch–Carlson): tangents are clamped so the curve stays\n  // between its neighbours and never invents a peak the data doesn't have.\n  const n = points.length;\n  const slopes: number[] = [];\n  for (let i = 0; i < n - 1; i++) {\n    const dx = points[i + 1][0] - points[i][0];\n    slopes.push(dx ? (points[i + 1][1] - points[i][1]) / dx : 0);\n  }\n  const tangents = points.map((_, i) => {\n    if (i === 0) return slopes[0];\n    if (i === n - 1) return slopes[n - 2];\n    const a = slopes[i - 1];\n    const b = slopes[i];\n    return a * b <= 0 ? 0 : (a + b) / 2;\n  });\n  for (let i = 0; i < n - 1; i++) {\n    const m = slopes[i];\n    if (m === 0) {\n      tangents[i] = 0;\n      tangents[i + 1] = 0;\n      continue;\n    }\n    const a = tangents[i] / m;\n    const b = tangents[i + 1] / m;\n    const s = a * a + b * b;\n    if (s > 9) {\n      const t = 3 / Math.sqrt(s);\n      tangents[i] = t * a * m;\n      tangents[i + 1] = t * b * m;\n    }\n  }\n  let d = `M${x0},${y0}`;\n  for (let i = 0; i < n - 1; i++) {\n    const [xa, ya] = points[i];\n    const [xb, yb] = points[i + 1];\n    const h = (xb - xa) / 3;\n    d += `C${xa + h},${ya + tangents[i] * h} ${xb - h},${yb - tangents[i + 1] * h} ${xb},${yb}`;\n  }\n  return d;\n}\n\n// ── tooltip ────────────────────────────────────────────────────────────────\n\nexport type TooltipRow = { label: string; value: string; color: string };\n\nfunction Indicator({ kind, color }: { kind: TooltipIndicator; color: string }) {\n  return (\n    <span\n      aria-hidden\n      className={cn(\n        \"shrink-0 rounded-[2px]\",\n        kind === \"dot\" && \"size-2.5\",\n        kind === \"line\" && \"w-1 self-stretch\",\n        kind === \"dashed\" && \"w-0 self-stretch border-[1.5px] border-dashed\",\n      )}\n      style={\n        kind === \"dashed\" ? { borderColor: color } : { background: color }\n      }\n    />\n  );\n}\n\n/**\n * Compact readout that springs between data positions: an optional label,\n * then one row per series with a colored indicator, the name and the value.\n */\nexport function ChartTooltip({\n  open,\n  x,\n  y,\n  width,\n  label,\n  rows,\n  indicator = \"dot\",\n}: {\n  open: boolean;\n  /** Anchor in px, relative to the chart's positioned wrapper. */\n  x: number;\n  y: number;\n  /** Wrapper width, to flip the tooltip before it overflows the edge. */\n  width: number;\n  label?: string;\n  rows: TooltipRow[];\n  indicator?: TooltipIndicator;\n}) {\n  const reduce = useReducedMotion();\n  const flip = x > width / 2;\n  return (\n    <AnimatePresence>\n      {open && (\n        <motion.div\n          aria-hidden\n          initial={{ opacity: 0, x, y, scale: reduce ? 1 : 0.96 }}\n          animate={{ opacity: 1, x, y, scale: 1 }}\n          exit={{ opacity: 0, scale: reduce ? 1 : 0.96, transition: { duration: 0.1 } }}\n          transition={\n            reduce\n              ? { duration: 0 }\n              : {\n                  opacity: { duration: 0.12 },\n                  scale: { duration: 0.15, ease: EASE_OUT },\n                  // Tracks the pointer between data positions; firm enough to\n                  // never trail behind a quick sweep.\n                  default: { type: \"spring\", stiffness: 520, damping: 42 },\n                }\n          }\n          className=\"pointer-events-none absolute top-0 left-0 z-10\"\n        >\n          <div\n            className={cn(\n              \"grid min-w-32 -translate-y-1/2 gap-1.5 rounded-lg border border-border bg-background px-2.5 py-1.5 text-xs shadow-xl\",\n              flip ? \"-translate-x-[calc(100%+12px)]\" : \"translate-x-3\",\n            )}\n          >\n            {label ? (\n              <p className=\"font-medium text-foreground\">{label}</p>\n            ) : null}\n            {rows.map((row) => (\n              <div key={row.label} className=\"flex items-stretch gap-2\">\n                <Indicator kind={indicator} color={row.color} />\n                <div className=\"flex flex-1 items-center justify-between gap-4 leading-none\">\n                  <span className=\"text-muted-foreground\">{row.label}</span>\n                  <span className=\"font-mono font-medium tabular-nums text-foreground\">\n                    {row.value}\n                  </span>\n                </div>\n              </div>\n            ))}\n          </div>\n        </motion.div>\n      )}\n    </AnimatePresence>\n  );\n}\n\n// ── legend & table ─────────────────────────────────────────────────────────\n\nexport function ChartLegend({\n  items,\n  className,\n}: {\n  items: { label: string; color: string }[];\n  className?: string;\n}) {\n  return (\n    <ul\n      className={cn(\n        \"flex flex-wrap items-center justify-center gap-x-4 gap-y-1 pt-3 text-xs text-muted-foreground\",\n        className,\n      )}\n    >\n      {items.map((item) => (\n        <li key={item.label} className=\"flex items-center gap-1.5\">\n          <span\n            aria-hidden\n            className=\"size-2 shrink-0 rounded-[2px]\"\n            style={{ background: item.color }}\n          />\n          {item.label}\n        </li>\n      ))}\n    </ul>\n  );\n}\n\n/** The table view: every value reachable without hovering or seeing color. */\nexport function ChartTable({\n  caption,\n  head,\n  rows,\n}: {\n  caption: string;\n  head: string[];\n  rows: ReactNode[][];\n}) {\n  // The wrapper is what hides it: a <caption> escapes a visually hidden\n  // table's clip and would otherwise paint over the chart.\n  return (\n    <div className=\"sr-only\">\n      <table>\n        <caption>{caption}</caption>\n        <thead>\n          <tr>\n            {head.map((h) => (\n              <th key={h} scope=\"col\">\n                {h}\n              </th>\n            ))}\n          </tr>\n        </thead>\n        <tbody>\n          {rows.map((row, r) => (\n            // biome-ignore lint/suspicious/noArrayIndexKey: rows mirror data order and never reorder.\n            <tr key={r}>\n              {row.map((cell, c) =>\n                c === 0 ? (\n                  // biome-ignore lint/suspicious/noArrayIndexKey: fixed column order.\n                  <th key={c} scope=\"row\">\n                    {cell}\n                  </th>\n                ) : (\n                  // biome-ignore lint/suspicious/noArrayIndexKey: fixed column order.\n                  <td key={c}>{cell}</td>\n                ),\n              )}\n            </tr>\n          ))}\n        </tbody>\n      </table>\n    </div>\n  );\n}\n\n/** Shared entrance timing: marks grow once the chart scrolls into view. */\nexport const GROW = { duration: 0.7, ease: EASE_OUT } as const;\n\n/** Arrow-key stepping shared by every chart that reads positions. */\nexport function stepIndex(\n  key: string,\n  current: number | null,\n  count: number,\n  wrap = false,\n) {\n  const forward = key === \"ArrowRight\" || key === \"ArrowDown\";\n  const back = key === \"ArrowLeft\" || key === \"ArrowUp\";\n  if (!forward && !back) return undefined;\n  if (current === null) return forward ? 0 : count - 1;\n  const next = current + (forward ? 1 : -1);\n  if (wrap) return (next + count) % count;\n  return Math.min(count - 1, Math.max(0, next));\n}\n"},{"path":"lib/utils.ts","type":"util","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"},{"path":"lib/ease.ts","type":"util","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"}]}