{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "line-chart",
  "type": "registry:ui",
  "title": "Line chart",
  "description": "A multi-series line chart with a gliding crosshair, legend toggles, and paths that morph between ranges.",
  "categories": [
    "data"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/line-chart/line-chart.tsx",
      "type": "registry:component",
      "target": "@components/xzero/line-chart/line-chart.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties, type KeyboardEvent, type PointerEvent, type Ref } from \"react\";\nimport { AnimatePresence, animate, motion, useInView, useMotionValue, useMotionValueEvent, useReducedMotion, useSpring, useTransform, type MotionValue } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./line-chart.module.css\";\n\nexport interface LineChartSeries {\n  /** Stable identity. A series keeps its line across data changes, so switching ranges morphs the path instead of redrawing it. */\n  key: string;\n  /** Name in the legend, the tooltip, and the data table. */\n  label: string;\n  /** Any CSS color. Defaults to the accent for the first series, then quieter neutral steps. */\n  color?: string;\n  /** Draws a dashed stroke, useful for a previous period or a target. */\n  dashed?: boolean;\n  /** Fills the area under the line with a faint tint of its color. Defaults to true for the first series. */\n  area?: boolean;\n}\n\nexport interface LineChartDatum {\n  /** Stable identity, such as an ISO date. */\n  key: string;\n  /** Full label for the tooltip and screen readers, such as \"Tue, Sep 15\". */\n  label: string;\n  /** Short label on the x axis, such as \"Sep 15\". Leave it out to keep the axis quiet at that point. */\n  axisLabel?: string;\n  /** One value per series key. A missing value counts as zero. */\n  values: Record<string, number | undefined>;\n}\n\n/** Use a line chart for one or more measures over time, when the shape of the trend matters more than any single value. */\nexport interface LineChartProps {\n  data: LineChartDatum[];\n  series: LineChartSeries[];\n  /** What is measured, such as \"Signups\". Names the chart for assistive technology. */\n  label: string;\n  /** Unit after each value in the tooltip, such as \"ms\". */\n  unit?: string;\n  /** Plot height in pixels. The width follows the container. */\n  height?: number;\n  /** Formats values in the tooltip and data table. */\n  formatValue?: (value: number, series: LineChartSeries) => string;\n  /** Formats the value axis. Defaults to a compact number, such as 1.2K. */\n  formatTick?: (value: number) => string;\n  /** Controlled hidden series keys. */\n  hiddenSeries?: string[];\n  defaultHiddenSeries?: string[];\n  onHiddenSeriesChange?: (hidden: string[]) => void;\n  /** Called as the crosshair moves, and with null when it leaves. Use it to drive a headline readout. */\n  onActiveChange?: (index: number | null, datum: LineChartDatum | null) => void;\n  /** Keeps the current lines on screen, dimmed, while the next range loads. */\n  loading?: boolean;\n  /** Shown when there are no points. */\n  emptyLabel?: string;\n  /** Series toggles above the plot. Shown by default when there is more than one series. */\n  legend?: boolean;\n  /** Header of the first column in the data table read by screen readers. */\n  categoryLabel?: string;\n  /** Smooth monotone curves that never swing past the data, or straight segments. */\n  curve?: \"smooth\" | \"linear\";\n  ref?: Ref<HTMLElement>;\n  className?: string;\n}\n\ntype Point = [x: number, value: number];\ntype Track = { shape: Point[]; presence: number; fade: { stop: () => void } | null };\n\nconst { spring, duration, ease } = motionTokens;\n/** Motion drops inherited velocity on time-defined springs, so values that retarget mid-flight run the same springs written as stiffness and damping. */\nconst physical = ({ visualDuration, bounce }: { visualDuration: number; bounce: number }, restDelta = .001) => { const root = (2 * Math.PI) / (visualDuration * 1.2); return { type: \"spring\" as const, stiffness: root * root, damping: 2 * (1 - bounce) * root, restDelta, restSpeed: restDelta * 2 }; };\nconst settle = physical(spring.smooth);\nconst glide = physical(spring.snappy, .0001);\nconst follow = { stiffness: glide.stiffness, damping: glide.damping, restDelta: .01 };\nconst draw = { duration: duration.considered * 1.75, ease: [...ease.inOut] } as const;\nconst fadeFast = { duration: duration.instant, ease: [...ease.standard] } as const;\n\n/** Room above the top gridline, and the gap between the crosshair and its tooltip. */\nconst TOP = 12, GAP = 14;\nconst PALETTE = [\"var(--accent)\", \"color-mix(in oklab, var(--foreground) 46%, var(--surface))\", \"color-mix(in oklab, var(--foreground) 26%, var(--surface))\"];\nconst grouped = new Intl.NumberFormat(\"en-US\", { maximumFractionDigits: 1 });\nconst compact = new Intl.NumberFormat(\"en-US\", { notation: \"compact\", maximumFractionDigits: 1 });\nconst clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));\nconst colorOf = (series: LineChartSeries, index: number) => series.color ?? PALETTE[Math.min(index, PALETTE.length - 1)];\nconst clean = (value: number) => Number(value.toPrecision(12));\n\n/** Clean gridlines: the smallest step of 1, 2, 2.5, or 5 that covers the data in four rows or fewer, from zero unless the data dips below it. */\nfunction niceScale(low: number, high: number) {\n  const lo = Math.min(0, low), hi = Math.max(high, lo + 1);\n  const span = hi - lo, magnitude = 10 ** Math.floor(Math.log10(span / 4));\n  for (const factor of [1, 2, 2.5, 5, 10]) {\n    const step = factor * magnitude, bottom = Math.floor(lo / step) * step, top = Math.ceil(hi / step) * step;\n    if ((top - bottom) / step <= 4 + 1e-9) {\n      const ticks: number[] = [];\n      for (let value = bottom; value <= top + step / 2; value += step) ticks.push(clean(value));\n      return { min: clean(bottom), max: clean(top), ticks };\n    }\n  }\n  return { min: lo, max: hi, ticks: [lo, hi] };\n}\n\n/** The line through every value, sampled densely on x from 0 to 1 in data units. Monotone cubic tangents keep a smooth curve from swinging past the data. */\nfunction curveFor(values: number[], smooth: boolean): Point[] {\n  if (values.length === 0) return [];\n  if (values.length === 1) return [[0, values[0]!], [1, values[0]!]];\n  const last = values.length - 1, step = 1 / last;\n  if (!smooth) return values.map((value, index) => [index * step, value]);\n  const slopes = values.slice(1).map((value, index) => (value - values[index]!) / step);\n  const tangents = values.map((_, index) => index === 0 || index === last ? 0 : (Math.sign(slopes[index - 1]!) + Math.sign(slopes[index]!)) * Math.min(Math.abs(slopes[index - 1]!), Math.abs(slopes[index]!), Math.abs(slopes[index - 1]! + slopes[index]!) / 4) || 0);\n  if (last > 1) { tangents[0] = (3 * slopes[0]! - tangents[1]!) / 2; tangents[last] = (3 * slopes[last - 1]! - tangents[last - 1]!) / 2; }\n  else { tangents[0] = slopes[0]!; tangents[1] = slopes[0]!; }\n  const samples = Math.max(1, Math.min(16, Math.round(192 / last)));\n  const points: Point[] = [];\n  for (let index = 0; index < last; index++) {\n    const low = Math.min(values[index]!, values[index + 1]!), high = Math.max(values[index]!, values[index + 1]!);\n    for (let sample = 0; sample < samples; sample++) {\n      const t = sample / samples, t2 = t * t, t3 = t2 * t;\n      const value = (2 * t3 - 3 * t2 + 1) * values[index]! + (t3 - 2 * t2 + t) * step * tangents[index]! + (3 * t2 - 2 * t3) * values[index + 1]! + (t3 - t2) * step * tangents[index + 1]!;\n      points.push([(index + t) * step, clamp(value, low, high)]);\n    }\n  }\n  points.push([1, values[last]!]);\n  return points;\n}\n\n/** Value of a sampled line at x, straight between samples. */\nfunction valueAt(shape: Point[], x: number) {\n  if (!shape.length) return 0;\n  let low = 0, high = shape.length - 1;\n  if (x <= shape[low]![0]) return shape[low]![1];\n  if (x >= shape[high]![0]) return shape[high]![1];\n  while (high - low > 1) { const middle = (low + high) >> 1; if (shape[middle]![0] < x) low = middle; else high = middle; }\n  const [x0, y0] = shape[low]!, [x1, y1] = shape[high]!;\n  return x1 === x0 ? y1 : y0 + ((y1 - y0) * (x - x0)) / (x1 - x0);\n}\n\n/** Tracks are mutable animation state read by the painter, never by render. */\nconst update = (track: Track, patch: Partial<Track>) => Object.assign(track, patch);\n\nconst grid = Array.from({ length: 241 }, (_, index) => index / 240);\n\nconst subscribeNothing = () => () => {};\n/** Reduced motion is only known in the browser, so the first client render matches the server before it takes effect. */\nfunction useReducedMotionSafe() {\n  const hydrated = useSyncExternalStore(subscribeNothing, () => true, () => false);\n  return !!useReducedMotion() && hydrated;\n}\n\ntype Scale = { min: MotionValue<number>; max: MotionValue<number> };\nfunction useRowY(value: number, scale: Scale, height: number) {\n  return useTransform(() => { const span = scale.max.get() - scale.min.get() || 1; return Math.round(TOP + (1 - (value - scale.min.get()) / span) * (height - TOP)) + .5; });\n}\n\n/** A gridline rides the scale, so a taller range slides it down while new rows fade in above. */\nfunction Gridline({ value, scale, height }: { value: number; scale: Scale; height: number }) {\n  const y = useRowY(value, scale, height);\n  return <motion.line className={styles.grid} x1={0} x2=\"100%\" y1={y} y2={y} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, transition: fadeFast }} transition={{ duration: duration.standard, ease: [...ease.standard] }} />;\n}\n\nfunction TickLabel({ value, scale, height, format }: { value: number; scale: Scale; height: number; format: (value: number) => string }) {\n  const y = useRowY(value, scale, height);\n  return <motion.span className={styles.tick} style={{ y }} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, transition: fadeFast }} transition={{ duration: duration.standard, ease: [...ease.standard] }}>\n    <span className={styles.tickText}>{format(value)}</span>\n  </motion.span>;\n}\n\n/** Axis labels that fit: at most one per 76 pixels, counted back from the latest so the newest date always shows. */\nfunction axisPicks(data: LineChartDatum[], width: number) {\n  const labeled = data.flatMap((item, index) => item.axisLabel ? [index] : []);\n  if (!width || !labeled.length) return [];\n  const room = Math.max(2, Math.floor(width / 76));\n  const stride = Math.ceil(labeled.length / room);\n  return labeled.reverse().filter((_, rank) => rank % stride === 0).reverse();\n}\n\nexport function LineChart({ data, series, label, unit = \"\", height = 220, formatValue, formatTick = value => compact.format(value), hiddenSeries, defaultHiddenSeries, onHiddenSeriesChange, onActiveChange, loading = false, emptyLabel = \"No data for this range\", legend, categoryLabel = \"Date\", curve = \"smooth\", ref, className }: LineChartProps) {\n  const reduced = useReducedMotionSafe();\n  const figure = useRef<HTMLElement>(null);\n  const plot = useRef<HTMLDivElement>(null);\n  const tip = useRef<HTMLDivElement>(null);\n  const clip = useRef<SVGRectElement>(null);\n  const crosshair = useRef<SVGLineElement>(null);\n  const lines = useRef(new Map<string, SVGPathElement>());\n  const areas = useRef(new Map<string, SVGPathElement>());\n  const dots = useRef(new Map<string, SVGCircleElement>());\n  const groups = useRef(new Map<string, SVGGElement>());\n  useImperativeHandle(ref, () => figure.current as HTMLElement);\n  const inView = useInView(figure, { once: true, amount: .3 });\n  const uid = `line${useId().replace(/[^a-zA-Z0-9_-]/g, \"\")}`;\n  const last = data.length - 1;\n  const empty = data.length === 0;\n\n  // Hidden series, controlled or not.\n  const [ownHidden, setOwnHidden] = useState<string[]>(defaultHiddenSeries ?? []);\n  const hidden = hiddenSeries ?? ownHidden;\n  const toggle = (key: string) => {\n    const next = hidden.includes(key) ? hidden.filter(item => item !== key) : [...hidden, key];\n    if (hiddenSeries === undefined) setOwnHidden(next);\n    onHiddenSeriesChange?.(next);\n  };\n  const visible = series.filter(item => !hidden.includes(item.key));\n  const showLegend = legend ?? series.length > 1;\n\n  // Each series becomes a dense curve in data units; the scale fits only the series on show.\n  const signature = `${curve}|${series.map(item => item.key).join(\",\")}|${data.map(item => `${item.key}:${series.map(line => item.values[line.key] ?? 0).join(\",\")}`).join(\";\")}`;\n  const targets = useMemo(() => new Map(series.map(line => [line.key, curveFor(data.map(item => item.values[line.key] ?? 0), curve === \"smooth\")])), [signature]); // eslint-disable-line react-hooks/exhaustive-deps\n  let low = Infinity, high = -Infinity;\n  for (const line of visible) for (const item of data) { const value = item.values[line.key] ?? 0; low = Math.min(low, value); high = Math.max(high, value); }\n  const range = Number.isFinite(low) ? niceScale(low, high) : null;\n  const [steady, setSteady] = useState(() => range ?? niceScale(0, 100));\n  if (range && (range.min !== steady.min || range.max !== steady.max)) setSteady(range);\n  const scale = { min: useMotionValue(steady.min), max: useMotionValue(steady.max) };\n\n  // Scrubbing: a new range lets go of the crosshair.\n  const [active, setActive] = useState<number | null>(null);\n  const [seenSignature, setSeenSignature] = useState(signature);\n  if (seenSignature !== signature) { setSeenSignature(signature); setActive(null); }\n  const index = active === null || empty ? null : Math.min(active, last);\n  const scrubbing = index !== null;\n\n  const width = useRef(0);\n  const [plotWidth, setPlotWidth] = useState(0);\n  const tipSize = useRef({ w: 0, h: 0 });\n  const tracks = useRef(new Map<string, Track>());\n  const drawn = useMotionValue(0);\n  const cursor = useMotionValue(1);\n  const tipTargetX = useMotionValue(0), tipTargetY = useMotionValue(0);\n  const tipSpringX = useSpring(tipTargetX, follow), tipSpringY = useSpring(tipTargetY, follow);\n  const live = useRef({ series, hidden });\n  useLayoutEffect(() => { live.current = { series, hidden }; });\n\n  // One writer for every drawn attribute: morphs, toggles, the scale, the crosshair, and the measured size all repaint through here.\n  const paint = useCallback(() => {\n    const w = width.current;\n    if (!w) return;\n    const lo = scale.min.get(), span = scale.max.get() - lo || 1;\n    const toY = (value: number) => TOP + (1 - (value - lo) / span) * (height - TOP);\n    const at = cursor.get();\n    const dotYs: number[] = [];\n    for (const line of live.current.series) {\n      const track = tracks.current.get(line.key);\n      if (!track) continue;\n      const p = track.presence;\n      const y = (value: number) => toY(lo + (value - lo) * p);\n      const path = track.shape.length ? `M${track.shape.map(([x, value]) => `${(x * w).toFixed(1)},${y(value).toFixed(1)}`).join(\"L\")}` : \"\";\n      lines.current.get(line.key)?.setAttribute(\"d\", path);\n      areas.current.get(line.key)?.setAttribute(\"d\", path ? `${path}L${w},${height}L0,${height}Z` : \"\");\n      const group = groups.current.get(line.key);\n      if (group) group.style.opacity = String(p);\n      const dot = dots.current.get(line.key);\n      if (dot && track.shape.length) {\n        const cy = y(valueAt(track.shape, at));\n        dot.setAttribute(\"cx\", (at * w).toFixed(1));\n        dot.setAttribute(\"cy\", cy.toFixed(1));\n        if (p > .5) dotYs.push(cy);\n      }\n    }\n    clip.current?.setAttribute(\"width\", String(drawn.get() * (w + 16)));\n    const cx = Math.round(at * w) + .5;\n    crosshair.current?.setAttribute(\"x1\", String(cx));\n    crosshair.current?.setAttribute(\"x2\", String(cx));\n    // The tooltip sits beside the crosshair, flips sides before it would leave the plot, and centres on the points it describes.\n    const { w: tw, h: th } = tipSize.current;\n    let x = cx + GAP;\n    if (x + tw > w) x = cx - GAP - tw;\n    tipTargetX.set(clamp(x, 0, Math.max(0, w - tw)));\n    const middle = dotYs.length ? dotYs.reduce((sum, value) => sum + value, 0) / dotYs.length : height / 2;\n    tipTargetY.set(clamp(middle - th / 2, 0, Math.max(0, height - th)));\n  }, [cursor, drawn, height, scale.max, scale.min, tipTargetX, tipTargetY]);\n  useMotionValueEvent(cursor, \"change\", paint);\n  useMotionValueEvent(drawn, \"change\", paint);\n  useMotionValueEvent(scale.min, \"change\", paint);\n  useMotionValueEvent(scale.max, \"change\", paint);\n  useLayoutEffect(paint);\n\n  // The plot draws in its own pixels: measure the width, follow resizes, and size the tooltip for its flip.\n  useLayoutEffect(() => {\n    const node = plot.current, bubble = tip.current;\n    if (!node) return;\n    const read = () => { width.current = node.clientWidth; setPlotWidth(node.clientWidth); if (bubble) tipSize.current = { w: bubble.offsetWidth, h: bubble.offsetHeight }; paint(); };\n    read();\n    if (typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(read);\n    observer.observe(node);\n    if (bubble) observer.observe(bubble);\n    return () => observer.disconnect();\n  }, [paint]);\n\n  // New data morphs every line from the shape on screen, sampled on shared x positions so a range that gains or loses points still flows.\n  const morph = useRef<{ stop: () => void } | null>(null);\n  useEffect(() => {\n    const moves: { track: Track; xs: number[]; from: number[]; to: number[]; target: Point[] }[] = [];\n    for (const [key, target] of targets) {\n      const track = tracks.current.get(key);\n      if (!track) { tracks.current.set(key, { shape: target, presence: live.current.hidden.includes(key) ? 0 : 1, fade: null }); continue; }\n      if (!track.shape.length || !target.length || reduced) { update(track, { shape: target }); continue; }\n      const xs = [...new Set([...grid, ...target.map(([x]) => x)])].sort((a, b) => a - b);\n      moves.push({ track, xs, from: xs.map(x => valueAt(track.shape, x)), to: xs.map(x => valueAt(target, x)), target });\n    }\n    morph.current?.stop();\n    if (!moves.length) { paint(); return; }\n    morph.current = animate(0, 1, { ...settle, restDelta: .0005, onUpdate: progress => { for (const move of moves) update(move.track, { shape: move.xs.map((x, at): Point => [x, move.from[at]! + (move.to[at]! - move.from[at]!) * progress]) }); paint(); }, onComplete: () => { for (const move of moves) update(move.track, { shape: move.target }); paint(); } });\n    return () => morph.current?.stop();\n  }, [paint, reduced, targets]);\n\n  // A hidden series flattens into the baseline as it fades; the ones that stay rescale around it.\n  const hiddenKey = hidden.join(\"|\");\n  useEffect(() => {\n    for (const [key, track] of tracks.current) {\n      const to = hidden.includes(key) ? 0 : 1;\n      if (track.presence === to) continue;\n      track.fade?.stop();\n      if (reduced) { update(track, { presence: to }); continue; }\n      update(track, { fade: animate(track.presence, to, { ...settle, onUpdate: value => { update(track, { presence: value }); paint(); } }) });\n    }\n    paint();\n  }, [hiddenKey, paint, reduced]); // eslint-disable-line react-hooks/exhaustive-deps\n\n  useEffect(() => {\n    if (reduced) { scale.min.jump(steady.min); scale.max.jump(steady.max); return; }\n    const controls = [animate(scale.min, steady.min, settle), animate(scale.max, steady.max, settle)];\n    return () => controls.forEach(control => control.stop());\n  }, [reduced, scale.max, scale.min, steady]);\n\n  // The lines draw left to right the first time the chart is seen with data.\n  useEffect(() => {\n    if (empty) return;\n    if (reduced) { drawn.jump(1); return; }\n    if (!inView || drawn.get() >= 1) return;\n    const controls = animate(drawn, 1, draw);\n    return () => controls.stop();\n  }, [drawn, empty, inView, reduced]);\n\n  // The crosshair appears on the first scrubbed point and glides between the ones after it; the tooltip follows on its own spring.\n  const wasScrubbing = useRef(false);\n  useEffect(() => {\n    if (index === null) { wasScrubbing.current = false; return; }\n    const to = last > 0 ? index / last : .5;\n    if (reduced || !wasScrubbing.current) { cursor.jump(to); tipSpringX.jump(tipTargetX.get()); tipSpringY.jump(tipTargetY.get()); }\n    wasScrubbing.current = true;\n    if (reduced) return;\n    const controls = animate(cursor, to, glide);\n    return () => controls.stop();\n  }, [cursor, index, last, reduced, tipSpringX, tipSpringY, tipTargetX, tipTargetY]);\n\n  const onActive = useRef(onActiveChange);\n  useLayoutEffect(() => { onActive.current = onActiveChange; });\n  useEffect(() => { onActive.current?.(index, index === null ? null : data[index] ?? null); }, [index]); // eslint-disable-line react-hooks/exhaustive-deps\n\n  const pointAt = (clientX: number) => {\n    const rect = plot.current?.getBoundingClientRect();\n    if (!rect?.width || last < 1) return Math.max(0, last);\n    return clamp(Math.round(((clientX - rect.left) / rect.width) * last), 0, last);\n  };\n  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    if (event.key === \"Escape\" && scrubbing) { event.preventDefault(); setActive(null); return; }\n    const from = index ?? last + 1, page = Math.max(1, Math.round(data.length / 6));\n    const next = ({ ArrowLeft: from - 1, ArrowDown: from - 1, ArrowRight: index === null ? last : from + 1, ArrowUp: index === null ? last : from + 1, PageDown: from - page, PageUp: from + page, Home: 0, End: last } as Record<string, number>)[event.key];\n    if (next === undefined || empty) return;\n    event.preventDefault();\n    setActive(clamp(next, 0, last));\n  };\n  const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {\n    if (empty) return;\n    if (event.pointerType !== \"mouse\") event.currentTarget.setPointerCapture?.(event.pointerId);\n    setActive(pointAt(event.clientX));\n  };\n  const onPointerMove = (event: PointerEvent<HTMLDivElement>) => { if (!empty && (event.pointerType === \"mouse\" || event.buttons)) setActive(pointAt(event.clientX)); };\n\n  const format = (value: number, line: LineChartSeries) => `${formatValue ? formatValue(value, line) : grouped.format(value)}${unit ? ` ${unit}` : \"\"}`;\n  const reading = index === null ? null : data[index];\n  const valueText = reading ? `${reading.label}: ${visible.map(line => `${line.label} ${format(reading.values[line.key] ?? 0, line)}`).join(\", \")}` : empty ? emptyLabel : `${visible.length} of ${series.length} series shown`;\n  const summary = empty ? `${label}. ${emptyLabel}.` : `${label}, ${data[0]!.label} to ${data[last]!.label}. ${visible.map(line => `${line.label}, latest ${format(data[last]!.values[line.key] ?? 0, line)}`).join(\". \")}.`;\n  const picks = axisPicks(data, plotWidth);\n  const allHidden = !empty && visible.length === 0;\n  const tipX = reduced ? tipTargetX : tipSpringX, tipY = reduced ? tipTargetY : tipSpringY;\n\n  return <figure ref={figure} className={[styles.figure, className].filter(Boolean).join(\" \")} aria-label={label} aria-busy={loading || undefined}>\n    {showLegend && <div className={styles.legend} role=\"group\" aria-label={`${label} series`}>\n      {series.map((line, at) => <button key={line.key} type=\"button\" className={styles.toggle} aria-pressed={!hidden.includes(line.key)} onClick={() => toggle(line.key)} style={{ \"--series\": colorOf(line, at) } as CSSProperties}>\n        <span className={styles.swatch} data-dashed={line.dashed || undefined} aria-hidden=\"true\" />\n        <span className={styles.toggleLabel}>{line.label}</span>\n      </button>)}\n    </div>}\n    <div className={styles.chart} data-scrubbing={scrubbing || undefined} data-loading={loading || undefined}>\n      <div ref={plot} className={styles.plot} style={{ height }} role=\"slider\" tabIndex={empty ? -1 : 0} aria-label={`${label}, explore by ${categoryLabel.toLowerCase()}`} aria-orientation=\"horizontal\" aria-valuemin={1} aria-valuemax={Math.max(1, data.length)} aria-valuenow={(index ?? last) + 1} aria-valuetext={valueText}\n        onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={event => { if (event.pointerType !== \"mouse\") setActive(null); }} onPointerCancel={() => setActive(null)} onPointerLeave={event => { if (event.pointerType === \"mouse\") setActive(null); }} onKeyDown={onKeyDown} onBlur={() => setActive(null)}\n        onFocus={event => { if (event.currentTarget.matches(\":focus-visible\") && !empty) setActive(current => current ?? last); }}>\n        <svg className={styles.svg} width=\"100%\" height={height} aria-hidden=\"true\" focusable=\"false\">\n          <defs><clipPath id={`${uid}-draw`} clipPathUnits=\"userSpaceOnUse\"><rect ref={clip} x={-8} y={-16} width={0} height={height + 32} /></clipPath></defs>\n          <AnimatePresence initial={false}>{steady.ticks.slice(1).map(value => <Gridline key={value} value={value} scale={scale} height={height} />)}</AnimatePresence>\n          <line className={styles.baseline} x1={0} x2=\"100%\" y1={height - .5} y2={height - .5} />\n          <line ref={crosshair} className={styles.crosshair} y1={TOP - 4} y2={height} />\n          <g className={styles.series} clipPath={`url(#${uid}-draw)`}>\n            {series.map((line, at) => <g key={line.key} ref={node => { if (node) groups.current.set(line.key, node); else groups.current.delete(line.key); }} style={{ \"--series\": colorOf(line, at) } as CSSProperties}>\n              {(line.area ?? at === 0) && <path ref={node => { if (node) areas.current.set(line.key, node); else areas.current.delete(line.key); }} className={styles.area} />}\n              <path ref={node => { if (node) lines.current.set(line.key, node); else lines.current.delete(line.key); }} className={styles.line} data-dashed={line.dashed || undefined} />\n            </g>)}\n          </g>\n          {series.map((line, at) => <circle key={line.key} ref={node => { if (node) dots.current.set(line.key, node); else dots.current.delete(line.key); }} className={styles.dot} r={4} data-hidden={hidden.includes(line.key) || undefined} style={{ \"--series\": colorOf(line, at) } as CSSProperties} />)}\n        </svg>\n        <motion.div ref={tip} className={styles.tooltip} style={{ x: tipX, y: tipY }} aria-hidden=\"true\">\n          <p className={styles.tipTitle}>{(reading ?? data[last])?.label ?? \"\"}</p>\n          {visible.map(line => <p key={line.key} className={styles.tipRow} style={{ \"--series\": colorOf(line, series.indexOf(line)) } as CSSProperties}>\n            <span className={styles.tipSwatch} data-dashed={line.dashed || undefined} />\n            <span className={styles.tipName}>{line.label}</span>\n            <span className={styles.tipValue}>{format((reading ?? data[last])?.values[line.key] ?? 0, line)}</span>\n          </p>)}\n        </motion.div>\n        <AnimatePresence initial={false}>\n          {(empty || allHidden) && !loading && <motion.p key={empty ? \"empty\" : \"hidden\"} className={styles.message} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, transition: fadeFast }}>{empty ? emptyLabel : \"Choose a series to show\"}</motion.p>}\n          {loading && empty && <motion.span key=\"skeleton\" className={styles.skeleton} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, transition: fadeFast }} aria-hidden=\"true\" />}\n        </AnimatePresence>\n      </div>\n      <div className={styles.gutter} aria-hidden=\"true\">\n        <AnimatePresence initial={false}>{steady.ticks.map(value => <TickLabel key={value} value={value} scale={scale} height={height} format={formatTick} />)}</AnimatePresence>\n      </div>\n      <div className={styles.axis} aria-hidden=\"true\">\n        <AnimatePresence initial={false}>\n          {picks.map(at => { const share = last > 0 ? at / last : .5; return <motion.span key={`${data[at]!.key}:${data[at]!.axisLabel}`} className={styles.axisLabel} style={{ left: `${share * 100}%`, translateX: `${-share * 100}%` }} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, transition: fadeFast }} transition={{ duration: duration.standard, ease: [...ease.standard] }}>{data[at]!.axisLabel}</motion.span>; })}\n        </AnimatePresence>\n      </div>\n    </div>\n    <p className={styles.srOnly}>{summary}</p>\n    {!empty && <div className={styles.srOnly}><table>\n      <caption>{label}</caption>\n      <thead><tr><th scope=\"col\">{categoryLabel}</th>{series.map(line => <th key={line.key} scope=\"col\">{line.label}</th>)}</tr></thead>\n      <tbody>{data.map(item => <tr key={item.key}><th scope=\"row\">{item.label}</th>{series.map(line => <td key={line.key}>{format(item.values[line.key] ?? 0, line)}</td>)}</tr>)}</tbody>\n    </table></div>}\n    <p className={styles.srOnly} aria-live=\"polite\" aria-atomic=\"true\">{loading ? \"Loading\" : \"\"}</p>\n  </figure>;\n}\n\nexport default LineChart;\n"
    },
    {
      "path": "registry/components/line-chart/line-chart.module.css",
      "type": "registry:component",
      "target": "@components/xzero/line-chart/line-chart.module.css",
      "content": ".figure { display: grid; min-width: 0; gap: var(--space-4); margin: 0; color: var(--foreground); font-family: var(--font-body); letter-spacing: var(--tracking-body); }\n\n/* Series toggles: a plain line swatch beside each name. A hidden series keeps its place, its swatch empties and its name steps back. */\n.legend { display: flex; flex-wrap: wrap; gap: 2px 4px; min-width: 0; margin-inline: -8px; }\n.toggle { display: inline-flex; min-height: 32px; align-items: center; gap: 8px; padding: 0 10px 0 8px; border: 0; border-radius: var(--radius-pill); background: transparent; color: var(--text-secondary); cursor: pointer; font: inherit; font-size: var(--text-sm); line-height: var(--leading-body); -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard), transform var(--duration-instant) var(--ease-standard); }\n.toggle:active { transform: scale(.97); }\n.toggle[aria-pressed=\"false\"] { color: var(--text-muted); }\n.toggleLabel { white-space: nowrap; transition: opacity var(--duration-fast) var(--ease-standard); }\n.toggle[aria-pressed=\"false\"] .toggleLabel { opacity: .72; text-decoration: line-through; text-decoration-color: color-mix(in oklab, currentColor 55%, transparent); }\n.swatch { position: relative; width: 14px; height: 10px; flex: none; }\n.swatch::before { position: absolute; top: 50%; left: 0; width: 100%; height: 2.5px; border-radius: 2px; background: var(--series); content: \"\"; transform: translateY(-50%) scaleX(1); transform-origin: left; transition: transform var(--duration-standard) var(--ease-spring), opacity var(--duration-fast) var(--ease-standard); }\n.swatch[data-dashed]::before { background: repeating-linear-gradient(to right, var(--series) 0 4px, transparent 4px 7px); }\n.toggle[aria-pressed=\"false\"] .swatch::before { opacity: .45; transform: translateY(-50%) scaleX(.35); }\n@media (hover: hover) and (pointer: fine) { .toggle:hover { background: color-mix(in oklab, var(--foreground) 5%, transparent); color: var(--foreground); } }\n\n/* Plot, right-hand value axis, and the date axis beneath. */\n.chart { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) 48px; grid-template-rows: auto 26px; min-width: 0; }\n.plot { position: relative; grid-area: 1 / 1; min-width: 0; outline: none; touch-action: pan-y; user-select: none; -webkit-user-select: none; -webkit-tap-highlight-color: transparent; }\n.svg { display: block; overflow: visible; }\n.grid { stroke: var(--border); stroke-width: 1; shape-rendering: crispEdges; }\n.baseline { stroke: var(--border-strong); stroke-width: 1; shape-rendering: crispEdges; }\n.crosshair { stroke: var(--border-strong); stroke-width: 1; shape-rendering: crispEdges; opacity: 0; transition: opacity var(--duration-instant) var(--ease-standard); }\n.chart[data-scrubbing] .crosshair { opacity: 1; }\n\n.series { transition: opacity var(--duration-standard) var(--ease-standard); }\n.chart[data-loading] .series { opacity: .32; animation: breathe 1.4s var(--ease-in-out) infinite alternate; }\n.line { fill: none; stroke: var(--series); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; transition: stroke var(--duration-standard) var(--ease-standard); }\n.line[data-dashed] { stroke-dasharray: 4 5; stroke-width: 1.75; }\n.area { fill: color-mix(in oklab, var(--series) 9%, transparent); stroke: none; }\n.dot { fill: var(--series); stroke: var(--surface); stroke-width: 2; opacity: 0; transition: opacity var(--duration-instant) var(--ease-standard); }\n.chart[data-scrubbing] .dot:not([data-hidden]) { opacity: 1; }\n\n/* The tooltip floats over the plot, so it is the one layer here with a shadow. Springs carry its position; opacity and a small scale bring it in. */\n.tooltip { position: absolute; top: 0; left: 0; z-index: 1; display: grid; min-width: 140px; max-width: min(220px, 100%); gap: 4px; padding: 10px 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.chart[data-scrubbing] .tooltip { opacity: 1; scale: 1; }\n.tipTitle { margin: 0 0 2px; color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-body); white-space: nowrap; font-variant-numeric: tabular-nums; }\n.tipRow { display: flex; min-width: 0; align-items: center; gap: 8px; margin: 0; font-size: var(--text-sm); line-height: var(--leading-body); }\n.tipSwatch { width: 10px; height: 2.5px; flex: none; border-radius: 2px; background: var(--series); }\n.tipSwatch[data-dashed] { background: repeating-linear-gradient(to right, var(--series) 0 3px, transparent 3px 5px); }\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; color: var(--foreground); font-weight: 500; font-variant-numeric: tabular-nums; white-space: nowrap; }\n\n.message { position: absolute; inset: 0; display: grid; place-items: center; margin: 0; color: var(--text-muted); font-size: var(--text-sm); pointer-events: none; }\n/* First load with nothing to keep on screen: a calm band that breathes where the lines will be. */\n.skeleton { position: absolute; inset: 30% 0 25%; border-radius: 12px; background: linear-gradient(to bottom, transparent, color-mix(in oklab, var(--foreground) 5%, transparent)); animation: breathe 1.4s var(--ease-in-out) infinite alternate; }\n\n.gutter { position: relative; grid-area: 1 / 2; min-width: 0; }\n.tick { position: absolute; top: 0; left: 10px; display: block; height: 0; white-space: nowrap; pointer-events: none; }\n.tickText { display: block; transform: translateY(-50%); color: var(--text-muted); font-size: var(--text-xs); line-height: 1; font-variant-numeric: tabular-nums; }\n\n.axis { position: relative; grid-area: 2 / 1; min-width: 0; }\n.axisLabel { position: absolute; top: 8px; color: var(--text-muted); font-size: var(--text-xs); line-height: 1; white-space: nowrap; font-variant-numeric: tabular-nums; }\n\n.srOnly { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; border: 0; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }\n\n@keyframes breathe { from { opacity: .22; } to { opacity: .45; } }\n@media (prefers-reduced-motion: reduce) {\n  .toggle, .swatch::before, .line, .tooltip, .crosshair, .dot, .series { transition: none; }\n  .chart[data-loading] .series, .skeleton { animation: none; }\n}\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/line-chart",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "data",
      "new"
    ],
    "docs": "https://ui.xzero.co.uk/components/line-chart",
    "markdown": "https://ui.xzero.co.uk/components/line-chart/markdown"
  }
}