{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sparkline",
  "type": "registry:ui",
  "title": "Sparkline",
  "description": "A tiny trend line that draws in once and scrubs to any point.",
  "categories": [
    "data"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/sparkline.tsx",
      "content": "\"use client\";\nimport { animate, motion, useInView, useMotionValue, useReducedMotion, useTransform } from \"motion/react\";\nimport { useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\n\nconst noop = () => () => {};\n/** The reader's locale on the client, a fixed one on the server, so hydration always matches. */\nfunction useLocale(locale?: string) {\n  const detected = useSyncExternalStore(noop, () => Intl.NumberFormat().resolvedOptions().locale, () => \"en-US\");\n  return locale ?? detected;\n}\n\nexport type SparklineDatum = number | null;\n\nexport type SparklineGeometry = {\n  /** One entry per datum: x from 0 to 1, y in pixels from the top. null for a gap. */\n  points: ({ x: number; y: number } | null)[];\n  /** The line, in a viewBox that is 100 wide and `height` tall. */\n  line: string;\n  /** The line closed down to the bottom edge, for the area wash. */\n  area: string;\n  min: number;\n  max: number;\n};\n\n/**\n * Plots values into a box 100 units wide and `height` pixels tall. Width is in\n * percent so the SVG can stretch to any container without measuring it; height\n * is real pixels so the stroke and the padding stay true. Nulls break the line.\n */\nexport function sparklineGeometry(data: SparklineDatum[], height: number, domain?: [number, number], pad = 4): SparklineGeometry {\n  const values = data.filter((v): v is number => v != null && Number.isFinite(v));\n  let [min, max] = domain ?? [Math.min(...values), Math.max(...values)];\n  if (!values.length && !domain) [min, max] = [0, 1];\n  // A flat series sits in the middle rather than on the floor.\n  if (min === max) [min, max] = [min - 1, max + 1];\n  const n = data.length;\n  const points = data.map((v, i) => {\n    if (v == null || !Number.isFinite(v)) return null;\n    const x = n === 1 ? 0.5 : i / (n - 1);\n    const y = pad + (1 - (v - min) / (max - min)) * (height - pad * 2);\n    return { x, y };\n  });\n  let line = \"\";\n  let area = \"\";\n  let run: { x: number; y: number }[] = [];\n  const flush = () => {\n    if (!run.length) return;\n    // A lone point between gaps still gets a visible stub.\n    const pts = run.length === 1 ? [{ x: run[0].x - 0.004, y: run[0].y }, { x: run[0].x + 0.004, y: run[0].y }] : run;\n    const seg = pts.map((p, i) => `${i ? \"L\" : \"M\"}${(p.x * 100).toFixed(3)} ${p.y.toFixed(2)}`).join(\"\");\n    line += seg;\n    area += `${seg}L${(pts[pts.length - 1].x * 100).toFixed(3)} ${height}L${(pts[0].x * 100).toFixed(3)} ${height}Z`;\n    run = [];\n  };\n  for (const p of points) {\n    if (p) run.push(p);\n    else flush();\n  }\n  flush();\n  return { points, line, area, min, max };\n}\n\nexport type SparklineProps = Omit<React.ComponentProps<\"div\">, \"children\" | \"onChange\"> & {\n  /** The series, oldest first. null marks a gap (downtime, a missing day). */\n  data: SparklineDatum[];\n  /** One label per point, shown and announced while scrubbing: \"Mar 4\", \"14:05\". */\n  labels?: string[];\n  /** Names the series for screen readers: \"Revenue, last 30 days\". */\n  label?: string;\n  variant?: \"line\" | \"area\";\n  /**\n   * neutral draws in the foreground; trend turns success when the last value is\n   * above the first and danger when below; success and danger force a color.\n   */\n  tone?: \"neutral\" | \"trend\" | \"success\" | \"danger\";\n  /** For latency, errors or cost: a fall is good news under tone=\"trend\". */\n  inverse?: boolean;\n  /** Pixels. Width always follows the container. */\n  height?: number;\n  /** Fix the y range, so several sparklines compare honestly. Defaults to the data's own min and max. */\n  domain?: [number, number];\n  formatValue?: (value: number) => string;\n  locale?: string;\n  /** Pointer and arrow-key scrubbing. Off, it is a static image. */\n  scrub?: boolean;\n  /** Show the value pill while scrubbing. Turn off when onScrub drives a headline instead. */\n  tooltip?: boolean;\n  /** Called with the scrubbed index, and null when scrubbing ends. */\n  onScrub?: (index: number | null) => void;\n  /** A soft pulse on the last point, for data that is still arriving. */\n  live?: boolean;\n  /** Reveal left to right the first time it scrolls into view. */\n  animated?: boolean;\n};\n\nexport function Sparkline({\n  data,\n  labels,\n  label = \"Trend\",\n  variant = \"line\",\n  tone = \"neutral\",\n  inverse = false,\n  height = 32,\n  domain,\n  formatValue,\n  locale: localeProp,\n  scrub = true,\n  tooltip = true,\n  onScrub,\n  live = false,\n  animated = true,\n  className,\n  style,\n  ...rest\n}: SparklineProps) {\n  const locale = useLocale(localeProp);\n  const reduce = useReducedMotion();\n  const gradient = useId();\n  const clip = useId();\n  const box = useRef<HTMLDivElement>(null);\n  const inView = useInView(box, { once: true, amount: 0.6 });\n  const [index, setIndex] = useState<number | null>(null);\n  const [touching, setTouching] = useState(false);\n  // The last dot waits for the reveal once; after that it comes back at once.\n  const [revealed, setRevealed] = useState(false);\n  const summaryId = useId();\n\n  const geo = useMemo(() => sparklineGeometry(data, height, domain), [data, height, domain]);\n  const fmt = useMemo(() => {\n    const nf = new Intl.NumberFormat(locale, { maximumFractionDigits: 2 });\n    return formatValue ?? ((v: number) => nf.format(v));\n  }, [formatValue, locale]);\n\n  const lastIndex = findLast(geo.points);\n  const firstIndex = geo.points.findIndex(Boolean);\n  const first = firstIndex >= 0 ? (data[firstIndex] as number) : null;\n  const last = lastIndex >= 0 ? (data[lastIndex] as number) : null;\n  const up = first != null && last != null ? last > first : null;\n  const resolvedTone = tone === \"trend\" ? (up == null || last === first ? \"neutral\" : up !== inverse ? \"success\" : \"danger\") : tone;\n  const empty = lastIndex < 0;\n\n  // One scrub position, sprung after the pointer and jumped for the keyboard.\n  const rx = useMotionValue(0);\n  const ry = useMotionValue(0);\n  const left = useTransform(rx, (v) => `${v * 100}%`);\n  // Shifting the pill by the same percentage of its own width keeps it inside the box at both ends.\n  const shift = useTransform(rx, (v) => `${-v * 100}%`);\n  const clipWidth = useTransform(rx, (v) => v * 100);\n\n  const moveTo = (i: number | null, instant: boolean) => {\n    if (i === index) return;\n    setIndex(i);\n    onScrub?.(i);\n    const p = i == null ? null : geo.points[i];\n    if (!p) return;\n    const jump = instant || reduce || index == null;\n    if (jump) {\n      rx.jump(p.x);\n      ry.jump(p.y);\n    } else {\n      animate(rx, p.x, spring.follow);\n      animate(ry, p.y, spring.follow);\n    }\n  };\n\n  // Snap to the nearest point that has a value, so a gap never strands the scrubber.\n  const nearest = (ratio: number) => {\n    const n = data.length;\n    const target = Math.round(Math.min(1, Math.max(0, ratio)) * (n - 1));\n    for (let d = 0; d < n; d++) {\n      if (geo.points[target - d]) return target - d;\n      if (geo.points[target + d]) return target + d;\n    }\n    return null;\n  };\n\n  const fromPointer = (e: React.PointerEvent) => {\n    const r = e.currentTarget.getBoundingClientRect();\n    return nearest((e.clientX - r.left) / r.width);\n  };\n\n  const interactive = scrub && !empty && data.length > 1;\n  const current = index ?? lastIndex;\n  const valueText =\n    current >= 0 && data[current] != null ? `${labels?.[current] ? `${labels[current]}: ` : \"\"}${fmt(data[current] as number)}` : \"No data\";\n  const summary = empty\n    ? `${label}: no data`\n    : `${label}: ${fmt(first as number)} to ${fmt(last as number)}, low ${fmt(Math.min(...(data.filter((v) => v != null) as number[])))}, high ${fmt(\n        Math.max(...(data.filter((v) => v != null) as number[])),\n      )}`;\n\n  // Render output never depends on reduced motion (the server can't know it); only the transitions do.\n  const shown = !animated || inView;\n  useEffect(() => {\n    if (!shown || revealed) return;\n    const t = window.setTimeout(() => setRevealed(true), 900);\n    return () => window.clearTimeout(t);\n  }, [shown, revealed]);\n  const lastPoint = lastIndex >= 0 ? geo.points[lastIndex] : null;\n  const scrubbing = index != null;\n\n  return (\n    <div\n      ref={box}\n      data-slot=\"sparkline\"\n      data-tone={resolvedTone}\n      data-state={scrubbing ? \"scrubbing\" : \"idle\"}\n      data-empty={empty || undefined}\n      role={interactive ? \"slider\" : \"img\"}\n      aria-label={interactive ? label : summary}\n      aria-describedby={interactive ? summaryId : undefined}\n      aria-valuemin={interactive ? 0 : undefined}\n      aria-valuemax={interactive ? data.length - 1 : undefined}\n      aria-valuenow={interactive ? current : undefined}\n      aria-valuetext={interactive ? valueText : undefined}\n      aria-orientation={interactive ? \"horizontal\" : undefined}\n      tabIndex={interactive ? 0 : undefined}\n      onPointerMove={interactive ? (e) => moveTo(fromPointer(e), false) : undefined}\n      onPointerDown={interactive ? (e) => {\n        if (e.pointerType !== \"mouse\") setTouching(true);\n        moveTo(fromPointer(e), true);\n      } : undefined}\n      onPointerLeave={interactive ? () => {\n        setTouching(false);\n        moveTo(null, true);\n      } : undefined}\n      onPointerCancel={interactive ? () => {\n        setTouching(false);\n        moveTo(null, true);\n      } : undefined}\n      // Keyboard focus lands on the latest point, so the value is there before the first arrow press.\n      onFocus={interactive ? (e) => {\n        if (e.currentTarget.matches(\":focus-visible\")) moveTo(lastIndex, true);\n      } : undefined}\n      onBlur={interactive ? () => moveTo(null, true) : undefined}\n      onKeyDown={\n        interactive\n          ? (e) => {\n              const from = index ?? lastIndex;\n              const step = (dir: 1 | -1, start: number) => {\n                for (let i = start + dir; i >= 0 && i < data.length; i += dir) if (geo.points[i]) return i;\n                return start;\n              };\n              let next: number | null | undefined;\n              if (e.key === \"ArrowLeft\" || e.key === \"ArrowDown\") next = index == null ? lastIndex : step(-1, from);\n              else if (e.key === \"ArrowRight\" || e.key === \"ArrowUp\") next = index == null ? lastIndex : step(1, from);\n              else if (e.key === \"Home\") next = firstIndex;\n              else if (e.key === \"End\") next = lastIndex;\n              else if (e.key === \"Escape\" && index != null) next = null;\n              if (next === undefined) return;\n              e.preventDefault();\n              moveTo(next, true);\n            }\n          : undefined\n      }\n      style={{ height, ...style }}\n      className={cn(\n        \"group/spark relative w-full min-w-0 select-none\",\n        resolvedTone === \"neutral\" && \"text-fg-2\",\n        resolvedTone === \"success\" && \"text-success\",\n        resolvedTone === \"danger\" && \"text-danger\",\n        interactive && \"cursor-crosshair touch-pan-y rounded-[3px] outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-4 focus-visible:outline-fg-3\",\n        className,\n      )}\n      {...rest}\n    >\n      <motion.div\n        aria-hidden\n        className=\"absolute inset-0\"\n        initial={false}\n        animate={shown ? { clipPath: \"inset(-4px -4px -4px -4px)\", opacity: 1 } : { clipPath: \"inset(-4px 100% -4px -4px)\", opacity: 0 }}\n        transition={reduce ? { clipPath: { duration: 0 }, opacity: { duration: 0.2 } } : { clipPath: { duration: 0.9, ease: ease.outQuart }, opacity: { duration: 0.2 } }}\n      >\n        <svg viewBox={`0 0 100 ${height}`} preserveAspectRatio=\"none\" className=\"absolute inset-0 size-full overflow-visible\" fill=\"none\">\n          <defs>\n            <linearGradient id={gradient} x1=\"0\" x2=\"0\" y1=\"0\" y2=\"1\">\n              <stop offset=\"0\" stopColor=\"currentColor\" stopOpacity={0.16} />\n              <stop offset=\"1\" stopColor=\"currentColor\" stopOpacity={0} />\n            </linearGradient>\n            <clipPath id={clip}>\n              <motion.rect x={-1} y={-4} height={height + 8} width={clipWidth} />\n            </clipPath>\n          </defs>\n          {empty ? (\n            <line x1={0} x2={100} y1={height / 2} y2={height / 2} className=\"stroke-fg-4\" strokeWidth={1} vectorEffect=\"non-scaling-stroke\" />\n          ) : (\n            <>\n              {variant === \"area\" && (\n                <path d={geo.area} fill={`url(#${gradient})`} className=\"transition-opacity duration-150\" style={{ opacity: scrubbing ? 0.5 : 1 }} />\n              )}\n              {/* While scrubbing, the stretch after the pointer steps back and the part already traveled keeps full ink. */}\n              <path\n                d={geo.line}\n                stroke=\"currentColor\"\n                strokeWidth={1.5}\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n                vectorEffect=\"non-scaling-stroke\"\n                className=\"transition-opacity duration-150\"\n                style={{ opacity: scrubbing ? 0.3 : 1 }}\n              />\n              {scrubbing && (\n                <path\n                  d={geo.line}\n                  clipPath={`url(#${clip})`}\n                  stroke=\"currentColor\"\n                  strokeWidth={1.5}\n                  strokeLinecap=\"round\"\n                  strokeLinejoin=\"round\"\n                  vectorEffect=\"non-scaling-stroke\"\n                />\n              )}\n            </>\n          )}\n        </svg>\n      </motion.div>\n\n      {lastPoint && (\n        <motion.span\n          aria-hidden\n          className=\"pointer-events-none absolute size-[7px] -translate-x-1/2 -translate-y-1/2\"\n          style={{ left: `${lastPoint.x * 100}%`, top: lastPoint.y }}\n          initial={false}\n          animate={shown && !scrubbing ? { opacity: 1, scale: 1 } : { opacity: 0, scale: 0.4 }}\n          transition={\n            reduce\n              ? { opacity: { duration: 0.15, delay: revealed ? 0 : 0.2 }, scale: { duration: 0 } }\n              : shown && !scrubbing\n                ? { ...spring.pop, delay: revealed ? 0 : 0.7 }\n                : { duration: 0.12 }\n          }\n        >\n          {live && <span className=\"absolute inset-0 rounded-full bg-current motion-safe:animate-ping-soft\" />}\n          <span className=\"absolute inset-0 rounded-full bg-current ring-[1.5px] ring-[color:var(--sparkline-surface,var(--raised))]\" />\n        </motion.span>\n      )}\n\n      {/* The scrubber: a hairline, a dot riding the line, and the value above. */}\n      <motion.div\n        aria-hidden\n        className=\"pointer-events-none absolute inset-y-0 w-px -translate-x-1/2 bg-fg/15\"\n        style={{ left }}\n        initial={false}\n        animate={{ opacity: scrubbing ? 1 : 0 }}\n        transition={{ duration: scrubbing ? 0.12 : 0.1 }}\n      />\n      <motion.span\n        aria-hidden\n        className=\"pointer-events-none absolute top-0 -mt-[3.5px] size-[7px] rounded-full bg-current ring-[1.5px] ring-[color:var(--sparkline-surface,var(--raised))]\"\n        style={{ left, y: ry, x: \"-50%\" }}\n        initial={false}\n        animate={{ opacity: scrubbing ? 1 : 0, scale: scrubbing ? 1 : 0.4 }}\n        transition={{ duration: 0.14, ease: ease.out, scale: { duration: reduce ? 0 : 0.14, ease: ease.out } }}\n      />\n      {interactive && (\n        <span id={summaryId} className=\"sr-only\">\n          {summary}\n        </span>\n      )}\n      {tooltip && interactive && (\n        <motion.div\n          aria-hidden\n          className=\"pointer-events-none absolute bottom-full z-(--z-tooltip) pb-1.5\"\n          style={{ left, x: shift }}\n          initial={false}\n          animate={scrubbing ? { opacity: 1, y: 0, scale: 1 } : { opacity: 0, y: 3, scale: 0.96 }}\n          transition={{ duration: scrubbing ? 0.16 : 0.1, ease: ease.out, ...(reduce ? { y: { duration: 0 }, scale: { duration: 0 } } : null) }}\n        >\n          <div\n            className={cn(\n              \"flex items-baseline gap-1.5 whitespace-nowrap rounded-md border border-line-2 bg-raised px-1.5 py-[3px] text-[11px] leading-none shadow-pop\",\n              touching && \"-translate-y-5\",\n            )}\n          >\n            <span className=\"font-medium text-fg tabular\">{index != null && data[index] != null ? fmt(data[index] as number) : \"\"}</span>\n            {index != null && labels?.[index] && <span className=\"text-fg-3\">{labels[index]}</span>}\n          </div>\n        </motion.div>\n      )}\n    </div>\n  );\n}\n\nfunction findLast<T>(list: (T | null)[]) {\n  for (let i = list.length - 1; i >= 0; i--) if (list[i]) return i;\n  return -1;\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/sparkline"
}
