{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sparkline",
  "type": "registry:ui",
  "title": "Sparkline",
  "description": "A trend in one thin line, with no axes, gridlines or labels: a 2px line in the foreground or a data hue, a dot on the latest value and an optional dashed baseline. For a stat tile, a table cell or a list row. It shows shape, not values.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/sparkline.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/sparkline 2026.10.26. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Sparkline: a trend in one thin line, with no axes, no gridlines and no labels. It sits beside a figure (a Stat's\n * `trend` slot) or in a table row, and shows shape, not values: put the exact figure next to it. A sibling of the\n * LineChart and the Meter, drawn the way the Meter is (R7: a hue is a thread through neutral space).\n *\n * - Marks: a 2px line with round joins and caps, and a dot on the latest point so today is the strongest mark, as in\n *   the Chart. The dot is 4px in radius (8px across) on a 2px ring in the surface color, so it reads on a busy row and\n *   the line does not run into it. No area fill, no gradient, no glow (R2).\n * - Color: the line is the quiet foreground by default (`--litt-fg` at 55%) and the dot is `--litt-fg`. With a `hue` the line is\n *   the hue's mark tone (3:1 on every surface) and the dot its `-text` tone, a step stronger. One hue means one\n *   quantity (R7); keep it the same wherever that quantity is drawn.\n * - Scale: by default each sparkline fits its own values, with 2px of air above and below the line. Pass `min` and\n *   `max` to fix the scale, so the sparklines in a column compare. A value outside a fixed scale is held at its edge.\n *   A flat series (or a single value) sits in the middle. `baseline` is a reference value (a goal, last month's\n *   average) drawn as a 1px dashed `--litt-line-strong` line across the full width; the default scale includes it, so it\n *   is always in the box.\n * - Drawn at the container's width, measured with a ResizeObserver (as the Chart is), so the 2px line and the dot stay\n *   crisp and round: nothing is scaled and `preserveAspectRatio` never stretches it. Before the first measure (and on\n *   the server) it renders an empty box of the final height, so nothing jumps. The width comes from the container;\n *   the height is the caller's. The SVG is positioned inside the box and adds nothing to its size, so a box in a\n *   table cell or a flex row can still shrink: give that box a width (a `w-32` wrapper), because a cell sizes itself\n *   from its content and an empty box has none.\n * - Accessibility: the SVG is `role=\"img\"` named by a summary (\"Median build time, last 14 days: from 44 s to 41 s,\n *   low 37 s, high 46 s\", from `formatValue`). It is not interactive: no tab stop, no tooltip. Because it shows shape\n *   and not values, the exact figure belongs beside it, in a Stat or a table cell.\n * - Motion: none. New data redraws at once. In fun mode (motion/fun.tsx, a trial, off unless a <FunMode> is on) the\n *   line draws itself on arrival at a steady hand and the dot lands as the pen reaches it; each then hands over to the\n *   plain mark, so at rest it is the static Sparkline exactly. Reduced motion turns it off.\n */\nimport { useLayoutEffect, useRef, useState } from \"react\";\nimport { motion } from \"motion/react\";\nimport { useFun } from \"../../lib/litt/motion\";\n\n/** The five data hues, in their fixed order (R7). */\nexport type SparklineHue = \"blue\" | \"amber\" | \"teal\" | \"violet\" | \"rose\";\n\n/** The surface the sparkline sits on, which the dot's ring matches. */\ntype Surface = \"canvas\" | \"surface-1\" | \"surface-2\";\n\n/* Literal class names, so Tailwind sees them: the line in the mark tone, the dot in the stronger -text tone. */\nconst HUE: Record<SparklineHue, { line: string; dot: string }> = {\n  blue: { line: \"stroke-litt-data-blue\", dot: \"fill-litt-data-blue-text\" },\n  amber: { line: \"stroke-litt-data-amber\", dot: \"fill-litt-data-amber-text\" },\n  teal: { line: \"stroke-litt-data-teal\", dot: \"fill-litt-data-teal-text\" },\n  violet: { line: \"stroke-litt-data-violet\", dot: \"fill-litt-data-violet-text\" },\n  rose: { line: \"stroke-litt-data-rose\", dot: \"fill-litt-data-rose-text\" },\n};\n/* The quiet default: the line at fg 55% and the dot at full fg, as the single-series Chart does. */\nconst QUIET = { line: \"stroke-[color-mix(in_oklab,var(--litt-fg)_55%,transparent)]\", dot: \"fill-litt-fg\" };\nconst RING: Record<Surface, string> = { canvas: \"fill-litt-canvas\", \"surface-1\": \"fill-litt-surface-1\", \"surface-2\": \"fill-litt-surface-2\" };\n\nconst STROKE = 2; // the line's width\nconst DOT = 4; // the dot's radius: 8px across\nconst GAP = 2; // the ring around the dot\nconst AIR = 2; // between the line's edge and the box, above and below\nconst DASH = \"4 3\";\n\nconst groupDigits = (n: number) => n.toLocaleString(\"en-GB\", { maximumFractionDigits: 2 });\nconst round = (n: number) => Math.round(n * 100) / 100;\n\n/** The container's width, measured before paint and kept current. 0 until the first measure. */\nfunction useWidth() {\n  const ref = useRef<HTMLDivElement>(null);\n  const [width, setWidth] = useState(0);\n  useLayoutEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const read = () => setWidth(Math.floor(el.getBoundingClientRect().width));\n    read();\n    if (typeof ResizeObserver === \"undefined\") return;\n    const ro = new ResizeObserver(read);\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n  return [ref, width] as const;\n}\n\nexport type SparklineProps = {\n  /** What it shows, for the summary that names the image: \"Median build time, last 14 days\". */\n  label: string;\n  /** The values, oldest first. Zero or more; the last one is the latest and carries the dot. Numbers that are not finite are ignored. */\n  values: number[];\n  /** Draws the line in one of the five data hues. Leave it out for the quiet foreground, which is right for a single quantity. */\n  hue?: SparklineHue;\n  /** Height of the box in px. The width is the container's. */\n  height?: number;\n  /** Turns a value into text for the summary: `(v) => \\`${v} s\\``. The default groups thousands. */\n  formatValue?: (value: number) => string;\n  /** A reference value, such as a goal or last month's average, drawn as a 1px dashed line across the full width. The default scale includes it. */\n  baseline?: number;\n  /** The value at the bottom of the box. Fix it, with `max`, so sparklines in a column compare. Default: the lowest value. */\n  min?: number;\n  /** The value at the top of the box. Fix it, with `min`, so sparklines in a column compare. Default: the highest value. */\n  max?: number;\n  /** Replaces the generated summary that names the image, such as \"Median build time, last 14 days: from 44 s to 41 s, low 37 s, high 46 s\". */\n  summary?: string;\n  /** The surface the sparkline sits on, which the ring behind the dot matches. Cards, tiles and table rows are surface-1. */\n  surface?: Surface;\n  /** Extra classes for the box, such as a top margin. */\n  className?: string;\n};\n\n/** A trend in one 2px line with a dot on the latest value. Static, not interactive: put the exact figure beside it. */\nexport function Sparkline({\n  label,\n  values,\n  hue,\n  height = 32,\n  formatValue,\n  baseline,\n  min,\n  max,\n  summary,\n  surface = \"surface-1\",\n  className = \"\",\n}: SparklineProps) {\n  const [boxRef, width] = useWidth();\n  const fmt = formatValue ?? groupDigits;\n  const data = values.filter(Number.isFinite);\n  const n = data.length;\n  const goal = baseline !== undefined && Number.isFinite(baseline) ? baseline : undefined;\n\n  /* The scale: fixed ends win, else the values (and the baseline, so it is always in the box). */\n  const extent = goal === undefined ? data : [...data, goal];\n  const lo = min ?? (extent.length ? Math.min(...extent) : 0);\n  const hi = max ?? (extent.length ? Math.max(...extent) : 0);\n\n  const top = AIR + STROKE / 2;\n  const plotH = Math.max(0, height - 2 * top);\n  const y = (v: number) => {\n    const t = hi > lo ? Math.max(0, Math.min(1, (v - lo) / (hi - lo))) : 0.5;\n    return round(top + (1 - t) * plotH);\n  };\n  /* The line starts half a stroke in and ends a dot's radius short of the edge, so neither is cut. */\n  const left = STROKE / 2;\n  const right = Math.max(left, width - DOT);\n  const x = (i: number) => round(n > 1 ? left + (i * (right - left)) / (n - 1) : right);\n\n  const text =\n    summary ??\n    (n === 0\n      ? `${label}: no data`\n      : n === 1\n        ? `${label}: ${fmt(data[0]!)}`\n        : `${label}: from ${fmt(data[0]!)} to ${fmt(data[n - 1]!)}, low ${fmt(Math.min(...data))}, high ${fmt(Math.max(...data))}` +\n          (goal === undefined ? \"\" : `, baseline ${fmt(goal)}`));\n\n  const tone = hue ? HUE[hue] : QUIET;\n  /* Fun mode: on arrival the line draws itself at a steady hand and the dot lands as the pen reaches it. Each hands\n     over to the plain mark once done (a drawn path keeps a dash pattern that antialiases its caps a hair differently). */\n  const fun = useFun();\n  const [drawing, setDrawing] = useState(() => !!fun);\n  const [landing, setLanding] = useState(() => !!fun);\n  const land = fun ? { ...fun.pop, delay: data.length > 1 ? fun.draw.duration : 0 } : undefined;\n  const line = data.map((v, i) => `${i === 0 ? \"M\" : \"L\"}${x(i)} ${y(v)}`).join(\" \");\n  /* A 1px line is crisp on the half pixel. */\n  const goalY = goal === undefined ? 0 : Math.round(y(goal)) + 0.5;\n\n  return (\n    <div ref={boxRef} className={\"relative w-full min-w-0 \" + className} style={{ height }}>\n      {width > 0 && (\n        <svg width={width} height={height} role=\"img\" aria-label={text} className=\"absolute top-0 left-0 block overflow-visible\">\n          {goal !== undefined && (\n            <line\n              x1={0}\n              x2={width}\n              y1={goalY}\n              y2={goalY}\n              strokeWidth={1}\n              strokeDasharray={DASH}\n              shapeRendering=\"crispEdges\"\n              className=\"stroke-litt-line-strong\"\n            />\n          )}\n          {n > 1 &&\n            (fun && drawing ? (\n              <motion.path\n                d={line}\n                fill=\"none\"\n                strokeWidth={STROKE}\n                strokeLinejoin=\"round\"\n                strokeLinecap=\"round\"\n                className={tone.line}\n                initial={{ pathLength: 0 }}\n                animate={{ pathLength: 1 }}\n                transition={fun.draw}\n                onAnimationComplete={() => setDrawing(false)}\n              />\n            ) : (\n              <path d={line} fill=\"none\" strokeWidth={STROKE} strokeLinejoin=\"round\" strokeLinecap=\"round\" className={tone.line} />\n            ))}\n          {n > 0 &&\n            (fun && landing ? (\n              <motion.g\n                style={{ originX: `${x(n - 1)}px`, originY: `${y(data[n - 1]!)}px` }}\n                initial={{ scale: 0 }}\n                animate={{ scale: 1 }}\n                transition={land}\n                onAnimationComplete={() => setLanding(false)}\n              >\n                <circle cx={x(n - 1)} cy={y(data[n - 1]!)} r={DOT + GAP} stroke=\"none\" className={RING[surface]} />\n                <circle cx={x(n - 1)} cy={y(data[n - 1]!)} r={DOT} stroke=\"none\" className={tone.dot} />\n              </motion.g>\n            ) : (\n              <>\n                <circle cx={x(n - 1)} cy={y(data[n - 1]!)} r={DOT + GAP} stroke=\"none\" className={RING[surface]} />\n                <circle cx={x(n - 1)} cy={y(data[n - 1]!)} r={DOT} stroke=\"none\" className={tone.dot} />\n              </>\n            ))}\n        </svg>\n      )}\n    </div>\n  );\n}\n",
      "target": "components/litt/sparkline.tsx"
    }
  ],
  "docs": "Import Sparkline from components/litt/sparkline. Pass a label and the values, oldest first. It fills its container's width, so give a table cell a wrapper with a width, and fix min and max so rows compare. Put the exact figure beside it. Stat takes one in its trend prop.",
  "meta": {
    "version": "2026.10.26",
    "released": "2026-10-26",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
