{
  "$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://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/sparkline/sparkline.tsx",
      "type": "registry:component",
      "target": "@components/xzero/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<HTMLSpanElement>(null);\n  const width = useMotionValue<number | \"auto\">(\"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      if (!entry) return;\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 <motion.span className={styles.roll} data-end={numeric || undefined} data-whole={whole || undefined} style={morph ? { width } : undefined}>\n    {morph && <span ref={sizer} className={styles.sizer} aria-hidden=\"true\">{text}</span>}\n    {/* One flex item holds the pieces, so they stay inline and the text reads and copies as one string. */}\n    <span className={styles.track}><AnimatePresence mode=\"popLayout\" initial={false} custom={shown.direction} anchorX={anchor}>\n      {chunksOf(text, whole).map(chunk => <motion.span key={chunk.slot} className={styles.slot} custom={shown.direction} variants={variants} initial=\"hidden\" animate=\"shown\" exit=\"gone\">\n        <AnimatePresence mode=\"popLayout\" initial={false} custom={shown.direction} anchorX={anchor}>\n          <motion.span key={`${tone ?? \"\"}${chunk.key}`} className={tone ? `${styles.glyph} ${tone}` : styles.glyph} custom={shown.direction} variants={variants} initial=\"hidden\" animate=\"shown\" exit=\"gone\">{chunk.text}</motion.span>\n        </AnimatePresence>\n      </motion.span>)}\n    </AnimatePresence></span>\n  </motion.span>;\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    const step = steps[index]!, from = points[index]![0];\n    if (left <= step) return from + (points[index + 1]![0] - from) * (step ? left / step : 1);\n    left -= step;\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<HTMLElement>(null);\n  const plot = useRef<HTMLDivElement>(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<number | null>(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<HTMLDivElement>) => {\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<string, number>)[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<HTMLDivElement>) => setActive(pointAt(event.clientX)),\n    onPointerMove: (event: PointerEvent<HTMLDivElement>) => { if (event.pointerType === \"mouse\" || event.buttons) setActive(pointAt(event.clientX)); },\n    onPointerUp: (event: PointerEvent<HTMLDivElement>) => { if (event.pointerType !== \"mouse\") setActive(null); },\n    onPointerLeave: () => setActive(null), onPointerCancel: () => setActive(null), onBlur: () => setActive(null), onKeyDown,\n  } : {};\n  return <figure ref={ref} className={styles.figure} aria-label={description}>\n    <figcaption className={styles.caption}>\n      <span className={styles.label}><Roll text={label} whole /></span>\n      {headline !== undefined && <strong><Roll text={headline} morph /></strong>}\n      <small className={styles[tone]}><Roll text={aside} direction={scrubbing ? moved.direction : undefined} tone={scrubbing ? styles.point : undefined} morph /></small>\n    </figcaption>\n    <div ref={plot} className={styles.plot} data-interactive={interactive || undefined} {...scrub}>\n      <svg className={styles.chart} viewBox={`0 0 ${box.w} ${box.h}`} aria-hidden=\"true\" focusable=\"false\">\n        <defs>\n          <clipPath id={`${uid}-lit`} clipPathUnits=\"userSpaceOnUse\"><motion.rect x={-16} y={-16} width={lit} height={box.h + 32} /></clipPath>\n          <clipPath id={`${uid}-dim`} clipPathUnits=\"userSpaceOnUse\"><motion.rect x={dim} y={-16} width={box.w + 32} height={box.h + 32} /></clipPath>\n          <clipPath id={`${uid}-fill`} clipPathUnits=\"userSpaceOnUse\"><motion.rect x={-16} y={-16} width={reveal} height={box.h + 32} /></clipPath>\n        </defs>\n        {area && <motion.path className={`${styles.area} ${styles[tone]}`} d={fill} clipPath={`url(#${uid}-fill)`} />}\n        <line className={styles.baseline} x1=\"0\" y1={box.h - .5} x2={box.w} y2={box.h - .5} />\n        {/* While scrubbing, the stretch after the cursor dims, as if the later points have not happened yet. */}\n        <motion.path className={styles.trail} d={trail} clipPath={`url(#${uid}-dim)`} initial={false} animate={{ opacity: scrubbing && landed ? 1 : 0 }} transition={reduceMotion ? { duration: 0 } : quick} />\n        <motion.polyline className={[styles.line, styles[tone]].join(\" \")} points={line} clipPath={`url(#${uid}-lit)`} style={{ pathLength: drawn, opacity: lineOpacity }} />\n        <motion.line className={styles.cursor} x1={cursorX} x2={cursorX} y1={0} y2={box.h} initial={false} animate={{ opacity: scrubbing ? 1 : 0 }} transition={reduceMotion ? { duration: 0 } : quick} />\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        <motion.polyline className={[styles.end, styles[tone]].join(\" \")} points={dot} initial={false} animate={{ opacity: landed ? 1 : 0, strokeWidth: landed ? scrubbing ? 10 : 7 : 0 }} transition={reduceMotion ? { duration: 0 } : { opacity: quick, strokeWidth: motionTokens.spring.snappy }} fill=\"none\" strokeLinecap=\"round\" />\n      </svg>\n    </div>\n  </figure>;\n}\n\nexport default Sparkline;\n"
    },
    {
      "path": "registry/components/sparkline/sparkline.module.css",
      "type": "registry:component",
      "target": "@components/xzero/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 { --focus-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://ui.xzero.co.uk/components/sparkline",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "data",
      "chart"
    ],
    "docs": "https://ui.xzero.co.uk/components/sparkline",
    "markdown": "https://ui.xzero.co.uk/components/sparkline/markdown"
  }
}