{"$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://uiarc.dev/r/arc-foundation.json"],"files":[{"path":"registry/components/line-chart/line-chart.tsx","type":"registry:component","target":"@components/arc/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;\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;\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 oklch, var(--foreground) 46%, var(--surface))\", \"color-mix(in oklch, 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) => 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; max: MotionValue };\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 ;\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 \n {format(value)}\n ;\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(null);\n const plot = useRef(null);\n const tip = useRef(null);\n const clip = useRef(null);\n const crosshair = useRef(null);\n const lines = useRef(new Map());\n const areas = useRef(new Map());\n const dots = useRef(new Map());\n const groups = useRef(new Map());\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(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(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());\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) => {\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)[event.key];\n if (next === undefined || empty) return;\n event.preventDefault();\n setActive(clamp(next, 0, last));\n };\n const onPointerDown = (event: PointerEvent) => {\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) => { 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
\n {showLegend &&
\n {series.map((line, at) => )}\n
}\n
\n
{ 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 \n \n {steady.ticks.slice(1).map(value => )}\n \n \n \n {series.map((line, at) => { 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) && { if (node) areas.current.set(line.key, node); else areas.current.delete(line.key); }} className={styles.area} />}\n { if (node) lines.current.set(line.key, node); else lines.current.delete(line.key); }} className={styles.line} data-dashed={line.dashed || undefined} />\n )}\n \n {series.map((line, at) => { 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 \n \n

{(reading ?? data[last])?.label ?? \"\"}

\n {visible.map(line =>

\n \n {line.label}\n {format((reading ?? data[last])?.values[line.key] ?? 0, line)}\n

)}\n
\n \n {(empty || allHidden) && !loading && {empty ? emptyLabel : \"Choose a series to show\"}}\n {loading && empty && }\n \n
\n
\n {steady.ticks.map(value => )}\n
\n
\n \n {picks.map(at => { const share = last > 0 ? at / last : .5; return {data[at].axisLabel}; })}\n \n
\n
\n

{summary}

\n {!empty && \n \n {series.map(line => )}\n {data.map(item => {series.map(line => )})}\n
{label}
{categoryLabel}{line.label}
{item.label}{format(item.values[line.key] ?? 0, line)}
}\n

{loading ? \"Loading\" : \"\"}

\n
;\n}\n\nexport default LineChart;\n"},{"path":"registry/components/line-chart/line-chart.module.css","type":"registry:component","target":"@components/arc/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 oklch, 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 oklch, 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 oklch, 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: 1.3; }\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 oklch, 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; overflow: hidden; 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://uiarc.dev/components/line-chart","meta":{"tier":"free","kind":"component","docs":"https://uiarc.dev/components/line-chart","markdown":"https://uiarc.dev/components/line-chart/markdown","tags":["data","new"]}}