{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chart-line",
  "title": "Chart Line",
  "description": "Multi-series line and area chart from plain row data, with draw-in lines, halftone fills, a snapping crosshair tooltip and a toggleable legend.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://trytweenly.vercel.app/r/chart-kit.json",
    "utils"
  ],
  "files": [
    {
      "path": "registry/new-york/chart-line/chart-line.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useId, useMemo, useState } from \"react\"\nimport { animate as animateValue, motion, useMotionValue, useMotionValueEvent } from \"motion/react\"\nimport {\n  ChartCard,\n  ChartLegend,\n  ChartPattern,\n  ChartTooltip,\n  chartTheme,\n  createFormatter,\n  linePath,\n  niceTicks,\n  resolveSeries,\n  toNumber,\n  useChartReveal,\n  type ChartBaseProps,\n  type ChartCurve,\n  type ChartDatum,\n  type ChartSeries,\n  type Point,\n  type ValueFormat,\n} from \"@/registry/new-york/lib/chart-kit\"\n\nexport interface ChartLineProps extends ChartBaseProps {\n  /** Rows of data, e.g. `{ month: \"Jan\", revenue: 18600 }`. */\n  data: ChartDatum[]\n  /** Key in each row used for the x-axis labels. */\n  index: string\n  /** Numeric keys to plot, one line each. */\n  series: ChartSeries[]\n  /** Line interpolation. Default: \"smooth\" */\n  curve?: ChartCurve\n  /** Area under each line. \"gradient\" is kept for API compatibility and renders a flat translucent fill. Default: \"dots\" */\n  fill?: \"none\" | \"gradient\" | \"dots\" | \"hatch\"\n  /** Line thickness in px. Default: 2.5 */\n  strokeWidth?: number\n  /** Horizontal grid lines at each y tick. Default: true */\n  showGrid?: boolean\n  /** Index labels under the plot. Default: true */\n  showXAxis?: boolean\n  /** Value labels left of the plot. Default: true */\n  showYAxis?: boolean\n  /** Approximate number of y-axis ticks. Default: 4 */\n  yTicks?: number\n  /** Lower bound of the y domain. Default: 0, or a nice minimum for negative data */\n  yMin?: number\n  /** Upper bound of the y domain. Default: a nice maximum above the data */\n  yMax?: number\n  /** Which data points get a marker. \"last\" pulses at the end of the first series. Default: \"last\" */\n  showPoints?: \"none\" | \"hover\" | \"all\" | \"last\"\n  /** Crosshair and tooltip on hover / arrow keys. Default: true */\n  showTooltip?: boolean\n  /** Toggleable legend. Default: true when there is more than one series */\n  showLegend?: boolean\n  /** Plot height in px, excluding the x-axis. Default: 220 */\n  height?: number\n  /** Show every nth x label. Default: auto, based on the available width */\n  xLabelEvery?: number\n  /** Headline figure from the first series. Default: \"last\" */\n  total?: \"sum\" | \"last\" | \"none\"\n  /** Change pill next to the headline, in percent. \"auto\" compares the first and last value of the first series. Default: \"auto\" */\n  delta?: number | \"auto\"\n}\n\nconst EASE = [0.65, 0, 0.35, 1] as const\nconst r2 = (v: number) => Math.round(v * 100) / 100\n\nexport function ChartLine({\n  data,\n  index,\n  series,\n  curve = \"smooth\",\n  fill = \"dots\",\n  strokeWidth = 2.5,\n  showGrid = true,\n  showXAxis = true,\n  showYAxis = true,\n  yTicks = 4,\n  yMin,\n  yMax,\n  showPoints = \"last\",\n  showTooltip = true,\n  showLegend,\n  height = 220,\n  xLabelEvery,\n  total = \"last\",\n  delta = \"auto\",\n  title,\n  description,\n  titleSize,\n  surface = \"dark\",\n  accent,\n  palette,\n  icon,\n  radius,\n  bare,\n  animate: animated = true,\n  duration = 1.2,\n  delay = 0,\n  once = true,\n  valueFormat = \"number\",\n  currency = \"USD\",\n  decimals,\n  className,\n}: ChartLineProps) {\n  const theme = chartTheme(surface, accent)\n  const resolved = resolveSeries(series, theme, palette)\n  const uid = useId().replace(/[^a-zA-Z0-9_-]/g, \"\")\n  const { ref, shown, still } = useChartReveal(once, animated)\n  const width = useWidth(ref)\n  const [hidden, setHidden] = useState<Set<string>>(() => new Set())\n  const [active, setActive] = useState<number | null>(null)\n\n  const fmt = useMemo(() => createFormatter(valueFormat, { currency, decimals }), [valueFormat, currency, decimals])\n  const axisFmt = useMemo(() => axisFormatter(valueFormat, currency, decimals), [valueFormat, currency, decimals])\n\n  const n = data.length\n  const visible = resolved.filter((s) => !hidden.has(s.key))\n  const values = visible.flatMap((s) => data.map((d) => toNumber(d[s.key])))\n  const dataMin = values.length ? Math.min(...values) : 0\n  const dataMax = values.length ? Math.max(...values) : 1\n  const ticks = niceTicks(yMin ?? Math.min(0, dataMin), yMax ?? Math.max(0, dataMax), yTicks)\n  const lo = yMin ?? ticks[0]\n  const hi = Math.max(yMax ?? ticks[ticks.length - 1], lo + 1e-9)\n  const tickValues = ticks.filter((t) => t >= lo - 1e-9 && t <= hi + 1e-9)\n\n  const padT = 10\n  const padR = 12\n  const padL = showYAxis ? Math.max(...tickValues.map((t) => axisFmt(t).length), 1) * 6.4 + 14 : 6\n  const svgH = height + (showXAxis ? 26 : 0)\n  const plotW = Math.max(width - padL - padR, 1)\n  const xAt = (i: number) => r2(n <= 1 ? padL + plotW / 2 : padL + (i / (n - 1)) * plotW)\n  const yAt = (v: number) => r2(padT + (1 - (v - lo) / (hi - lo)) * (height - padT))\n  const baseY = yAt(Math.min(Math.max(0, lo), hi))\n\n  const paths = resolved.map((s, si) => {\n    const off = hidden.has(s.key)\n    const pts: Point[] = data.map((d, i) => [xAt(i), off ? baseY : yAt(toNumber(d[s.key]))])\n    const line = linePath(pts, curve)\n    const area = pts.length ? `${line}L${pts[pts.length - 1][0]},${baseY}L${pts[0][0]},${baseY}Z` : \"\"\n    return { s, si, off, pts, line, area }\n  })\n\n  const labelW = Math.max(...data.map((d) => String(d[index] ?? \"\").length), 1) * 6.6 + 14\n  const every = xLabelEvery ?? Math.max(1, Math.ceil((n * labelW) / Math.max(plotW, 1)))\n\n  const firstVals = series[0] ? data.map((d) => toNumber(d[series[0].key])) : []\n  const first = firstVals[0] ?? 0\n  const last = firstVals[firstVals.length - 1] ?? 0\n  const headline =\n    total === \"none\" || !firstVals.length ? undefined : total === \"sum\" ? firstVals.reduce((a, b) => a + b, 0) : last\n  const deltaValue =\n    delta === \"auto\" ? (firstVals.length > 1 && first !== 0 ? ((last - first) / Math.abs(first)) * 100 : undefined) : delta\n\n  const legend = showLegend ?? series.length > 1\n  const tracking = n > 0 && (showTooltip || showPoints === \"hover\")\n  const activeIdx = active !== null && active < n ? active : null\n  const marker = paths.find((p) => !p.off)\n\n  const toggle = (key: string) =>\n    setHidden((prev) => {\n      const next = new Set(prev)\n      if (next.has(key)) next.delete(key)\n      else next.add(key)\n      return next\n    })\n\n  const onPointerMove = (e: React.PointerEvent<SVGSVGElement>) => {\n    if (!tracking) return\n    const x = e.clientX - e.currentTarget.getBoundingClientRect().left\n    setActive(n <= 1 ? 0 : Math.min(n - 1, Math.max(0, Math.round(((x - padL) / plotW) * (n - 1)))))\n  }\n\n  const onKeyDown = (e: React.KeyboardEvent) => {\n    if (!tracking) return\n    const moves: Record<string, (a: number | null) => number | null> = {\n      ArrowRight: (a) => (a === null ? 0 : Math.min(n - 1, a + 1)),\n      ArrowLeft: (a) => (a === null ? n - 1 : Math.max(0, a - 1)),\n      Home: () => 0,\n      End: () => n - 1,\n      Escape: () => null,\n    }\n    const move = moves[e.key]\n    if (!move) return\n    e.preventDefault()\n    setActive(move)\n  }\n\n  const t = (d: number, extra: object = {}) => (still ? { duration: 0 } : { duration: d, ease: EASE, ...extra })\n  const morph = still ? { duration: 0 } : { type: \"spring\" as const, duration: 0.7, bounce: 0.1 }\n  const drawEnd = delay + duration + Math.max(visible.length - 1, 0) * 0.15\n\n  const activeYs = activeIdx === null ? [] : paths.filter((p) => !p.off).map((p) => p.pts[activeIdx][1])\n  const tooltipY = Math.min(Math.max(activeYs.length ? Math.min(...activeYs) : height / 2, 36), height - 36)\n\n  return (\n    <ChartCard\n      theme={theme}\n      title={title}\n      description={description}\n      titleSize={titleSize}\n      value={\n        headline === undefined ? undefined : (\n          <Ticker value={headline} format={fmt} shown={shown} still={still} delay={delay} duration={duration} />\n        )\n      }\n      delta={headline === undefined ? undefined : deltaValue}\n      icon={icon === undefined ? <TrendGlyph /> : icon}\n      radius={radius}\n      bare={bare}\n      className={className}\n    >\n      <div\n        ref={ref}\n        tabIndex={tracking ? 0 : undefined}\n        role=\"group\"\n        aria-label={title ? `${title} chart` : \"Line chart\"}\n        onKeyDown={onKeyDown}\n        onBlur={() => setActive(null)}\n        className=\"relative w-full touch-pan-y select-none rounded-md outline-offset-4 focus-visible:outline-2\"\n        style={{ height: svgH, outlineColor: theme.accent }}\n      >\n        {width > 0 && n > 0 && (\n          <svg\n            width={width}\n            height={svgH}\n            className=\"block overflow-visible\"\n            onPointerMove={onPointerMove}\n            onPointerLeave={() => setActive(null)}\n          >\n            <defs>\n              <clipPath id={`${uid}-reveal`}>\n                <motion.rect\n                  x={0}\n                  y={-20}\n                  height={svgH + 40}\n                  initial={still ? false : { width: 0 }}\n                  animate={{ width: shown ? width + 20 : 0 }}\n                  transition={t(duration * 1.1, { delay })}\n                />\n              </clipPath>\n              {resolved.map((s, si) => (\n                <SeriesFill key={s.key} id={`${uid}-f${si}`} fill={fill} color={s.color} theme={theme} top={padT} bottom={baseY} />\n              ))}\n            </defs>\n\n            {showGrid &&\n              tickValues.map((v) => (\n                <motion.line\n                  key={v}\n                  x1={padL}\n                  x2={width - padR + 4}\n                  stroke={v === 0 && lo < 0 ? theme.subtle : theme.grid}\n                  strokeDasharray={v === 0 && lo < 0 ? undefined : \"3 4\"}\n                  initial={still ? false : { opacity: 0, y1: yAt(v), y2: yAt(v) }}\n                  animate={{ opacity: shown ? 1 : 0, y1: yAt(v), y2: yAt(v) }}\n                  transition={{ ...morph, opacity: t(0.5, { delay }) }}\n                />\n              ))}\n\n            {showYAxis &&\n              tickValues.map((v) => (\n                <motion.text\n                  key={v}\n                  x={padL - 10}\n                  textAnchor=\"end\"\n                  dominantBaseline=\"middle\"\n                  fontSize={11}\n                  fill={theme.muted}\n                  className=\"tabular-nums\"\n                  initial={still ? false : { opacity: 0, y: yAt(v) }}\n                  animate={{ opacity: shown ? 1 : 0, y: yAt(v) }}\n                  transition={{ ...morph, opacity: t(0.5, { delay: delay + 0.1 }) }}\n                >\n                  {axisFmt(v)}\n                </motion.text>\n              ))}\n\n            {showXAxis &&\n              data.map((d, i) =>\n                i % every === 0 ? (\n                  <motion.text\n                    key={i}\n                    x={xAt(i)}\n                    y={height + 19}\n                    textAnchor=\"middle\"\n                    fontSize={11}\n                    fill={activeIdx === i ? theme.fg : theme.muted}\n                    initial={still ? false : { opacity: 0 }}\n                    animate={{ opacity: shown ? 1 : 0 }}\n                    transition={t(0.4, { delay: delay + (i / Math.max(n - 1, 1)) * duration * 0.6 })}\n                  >\n                    {String(d[index] ?? \"\")}\n                  </motion.text>\n                ) : null\n              )}\n\n            {fill !== \"none\" && (\n              <g clipPath={`url(#${uid}-reveal)`} opacity={fill === \"gradient\" ? 1 : 0.55}>\n                {paths.map((p) => (\n                  <motion.path\n                    key={p.s.key}\n                    fill={`url(#${uid}-f${p.si})`}\n                    initial={still ? false : { d: p.area, opacity: 0 }}\n                    animate={{ d: p.area, opacity: p.off ? 0 : p.si === 0 ? 1 : 0.55 }}\n                    transition={{ d: morph, opacity: t(0.4, { delay: shown && p.off ? 0 : delay }) }}\n                  />\n                ))}\n              </g>\n            )}\n\n            {paths.map((p) => (\n              <motion.path\n                key={p.s.key}\n                fill=\"none\"\n                stroke={p.s.color}\n                strokeWidth={strokeWidth}\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n                initial={still ? false : { d: p.line, pathLength: 0, opacity: 0 }}\n                animate={{ d: p.line, pathLength: shown ? 1 : 0, opacity: shown && !p.off ? 1 : 0 }}\n                transition={{\n                  d: morph,\n                  pathLength: t(duration, { delay: delay + p.si * 0.15 }),\n                  opacity: t(0.25, { delay: shown && !p.off ? delay + p.si * 0.15 : 0 }),\n                }}\n              />\n            ))}\n\n            {showPoints === \"all\" &&\n              paths.map((p) =>\n                p.pts.map(([x, y], i) => (\n                  <motion.circle\n                    key={`${p.s.key}-${i}`}\n                    r={3.5}\n                    fill={theme.bg}\n                    stroke={p.s.color}\n                    strokeWidth={2}\n                    style={{ transformBox: \"fill-box\", transformOrigin: \"center\" }}\n                    initial={still ? false : { cx: x, cy: y, scale: 0 }}\n                    animate={{ cx: x, cy: y, scale: shown && !p.off ? 1 : 0 }}\n                    transition={{\n                      cx: morph,\n                      cy: morph,\n                      scale: still\n                        ? { duration: 0 }\n                        : { type: \"spring\", stiffness: 500, damping: 18, delay: shown && !p.off ? delay + p.si * 0.15 + (i / Math.max(n - 1, 1)) * duration : 0 },\n                    }}\n                  />\n                ))\n              )}\n\n            {tracking && (\n              <motion.g\n                pointerEvents=\"none\"\n                initial={false}\n                animate={{ opacity: activeIdx === null ? 0 : 1, x: activeIdx === null ? 0 : xAt(activeIdx) }}\n                transition={{ opacity: { duration: 0.15 }, x: still ? { duration: 0 } : { type: \"spring\", stiffness: 600, damping: 45 } }}\n              >\n                <line x1={0} x2={0} y1={padT - 4} y2={height} stroke={theme.fg} strokeOpacity={0.25} strokeDasharray=\"3 3\" />\n              </motion.g>\n            )}\n\n            {showPoints === \"last\" && marker && (\n              <motion.g\n                pointerEvents=\"none\"\n                initial={still ? false : { x: marker.pts[n - 1][0], y: marker.pts[n - 1][1], scale: 0 }}\n                animate={{ x: marker.pts[n - 1][0], y: marker.pts[n - 1][1], scale: shown ? 1 : 0 }}\n                transition={{\n                  x: morph,\n                  y: morph,\n                  scale: still ? { duration: 0 } : { type: \"spring\", stiffness: 420, damping: 16, delay: shown ? delay + duration * 0.95 : 0 },\n                }}\n              >\n                {!still && (\n                  <motion.circle\n                    r={7}\n                    fill={marker.s.color}\n                    animate={{ scale: [1, 2.4], opacity: [0.35, 0] }}\n                    transition={{ duration: 1.8, repeat: Infinity, ease: \"easeOut\", delay: drawEnd }}\n                    style={{ opacity: 0 }}\n                  />\n                )}\n                <circle r={7.5} fill={theme.bg} />\n                <circle r={4.5} fill={marker.s.color} />\n              </motion.g>\n            )}\n\n            {tracking &&\n              paths.map((p) => {\n                const on = activeIdx !== null && !p.off\n                const [x, y] = activeIdx === null ? [0, 0] : p.pts[activeIdx]\n                return (\n                  <motion.circle\n                    key={p.s.key}\n                    r={5}\n                    fill={p.s.color}\n                    stroke={theme.bg}\n                    strokeWidth={2.5}\n                    pointerEvents=\"none\"\n                    style={{ transformBox: \"fill-box\", transformOrigin: \"center\" }}\n                    initial={false}\n                    animate={on ? { cx: x, cy: y, scale: 1, opacity: 1 } : { scale: 0.4, opacity: 0 }}\n                    transition={still ? { duration: 0 } : { type: \"spring\", stiffness: 600, damping: 45, opacity: { duration: 0.12 } }}\n                  />\n                )\n              })}\n          </svg>\n        )}\n\n        <ChartTooltip\n          open={showTooltip && activeIdx !== null && visible.length > 0}\n          x={activeIdx === null ? 0 : xAt(activeIdx)}\n          y={tooltipY}\n          width={width}\n          title={activeIdx === null ? undefined : String(data[activeIdx]?.[index] ?? \"\")}\n          rows={\n            activeIdx === null\n              ? []\n              : visible.map((s) => ({ label: s.label, value: fmt(toNumber(data[activeIdx]?.[s.key])), color: s.color }))\n          }\n          theme={theme}\n        />\n      </div>\n\n      {legend && (\n        <div className=\"mt-4\">\n          <ChartLegend series={resolved.map((s) => ({ ...s, texture: \"solid\" }))} theme={theme} hidden={hidden} onToggle={toggle} />\n        </div>\n      )}\n    </ChartCard>\n  )\n}\n\nfunction SeriesFill({ id, fill, color, theme, top, bottom }: {\n  id: string\n  fill: NonNullable<ChartLineProps[\"fill\"]>\n  color: string\n  theme: ReturnType<typeof chartTheme>\n  top: number\n  bottom: number\n}) {\n  if (fill === \"gradient\") {\n    // Flat translucent area fill (legacy \"gradient\" value, no color blending)\n    return (\n      <pattern id={id} x={0} y={top} width={1e5} height={Math.max(1, bottom - top) + 1e5} patternUnits=\"userSpaceOnUse\">\n        <rect width={1e5} height={Math.max(1, bottom - top) + 1e5} fill={color} fillOpacity={0.16} />\n      </pattern>\n    )\n  }\n  if (fill === \"hatch\") return <ChartPattern id={id} color={color} texture=\"hatch\" theme={theme} />\n  return (\n    <pattern id={id} width=\"5\" height=\"5\" patternUnits=\"userSpaceOnUse\">\n      <circle cx=\"1.25\" cy=\"1.25\" r=\"1.1\" fill={color} />\n      <circle cx=\"3.75\" cy=\"3.75\" r=\"1.1\" fill={color} />\n    </pattern>\n  )\n}\n\nfunction useWidth(ref: React.RefObject<HTMLElement | null>) {\n  const [width, setWidth] = useState(0)\n  useEffect(() => {\n    const el = ref.current\n    if (!el) return\n    const ro = new ResizeObserver(([entry]) => setWidth(Math.round(entry.contentRect.width)))\n    ro.observe(el)\n    return () => ro.disconnect()\n  }, [ref])\n  return width\n}\n\nfunction axisFormatter(format: ValueFormat, currency: string, decimals?: number) {\n  if (typeof format === \"function\") return format\n  if (format === \"currency\") {\n    const intl = new Intl.NumberFormat(\"en-US\", { style: \"currency\", currency, notation: \"compact\", maximumFractionDigits: 1 })\n    return (v: number) => intl.format(v)\n  }\n  return createFormatter(format === \"percent\" ? \"percent\" : \"compact\", { decimals })\n}\n\nfunction Ticker({ value, format, shown, still, delay, duration }: {\n  value: number\n  format: (v: number) => string\n  shown: boolean\n  still: boolean\n  delay: number\n  duration: number\n}) {\n  const mv = useMotionValue(still ? value : 0)\n  const [display, setDisplay] = useState(still ? value : 0)\n  useMotionValueEvent(mv, \"change\", setDisplay)\n  useEffect(() => {\n    if (!shown) return\n    if (still) {\n      mv.set(value)\n      return\n    }\n    const controls = animateValue(mv, value, { duration, delay, ease: [0.16, 1, 0.3, 1] })\n    return () => controls.stop()\n  }, [mv, value, shown, still, delay, duration])\n  return <span>{format(display)}</span>\n}\n\nfunction TrendGlyph() {\n  return (\n    <svg viewBox=\"0 0 16 16\" className=\"size-3.5\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.8} strokeLinecap=\"round\" strokeLinejoin=\"round\">\n      <path d=\"M2 11.5 6 7.5l3 3 5-6\" />\n    </svg>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "docs": "Docs, live playground and every prop: https://trytweenly.vercel.app/docs/components/chart-line",
  "type": "registry:component"
}