{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "area-chart",
  "title": "Area Chart",
  "description": "Area chart for React and shadcn that draws in once, morphs to new data while its scale springs, and has a crosshair tooltip.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://eased.dev/r/eased-tokens.json",
    "https://eased.dev/r/eased-motion.json",
    "https://eased.dev/r/eased-chart-core.json"
  ],
  "files": [
    {
      "path": "src/registry/eased/area-chart/area-chart.tsx",
      "content": "\"use client\";\n\nimport { animate, motion, useMotionValue, useTransform } from \"motion/react\";\nimport { useCallback, useId, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport { snappy, useSafeReducedMotion } from \"../lib/motion\";\nimport { ChartDataTable } from \"../_primitives/chart-core/data-table\";\nimport { useDrawIn } from \"../_primitives/chart-core/draw-in\";\nimport { ChartGrid, ChartXAxis, gutterFor } from \"../_primitives/chart-core/grid\";\nimport { ChartLegend, ChartSwatch } from \"../_primitives/chart-core/legend\";\nimport { baselineOf, sampleAt, samplesToArea, samplesToPath, useMorphPath, type Curve, type MorphFrame } from \"../_primitives/chart-core/morph\";\nimport { useChartTransition } from \"../_primitives/chart-core/range\";\nimport { linear, niceScale, thinLabels, type Domain } from \"../_primitives/chart-core/scales\";\nimport { ChartTooltip, Crosshair, useActiveStore, useChartScrub } from \"../_primitives/chart-core/scrub\";\nimport { ChartTokens, chartColor } from \"../_primitives/chart-core/tokens\";\nimport { useChartSize } from \"../_primitives/chart-core/use-chart-size\";\n\nexport type AreaDatum = Record<string, string | number>;\n\nexport type AreaSeries<D extends AreaDatum = AreaDatum> = {\n  /** The datum field holding this series' values. */\n  key: keyof D & string;\n  /** Names the series in the tooltip, the legend and the hidden table. */\n  label: string;\n};\n\nexport type AreaChartProps<D extends AreaDatum> = {\n  /** Points, oldest first, evenly spaced (days, weeks…). */\n  data: readonly D[];\n  /** The field naming each point, e.g. an ISO date. Labels with the same key slide when the data changes. */\n  xKey: keyof D & string;\n  /** The filled series. */\n  series: AreaSeries<D>;\n  /** A second series to read against, such as the previous period: a dashed line at 40%, no fill. */\n  compare?: AreaSeries<D>;\n  /** The figure's name, e.g. \"Signups per week, last 13 weeks\". */\n  label: string;\n  /** Default `var(--eased-chart-1)`. The comparison line uses the same colour. */\n  color?: string;\n  /** Default \"monotone\". */\n  curve?: Curve;\n  /** Total height in px, legend excluded, x labels included. Default 240. */\n  height?: number;\n  /** Formats values (tooltip, screen readers). */\n  format?: (value: number) => string;\n  /** Compact gridline labels. Defaults to a compact `format`. */\n  formatTick?: (value: number) => string;\n  /** Formats a key for the x axis. */\n  formatX?: (key: string) => string;\n  /** Formats a key for the tooltip and screen readers. Defaults to `formatX`. */\n  formatXDetail?: (key: string) => string;\n  /** The scrubbed point, or `null` when scrubbing stops: drive a headline outside the chart. */\n  onActiveChange?: (index: number | null, datum: D | null) => void;\n  className?: string;\n};\n\nconst TOP = 10;\nconst AXIS = 24;\nconst compact = new Intl.NumberFormat(undefined, { notation: \"compact\", maximumFractionDigits: 1 });\nconst defaultFormat = (v: number) => new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 }).format(v);\nconst identity = (k: string) => k;\n\n/**\n * One filled series, with an optional dashed line to compare it against. The area draws in\n * left to right once; new data morphs from the shape on screen, resampled on shared x\n * positions, while the value scale springs and gridlines slide. Scrubbing glides a crosshair\n * and dot on the snappy spring and the tooltip trails a beat behind. Paths are written\n * straight to the DOM, so nothing re-renders per frame.\n */\nexport function AreaChart<D extends AreaDatum>({\n  data,\n  xKey,\n  series,\n  compare,\n  label,\n  color = chartColor(1),\n  curve = \"monotone\",\n  height = 240,\n  format = defaultFormat,\n  formatTick,\n  formatX = identity,\n  formatXDetail,\n  onActiveChange,\n  className = \"\",\n}: AreaChartProps<D>) {\n  const reduced = useSafeReducedMotion();\n  const transition = useChartTransition();\n  const detail = formatXDetail ?? formatX;\n  const [box, { width }] = useChartSize();\n  const id = useId().replace(/:/g, \"\");\n  const n = data.length;\n  const keys = useMemo(() => data.map((d) => String(d[xKey])), [data, xKey]);\n  const plotH = Math.max(0, height - AXIS);\n  const valueOf = (d: D | undefined, key: string) => Number(d?.[key]) || 0;\n\n  const seriesKey = series.key;\n  const compareKey = compare?.key;\n  const values = useMemo(() => data.map((d) => Number(d[seriesKey]) || 0), [data, seriesKey]);\n  const previous = useMemo(() => (compareKey ? data.map((d) => Number(d[compareKey]) || 0) : null), [data, compareKey]);\n  const scale = useMemo(() => niceScale(previous ? [...values, ...previous] : values, { zero: true, ticks: 4 }), [values, previous]);\n  // An empty chart has no series: what was there flattens into the baseline and goes.\n  const shape = useMemo(() => {\n    const s: Record<string, readonly number[]> = {};\n    if (n > 0) s.value = values;\n    if (n > 0 && previous) s.compare = previous;\n    return { domain: scale.domain, series: s };\n  }, [n, values, previous, scale.domain]);\n\n  const tickFormat = formatTick ?? ((v: number) => (Math.abs(v) >= 1000 ? compact.format(v) : format(v)));\n  const gutter = gutterFor(scale.ticks.map(tickFormat));\n  const plotW = Math.max(0, width - gutter);\n  const xOf = useCallback((i: number) => (n > 1 ? (i / (n - 1)) * plotW : plotW), [n, plotW]);\n  const indexAt = useCallback((px: number) => (n > 1 ? Math.round((px / Math.max(1, plotW)) * (n - 1)) : 0), [n, plotW]);\n\n  // The live domain feeds the gridlines; the morph feeds paths, domain and the active dot.\n  const store = useActiveStore();\n  const domain = useMotionValue<Domain>(scale.domain);\n  const dotY = useMotionValue(0);\n  const area = useRef<SVGPathElement>(null);\n  const line = useRef<SVGPathElement>(null);\n  const dashed = useRef<SVGPathElement>(null);\n  const frameRef = useRef<MorphFrame | null>(null);\n  const draw = useCallback(\n    (f: MorphFrame) => {\n      frameRef.current = f;\n      domain.set(f.domain);\n      const x = (u: number) => u * plotW;\n      const y = linear(f.domain, [plotH, TOP]);\n      const { value, compare: prev } = f.series;\n      line.current?.setAttribute(\"d\", value ? samplesToPath(value, x, y) : \"\");\n      area.current?.setAttribute(\"d\", value ? samplesToArea(value, x, y, y(baselineOf(f.domain))) : \"\");\n      dashed.current?.setAttribute(\"d\", prev ? samplesToPath(prev, x, y) : \"\");\n      // A dot held by the keyboard rides the morph with the line under it.\n      const i = store.get();\n      if (i !== null && value) dotY.jump(y(sampleAt(value, n > 1 ? i / (n - 1) : 1)));\n    },\n    [domain, dotY, store, plotW, plotH, n],\n  );\n  const morph = useMorphPath(shape, {\n    onFrame: draw,\n    curve,\n    transition,\n    animate: !reduced,\n    samples: Math.min(400, Math.max(160, n * 10)),\n  });\n  useLayoutEffect(() => morph.redraw(), [draw, morph]);\n\n  // Draw-in: a left-to-right clip, once.\n  const progress = useDrawIn(box, { reduced });\n  const plotWMV = useMotionValue(plotW);\n  useLayoutEffect(() => plotWMV.set(plotW), [plotW, plotWMV]);\n  const clipWidth = useTransform([progress, plotWMV], ([p, w]: number[]) => p * (w + 8));\n\n  const valueText = (i: number) => {\n    const d = data[i];\n    if (!d) return \"\";\n    const main = `${series.label} ${format(valueOf(d, series.key))}`;\n    return `${detail(keys[i])}: ${compare ? `${main}, ${compare.label} ${format(valueOf(d, compare.key))}` : main}`;\n  };\n  const scrub = useChartScrub({\n    count: n,\n    store,\n    xOf,\n    indexAt,\n    valueText,\n    label,\n    reduced,\n    onActiveChange: (i) => {\n      const f = frameRef.current;\n      if (i !== null && f?.series.value) {\n        const target = linear(f.domain, [plotH, TOP])(sampleAt(f.series.value, n > 1 ? i / (n - 1) : 1));\n        if (reduced || scrub.opacity.get() < 0.01) dotY.jump(target);\n        else animate(dotY, target, snappy);\n      }\n      onActiveChange?.(i, i === null ? null : (data[i] ?? null));\n    },\n  });\n  // Once a comparison has been shown, its legend row stays: removing it flattens the dashed line\n  // on the morph without the plot jumping up mid-flight.\n  const [legendRow, setLegendRow] = useState(compare !== undefined);\n  if (compare && !legendRow) setLegendRow(true);\n  const tooltipX = useCallback((i: number) => gutter + xOf(i), [gutter, xOf]);\n\n  const labelItems = thinLabels(n, plotW, 76).map((i) => ({\n    key: keys[i],\n    text: formatX(keys[i]),\n    x: xOf(i),\n    align: i === n - 1 ? (\"end\" as const) : i === 0 ? (\"start\" as const) : (\"center\" as const),\n  }));\n\n  return (\n    <figure className={`flex min-w-0 flex-col gap-3 ${className}`} aria-label={label}>\n      <ChartTokens />\n      {legendRow && (\n        <ChartLegend\n          shape=\"line\"\n          items={[{ key: series.key, label: series.label, color }, ...(compare ? [{ key: compare.key, label: compare.label, color, dashed: true }] : [])]}\n        />\n      )}\n      <div ref={box} className=\"relative w-full min-w-0\" style={{ height }}>\n        {width > 0 && (\n          <>\n            <svg aria-hidden width={width} height={plotH} className=\"absolute inset-x-0 top-0 overflow-visible\">\n              <defs>\n                <clipPath id={`${id}-clip`}>\n                  <motion.rect x={-4} y={-8} height={plotH + 16} width={clipWidth} />\n                </clipPath>\n                <linearGradient id={`${id}-wash`} x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n                  <stop offset=\"0\" stopColor={color} stopOpacity={0.24} />\n                  <stop offset=\"1\" stopColor={color} stopOpacity={0} />\n                </linearGradient>\n              </defs>\n              <ChartGrid ticks={scale.ticks} domain={domain} width={width} gutter={gutter} range={[TOP, plotH]} format={tickFormat} reduced={reduced} />\n              <g transform={`translate(${gutter} 0)`}>\n                <g clipPath={`url(#${id}-clip)`}>\n                  <path ref={area} fill={`url(#${id}-wash)`} />\n                  <path ref={dashed} fill=\"none\" stroke={color} strokeWidth={2} strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeDasharray=\"4 4\" strokeOpacity={0.4} />\n                  <path ref={line} fill=\"none\" stroke={color} strokeWidth={2} strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n                </g>\n                <Crosshair x={scrub.x} opacity={scrub.opacity} top={TOP - 4} height={plotH} />\n                <motion.circle\n                  r={4}\n                  cx={0}\n                  cy={0}\n                  fill={color}\n                  stroke=\"var(--chart-surface, var(--eased-surface))\"\n                  strokeWidth={2}\n                  paintOrder=\"stroke\"\n                  style={{ x: scrub.x, y: dotY, opacity: scrub.opacity }}\n                />\n              </g>\n            </svg>\n            <ChartXAxis className=\"absolute right-0 bottom-0\" left={gutter} items={labelItems} transition={transition} reduced={reduced} />\n            <div {...scrub.props} className=\"absolute top-0 right-0 cursor-crosshair rounded-control-xs focus-ring\" style={{ ...scrub.props.style, left: gutter, height: plotH }} />\n            <ChartTooltip store={store} xOf={tooltipX} width={width} top={TOP} reduced={reduced} className=\"min-w-36\">\n              {(i) => {\n                const d = data[i];\n                if (!d) return null;\n                return (\n                  <div className=\"flex flex-col gap-1.5\">\n                    <p className=\"text-text-muted\">{detail(keys[i])}</p>\n                    <div className=\"grid grid-cols-[auto_1fr_auto] items-center gap-x-2 gap-y-1\">\n                      <TooltipRow color={color} label={series.label} value={format(valueOf(d, series.key))} />\n                      {compare && <TooltipRow color={color} dashed label={compare.label} value={format(valueOf(d, compare.key))} />}\n                    </div>\n                  </div>\n                );\n              }}\n            </ChartTooltip>\n          </>\n        )}\n      </div>\n      <ChartDataTable\n        caption={label}\n        data={data}\n        rowLabel={(_, i) => detail(keys[i])}\n        columns={[series, ...(compare ? [compare] : [])].map((s) => ({ label: s.label, value: (d: D) => format(valueOf(d, s.key)) }))}\n      />\n    </figure>\n  );\n}\n\nfunction TooltipRow({ color, dashed = false, label, value }: { color: string; dashed?: boolean; label: string; value: string }) {\n  return (\n    <>\n      <ChartSwatch color={color} shape=\"line\" dashed={dashed} />\n      <span className=\"truncate text-text-secondary\">{label}</span>\n      <span className=\"text-right font-medium tabular-nums\">{value}</span>\n    </>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/area-chart/area-chart.tsx"
    }
  ],
  "meta": {
    "details": "An area chart that draws in once and morphs to new data from the shape on screen while its scale springs. A crosshair glides between points, a tooltip trails it, and an optional dashed line compares a previous period.",
    "industries": [
      "saas",
      "finance",
      "ecommerce",
      "health",
      "devtools"
    ],
    "releaseAt": "2026-10-23"
  },
  "docs": "Import AreaChart from components/eased/area-chart/area-chart. Props: data, xKey, series ({ key, label }), compare, label, color, curve, height, format, formatTick, formatX, formatXDetail, onActiveChange. The Eased tokens were merged into your global CSS; nothing to import. Dark mode follows .dark or data-theme=\"dark\".",
  "type": "registry:ui"
}