{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"sparkline","type":"registry:ui","title":"Sparkline","description":"Show a compact trend beside a value.","categories":["data"],"dependencies":["motion"],"registryDependencies":["https://uiarc.dev/r/arc-foundation.json"],"files":[{"path":"registry/components/sparkline/sparkline.tsx","type":"registry:component","target":"@components/arc/sparkline/sparkline.tsx","content":"\"use client\";\n\nimport { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent, type PointerEvent } from \"react\";\nimport { AnimatePresence, animate, motion, useInView, useMotionValue, useMotionValueEvent, useReducedMotion, useTransform, type Variants } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./sparkline.module.css\";\n\nexport interface SparklineProps {\n data: number[];\n label: string;\n value?: string;\n change?: string;\n tone?: \"accent\" | \"success\" | \"warning\" | \"danger\";\n width?: number;\n height?: number;\n /** One label per point, such as a date. It replaces the change while that point is scrubbed. */\n labels?: string[];\n /** Formats a scrubbed point for the headline. Defaults to a grouped number. */\n formatValue?: (value: number, index: number) => string;\n /** A quiet neutral fill under the line. */\n area?: boolean;\n /** Pointer and keyboard scrubbing through the points. */\n interactive?: boolean;\n}\n\n/** Copy that holds a number enters from the side it moved toward: a larger value rises from below, a smaller one drops from above. */\nconst rise: Variants = { hidden: (direction: number) => ({ opacity: 0, y: `${.3 * direction}em`, filter: `blur(${motionTokens.blur.soft}px)` }), shown: { opacity: 1, y: 0, filter: \"blur(0px)\", transition: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] } }, gone: (direction: number) => ({ opacity: 0, y: `${-.3 * direction}em`, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } }) };\nconst fade: Variants = { hidden: { opacity: 0, y: 0, filter: \"blur(0px)\" }, shown: { opacity: 1, y: 0, filter: \"blur(0px)\", transition: { duration: motionTokens.duration.instant } }, gone: { opacity: 0, y: 0, filter: \"blur(0px)\", transition: { duration: motionTokens.duration.instant } } };\nconst amountIn = (text: string) => Number(text.replace(/,/g, \"\").replace(/−/g, \"-\").match(/-?\\d+(?:\\.\\d+)?/)?.[0] ?? NaN);\nconst grouped = new Intl.NumberFormat(\"en-US\");\n/** The first draw: one smooth stroke, slow at both ends like a pen. */\nconst draw = { duration: motionTokens.duration.considered * 1.75, ease: [...motionTokens.ease.inOut] } as const;\nconst quick = { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } as const;\n\ntype Chunk = { slot: string; key: string; text: string };\n/** A number keeps its unchanged digits still, keyed by place from the right. Words key on everything up to them, so only the first changed word and what follows it roll. */\nfunction chunksOf(text: string, whole: boolean): Chunk[] {\n if (!text) return [];\n if (whole) return [{ slot: \"all\", key: text, text }];\n if (!/\\p{L}/u.test(text)) return [...text].map((char, index, all) => ({ slot: `n${all.length - index}`, key: char, text: char }));\n const words = text.split(/(?<=\\s)/);\n return words.map((word, index) => ({ slot: `w${index}`, key: words.slice(0, index + 1).join(\"\"), text: word }));\n}\n\n/** Text that changes rolls in place, like a numeric content transition; `morph` springs the wrapper to the new width so nothing beside it jumps. */\nfunction Roll({ text, direction, whole = false, morph = false, tone }: { text: string; direction?: number; whole?: boolean; morph?: boolean; tone?: string }) {\n const reduceMotion = !!useReducedMotion();\n const sizer = useRef(null);\n const width = useMotionValue(\"auto\");\n const [shown, setShown] = useState({ text, direction: 1 });\n if (shown.text !== text) setShown({ text, direction: direction ?? (amountIn(text) < amountIn(shown.text) ? -1 : 1) });\n useEffect(() => {\n const node = sizer.current;\n if (!node || typeof ResizeObserver === \"undefined\") return;\n let measured: string | null = null;\n // Layout size, not the transformed rect, so a scaling parent never leaves the text clipped. Only a new text springs; font loads jump.\n const observer = new ResizeObserver(([entry]) => {\n const next = entry.borderBoxSize?.[0]?.inlineSize ?? node.offsetWidth;\n if (measured !== null && measured !== node.textContent && !reduceMotion) animate(width, next, motionTokens.spring.morph);\n else width.jump(next);\n measured = node.textContent;\n });\n observer.observe(node);\n return () => observer.disconnect();\n }, [morph, reduceMotion, width]);\n const numeric = !whole && !/\\p{L}/u.test(text);\n const variants = reduceMotion ? fade : rise;\n const anchor = numeric ? \"right\" : \"left\";\n return \n {morph && {text}}\n {/* One flex item holds the pieces, so they stay inline and the text reads and copies as one string. */}\n \n {chunksOf(text, whole).map(chunk => \n \n {chunk.text}\n \n )}\n \n ;\n}\n\ntype Point = [number, number];\n/** Space kept above the highest and below the lowest point, and half the line width. */\nconst inset = 5, cap = 1.25;\nconst grid = Array.from({ length: 161 }, (_, index) => index / 160);\n\n/** A smooth line through every point, sampled densely in unit space (x and y both 0 to 1, y up). Monotone cubic tangents keep it from swinging past the data. */\nfunction curveFor(data: number[]): Point[] {\n const min = Math.min(...data), span = Math.max(...data) - min;\n const ys = data.map(value => span ? (value - min) / span : .5);\n const last = ys.length - 1, step = 1 / last;\n const slopes = ys.slice(1).map((y, index) => (y - ys[index]) / step);\n const tangents = ys.map((_, index) => index === 0 || index === last ? slopes[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 const samples = Math.max(1, Math.min(16, Math.round(128 / last)));\n const points: Point[] = [];\n for (let index = 0; index < last; index++) for (let sample = 0; sample < samples; sample++) {\n const t = sample / samples, t2 = t * t, t3 = t2 * t;\n const y = (2 * t3 - 3 * t2 + 1) * ys[index] + (t3 - 2 * t2 + t) * step * tangents[index] + (3 * t2 - 2 * t3) * ys[index + 1] + (t3 - t2) * step * tangents[index + 1];\n points.push([(index + t) * step, Math.min(1, Math.max(0, y))]);\n }\n points.push([1, ys[last]]);\n return points;\n}\n\n/** Height of the sampled line at x, straight between samples. */\nfunction heightAt(shape: Point[], x: number) {\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/** Every drawn string for one frame, in the chart's own pixels so strokes and dots stay round at any width. */\nfunction geometryOf(shape: Point[], w: number, h: number, at: number) {\n const toY = (y: number) => inset + (1 - y) * (h - inset * 2);\n const points = shape.map(([x, y]): Point => [x * w, toY(y)]);\n const pairs = points.map(([x, y]) => `${x.toFixed(2)},${y.toFixed(2)}`);\n const cx = at * w, cy = toY(heightAt(shape, at));\n return { points, line: pairs.join(\" \"), trail: `M${pairs.join(\"L\")}`, fill: `M${pairs.join(\"L\")}L${w},${h}L0,${h}Z`, dot: `${cx},${cy} ${cx},${cy}`, cx };\n}\n\n/** Where the pen is along the line once `progress` of its length is drawn, so the fill follows the tip. */\nfunction tipAt(points: Point[], progress: number) {\n const steps = points.slice(1).map(([x, y], index) => Math.hypot(x - points[index][0], y - points[index][1]));\n let left = progress * steps.reduce((sum, step) => sum + step, 0);\n for (let index = 0; index < steps.length; index++) {\n if (left <= steps[index]) return points[index][0] + (points[index + 1][0] - points[index][0]) * (steps[index] ? left / steps[index] : 1);\n left -= steps[index];\n }\n return points[points.length - 1][0];\n}\n\nexport function Sparkline({ data, label, value, change, tone = \"accent\", width = 160, height = 52, labels, formatValue, area = true, interactive = true }: SparklineProps) {\n const safeData = data.length > 1 ? data : [data[0] ?? 0, data[0] ?? 0];\n const last = safeData.length - 1;\n const key = safeData.join(\",\");\n const target = useMemo(() => curveFor(key.split(\",\").map(Number)), [key]);\n const description = `${label}: ${value ?? safeData[last]}. ${change ?? \"\"}`.trim();\n const uid = `sparkline${useId().replace(/[^a-zA-Z0-9_-]/g, \"\")}`;\n const ref = useRef(null);\n const plot = useRef(null);\n const inView = useInView(ref, { once: true, amount: .5 });\n const reduceMotion = !!useReducedMotion();\n const [box, setBox] = useState({ w: width, h: height });\n const [active, setActive] = useState(null);\n const [landed, setLanded] = useState(false);\n const index = active === null ? null : Math.min(active, last);\n const [moved, setMoved] = useState({ index, direction: 1 });\n if (moved.index !== index) setMoved({ index, direction: index === null || moved.index === null ? 1 : Math.sign(index - moved.index) || 1 });\n\n const [first] = useState(() => geometryOf(target, width, height, 1));\n const shape = useRef(target);\n const size = useRef(box);\n const drawn = useMotionValue(0), cursor = useMotionValue(1);\n const line = useMotionValue(first.line), trail = useMotionValue(first.trail), fill = useMotionValue(first.fill), dot = useMotionValue(first.dot);\n const cursorX = useMotionValue(first.cx), lit = useMotionValue(first.cx + cap + 16), dim = useMotionValue(first.cx + cap), reveal = useMotionValue(0);\n const lineOpacity = useTransform(drawn, progress => progress > .001 ? 1 : 0);\n\n // One writer for every drawn attribute: the morphing shape, the cursor, the pen and the measured size all repaint through here.\n const paint = useCallback(() => {\n const { w, h } = size.current, progress = drawn.get();\n const next = geometryOf(shape.current, w, h, cursor.get());\n line.set(next.line); trail.set(next.trail); fill.set(next.fill); dot.set(next.dot);\n cursorX.set(next.cx); lit.set(next.cx + cap + 16); dim.set(next.cx + cap);\n reveal.set(progress >= 1 ? w + 32 : tipAt(next.points, progress) + 16);\n }, [cursor, cursorX, dim, dot, drawn, fill, line, lit, reveal, trail]);\n useMotionValueEvent(cursor, \"change\", paint);\n useMotionValueEvent(drawn, \"change\", paint);\n\n // The chart draws in its own pixels: measure the width, keep the height in proportion.\n useLayoutEffect(() => {\n const node = plot.current;\n if (!node || typeof ResizeObserver === \"undefined\") return;\n const observer = new ResizeObserver(() => { const w = node.clientWidth; if (w) setBox(current => Math.abs(current.w - w) < .5 ? current : { w, h: (w * height) / width }); });\n observer.observe(node);\n return () => observer.disconnect();\n }, [height, width]);\n useLayoutEffect(() => { size.current = box; paint(); }, [box, paint]);\n\n // The line draws left to right once it is seen, the fill follows the pen, then the end dot lands.\n useEffect(() => {\n // Reduced motion shows the finished line at once; the dot lands a frame later, after hydration has matched the server.\n if (reduceMotion) { drawn.jump(1); const frame = requestAnimationFrame(() => setLanded(true)); return () => cancelAnimationFrame(frame); }\n if (!inView) return;\n const controls = animate(drawn, 1, { ...draw, onComplete: () => setLanded(true) });\n return () => controls.stop();\n }, [drawn, inView, reduceMotion]);\n\n // New data morphs the line from the shape on screen, sampled on shared x positions so a series that gains or loses points still flows.\n useEffect(() => {\n const from = shape.current;\n if (from === target) return;\n if (reduceMotion) { shape.current = target; paint(); return; }\n const xs = [...new Set([...grid, ...target.map(([x]) => x)])].sort((a, b) => a - b);\n const start = xs.map(x => heightAt(from, x)), goal = xs.map(x => heightAt(target, x));\n const controls = animate(0, 1, { ...motionTokens.spring.smooth, onUpdate: progress => { shape.current = xs.map((x, at): Point => [x, start[at] + (goal[at] - start[at]) * progress]); paint(); }, onComplete: () => { shape.current = target; paint(); } });\n return () => controls.stop();\n }, [paint, reduceMotion, target]);\n\n // The cursor springs to the scrubbed point and rides the line between points; leaving sends it home to the latest value.\n useEffect(() => {\n const to = index === null ? 1 : index / last;\n if (reduceMotion) { cursor.jump(to); return; }\n const controls = animate(cursor, to, motionTokens.spring.snappy);\n return () => controls.stop();\n }, [cursor, index, last, reduceMotion]);\n\n const scrubbing = index !== null;\n const format = (at: number) => formatValue ? formatValue(safeData[at], at) : grouped.format(safeData[at]);\n const point = format(index ?? last), when = labels?.[index ?? last];\n const headline = value === undefined ? undefined : scrubbing ? point : value;\n const aside = index === null ? change ?? \"\" : value === undefined ? [point, when].filter(Boolean).join(\" · \") : when ?? `${index + 1} of ${last + 1}`;\n const pointAt = (clientX: number) => { const rect = plot.current?.getBoundingClientRect(); return rect?.width ? Math.max(0, Math.min(last, Math.round(((clientX - rect.left) / rect.width) * last))) : last; };\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key === \"Escape\" && scrubbing) { event.preventDefault(); setActive(null); return; }\n const from = index ?? last, page = Math.max(1, Math.round((last + 1) / 6));\n const next = ({ ArrowLeft: from - 1, ArrowDown: from - 1, ArrowRight: from + 1, ArrowUp: from + 1, PageDown: from - page, PageUp: from + page, Home: 0, End: last } as Record)[event.key];\n if (next === undefined) return;\n event.preventDefault();\n setActive(Math.max(0, Math.min(last, next)));\n };\n const scrub = interactive ? {\n role: \"slider\", tabIndex: 0, \"aria-label\": `${label}, explore values`, \"aria-valuemin\": 1, \"aria-valuemax\": last + 1, \"aria-valuenow\": (index ?? last) + 1, \"aria-valuetext\": `${when ?? (index === null ? \"Latest\" : `Point ${index + 1} of ${last + 1}`)}: ${point}`,\n onPointerDown: (event: PointerEvent) => setActive(pointAt(event.clientX)),\n onPointerMove: (event: PointerEvent) => { if (event.pointerType === \"mouse\" || event.buttons) setActive(pointAt(event.clientX)); },\n onPointerUp: (event: PointerEvent) => { if (event.pointerType !== \"mouse\") setActive(null); },\n onPointerLeave: () => setActive(null), onPointerCancel: () => setActive(null), onBlur: () => setActive(null), onKeyDown,\n } : {};\n return
\n
\n \n {headline !== undefined && }\n \n
\n
\n \n \n \n \n \n \n {area && }\n \n {/* While scrubbing, the stretch after the cursor dims, as if the later points have not happened yet. */}\n \n \n \n {/* A zero-length round-capped stroke is a true circle, and it takes the line's colour. It rests on the latest point and becomes the scrub handle. */}\n \n \n
\n
;\n}\n\nexport default Sparkline;\n"},{"path":"registry/components/sparkline/sparkline.module.css","type":"registry:component","target":"@components/arc/sparkline/sparkline.module.css","content":".figure { display: grid; min-width: 0; gap: var(--space-3); margin: 0; color: var(--foreground); }.caption { display: flex; align-items: baseline; gap: var(--space-2); min-width: 0; color: var(--text-secondary); font-size: var(--text-sm); }.label { min-width: 0; overflow: hidden; white-space: nowrap; }.caption strong { margin-left: auto; color: var(--foreground); font-size: var(--text-lg); font-weight: 500; letter-spacing: var(--tracking-body); font-variant-numeric: tabular-nums; }.caption small { flex: none; font-size: var(--text-xs); font-weight: 500; font-variant-numeric: tabular-nums; transition: color var(--duration-standard) var(--ease-standard); }\n.accent { color: var(--accent-strong); stroke: var(--accent); }.success { color: var(--success); stroke: var(--success); }.warning { color: var(--warning); stroke: var(--warning); }.danger { color: var(--danger); stroke: var(--danger); }\n/* The scrubbed date is context, not a change, so it stays neutral whatever the tone. */\n.point { color: var(--text-secondary); }\n\n/* Changing text rolls in place. Outgoing pieces are popped out of flow, so the wrapper never holds both widths; numbers stay anchored to their right edge. */\n.roll { position: relative; display: inline-flex; max-width: 100%; overflow-x: clip; white-space: pre; vertical-align: top; }\n.roll[data-end] { justify-content: flex-end; }\n.track, .slot, .glyph { position: relative; display: inline-block; white-space: pre; }\n.roll[data-whole] .track, .roll[data-whole] .slot, .roll[data-whole] .glyph { max-width: 100%; overflow: hidden; text-overflow: ellipsis; vertical-align: top; }\n.sizer { position: absolute; top: 0; left: 0; visibility: hidden; white-space: pre; pointer-events: none; }\n\n/* The plot is the scrubber: hover or drag across it, or focus it and use the arrow keys. Vertical swipes still scroll the page. */\n.plot { position: relative; min-width: 0; border-radius: calc(var(--radius-control) / 2); outline: none; -webkit-tap-highlight-color: transparent; }\n.plot[data-interactive] { touch-action: pan-y; cursor: default; user-select: none; }\n.plot:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 8px; }\n.chart { display: block; width: 100%; height: auto; overflow: visible; }\n.baseline { stroke: var(--border); stroke-width: 1; }\n/* The area under the line carries a faint wash of the line tone. */\n.area.area { fill: color-mix(in oklab, currentColor 9%, transparent); stroke: none; }\n.line, .trail { fill: none; stroke-width: 2.5; stroke-linecap: round; stroke-linejoin: round; }\n.line { transition: stroke var(--duration-standard) var(--ease-standard); }\n.trail { stroke: var(--border-strong); }\n.cursor { stroke: var(--border-strong); stroke-width: 1; }\n.end { transition: stroke var(--duration-standard) var(--ease-standard); }\n@media (prefers-reduced-motion: reduce) { .line, .end, .caption small { transition: none; } }\n"}],"docs":"Docs and live preview: https://uiarc.dev/components/sparkline","meta":{"tier":"free","kind":"component","docs":"https://uiarc.dev/components/sparkline","markdown":"https://uiarc.dev/components/sparkline/markdown","tags":["data","chart"]}}