{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sparkline",
  "title": "Sparkline",
  "description": "An inline SVG trend line — a sparkline — that shows the shape of a series in about the space of a line of text. Use it when you need a chart small enough to live inside something else: a 7-day or 30-day trend next to a KPI in a stat card or dashboard tile, a per-row usage or activity graph in a table (requests, spend, errors, signups, page views), a mini price or metric history, a tiny “last N days” graph in a list item, or any micro / inline / thumbnail chart where axes, gridlines, a legend and a tooltip would just be noise. It renders as a plain <svg> with no hooks, no state and no effects, so it works unchanged inside a React Server Component, in a static export, and with JavaScript disabled — there is no “use client” in the file. Different from shadcn/ui’s official chart, which is a ~10KB wrapper around Recharts (it declares recharts@2.15.4 as a dependency and also pulls in card) meant for full charts with axes, tooltips and legends: this is one zero-dependency file that draws a single path and needs nothing but your cn util. Different from gauge and progress-ring, which draw one current value as an arc rather than a series over time. It handles the parts hand-written sparklines get wrong: null, undefined and NaN entries are treated as gaps that keep their slot on the x axis and break the line, instead of being dropped (which slides the rest of the series sideways) or drawn as zero (which invents a crash that is not in the data); a flat series is centred rather than dividing by zero and emitting a NaN path that silently renders nothing at all; the plot area is inset by half the stroke so the highest and lowest points are not sliced in half by the viewport edge; vector-effect=“non-scaling-stroke” keeps the line an even weight when the SVG is stretched across a wide table cell, and the last-value dot is drawn as a round line cap so it stays a circle instead of being squashed into an ellipse by that same stretch. Pass min and max to pin the scale so a whole column of sparklines is actually comparable — autoscale every row to its own extremes and they all end up looking like the same shape. It also ships an aria-label generated from the data (“12 points, up from 3 to 91, low 3, high 94”), where shadcn’s own chart.tsx sets no role=“img” or aria-label of its own; pass your own aria-label to override it, or aria-hidden when a surrounding stat card already announces the number. Props: data, width, height, min, max, strokeWidth, area, showLast, formatValue.",
  "files": [
    {
      "path": "registry/ui/sparkline.tsx",
      "content": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\ntype Datum = number | null | undefined\n\ninterface Point {\n  x: number\n  y: number\n}\n\nfunction isNum(value: Datum): value is number {\n  return typeof value === \"number\" && Number.isFinite(value)\n}\n\nfunction clamp(n: number, lo: number, hi: number) {\n  return Math.min(hi, Math.max(lo, n))\n}\n\n/**\n * Dimensions arrive from a JS call site where the prop types no longer apply. A\n * NaN or negative one would reach the viewBox and blank the whole graphic, so a\n * nonsense size falls back to the default rather than rendering nothing.\n */\nfunction sizeOr(n: number, fallback: number) {\n  return Number.isFinite(n) && n > 0 ? n : fallback\n}\n\n/**\n * Two decimals is below one device pixel at any size a sparkline is drawn at,\n * and it keeps the `d` attribute short. Rounding also makes the markup byte\n * identical on the server and the client, so hydration never sees a diff.\n */\nfunction fmt(n: number) {\n  return String(Math.round(n * 100) / 100)\n}\n\n/**\n * Deterministic on purpose: `toLocaleString` / `Intl` resolve against the host\n * locale, so the server and the browser can render different accessible names\n * for the same data and React warns about the mismatch.\n */\nfunction formatNumber(value: number) {\n  return Number.isInteger(value) ? String(value) : String(Math.round(value * 100) / 100)\n}\n\n/** Min and max in one pass — `Math.min(...values)` overflows the stack on long series. */\nfunction extent(values: number[]) {\n  let lo = Infinity\n  let hi = -Infinity\n  for (const v of values) {\n    if (v < lo) lo = v\n    if (v > hi) hi = v\n  }\n  return { lo, hi }\n}\n\n/**\n * The value range the plot area maps onto. Explicit `min`/`max` win so that a\n * column of sparklines can share one scale — without that, every row is\n * autoscaled to its own extremes and they all look like the same shape.\n */\nfunction resolveDomain(values: number[], min?: number, max?: number) {\n  const measured = values.length ? extent(values) : { lo: 0, hi: 0 }\n  let lo = isNum(min) ? min : measured.lo\n  let hi = isNum(max) ? max : measured.hi\n  if (hi < lo) {\n    const swap = lo\n    lo = hi\n    hi = swap\n  }\n  return { lo, hi }\n}\n\n/** True when some finite value has no finite neighbour, so a line can't reach it. */\nfunction hasIsolatedPoint(data: ReadonlyArray<Datum>) {\n  for (let i = 0; i < data.length; i++) {\n    if (!isNum(data[i])) continue\n    if (!isNum(data[i - 1]) && !isNum(data[i + 1])) return true\n  }\n  return false\n}\n\n/**\n * Split the series into runs of consecutive finite values and project each one\n * into the padded plot box. Gaps keep their horizontal slot, so a missing day\n * leaves a hole rather than shifting the rest of the series left.\n */\nfunction buildRuns(\n  data: ReadonlyArray<Datum>,\n  box: { width: number; height: number; pad: number; lo: number; hi: number }\n) {\n  const { width, height, pad, lo, hi } = box\n  const left = pad\n  const right = Math.max(pad, width - pad)\n  const top = pad\n  const bottom = Math.max(pad, height - pad)\n  const span = hi - lo\n  const runs: Point[][] = []\n  let run: Point[] = []\n\n  for (let i = 0; i < data.length; i++) {\n    const value = data[i]\n    if (!isNum(value)) {\n      if (run.length) runs.push(run)\n      run = []\n      continue\n    }\n    // A single point has no span to sit in, so it goes in the middle.\n    const t = data.length === 1 ? 0.5 : i / (data.length - 1)\n    // A flat series (or a plot box too short to hold the stroke) carries no\n    // vertical information; centring it beats dividing by zero and emitting NaN.\n    const f = span > 0 ? (clamp(value, lo, hi) - lo) / span : 0.5\n    run.push({ x: left + (right - left) * t, y: bottom - (bottom - top) * f })\n  }\n  if (run.length) runs.push(run)\n  return runs\n}\n\nfunction linePath(runs: Point[][]) {\n  let d = \"\"\n  for (const run of runs) {\n    // Lone points are drawn as dots instead — a one-point subpath draws nothing.\n    if (run.length < 2) continue\n    d += `M${fmt(run[0].x)},${fmt(run[0].y)}`\n    for (let i = 1; i < run.length; i++) d += `L${fmt(run[i].x)},${fmt(run[i].y)}`\n  }\n  return d\n}\n\nfunction areaPath(runs: Point[][], baseline: number) {\n  let d = \"\"\n  for (const run of runs) {\n    if (run.length < 2) continue\n    d += `M${fmt(run[0].x)},${fmt(baseline)}`\n    for (const p of run) d += `L${fmt(p.x)},${fmt(p.y)}`\n    d += `L${fmt(run[run.length - 1].x)},${fmt(baseline)}Z`\n  }\n  return d\n}\n\n/**\n * A zero-length subpath with a round cap. A `<circle>` would be squashed into\n * an ellipse, because `preserveAspectRatio=\"none\"` scales x and y differently;\n * this dot is drawn by the stroke, which `vector-effect` keeps circular.\n */\nfunction dotPath(points: Point[]) {\n  let d = \"\"\n  for (const p of points) d += `M${fmt(p.x)},${fmt(p.y)}L${fmt(p.x)},${fmt(p.y)}`\n  return d\n}\n\n/**\n * Everything the component draws, as one pure function of its props. Keeping\n * the geometry out of the render body is what makes the maths testable without\n * a DOM — every trap this component exists to avoid lives in here.\n */\nfunction buildSparkline(opts: {\n  data: ReadonlyArray<Datum>\n  width: number\n  height: number\n  strokeWidth: number\n  area: boolean\n  showLast: boolean\n  min?: number\n  max?: number\n}) {\n  const series: ReadonlyArray<Datum> = Array.isArray(opts.data) ? opts.data : []\n  const values: number[] = []\n  for (const v of series) if (isNum(v)) values.push(v)\n\n  const width = sizeOr(opts.width, 120)\n  const height = sizeOr(opts.height, 32)\n  const strokeWidth = sizeOr(opts.strokeWidth, 2)\n\n  const { lo, hi } = resolveDomain(values, opts.min, opts.max)\n  const dotRadius = Math.max(strokeWidth * 1.5, 1)\n  const drawsDots = opts.showLast || hasIsolatedPoint(series)\n  // Inset by whatever bleeds furthest, so the round cap on a point sitting at\n  // the very top or bottom of the scale is not sliced in half by the viewport.\n  const pad = Math.max(strokeWidth / 2, drawsDots ? dotRadius : 0)\n\n  const runs = buildRuns(series, { width, height, pad, lo, hi })\n  const lone = runs.filter((run) => run.length === 1).map((run) => run[0])\n  const lastRun = runs[runs.length - 1]\n  let last = opts.showLast && lastRun ? lastRun[lastRun.length - 1] : undefined\n  // When the newest value is itself an isolated point it is already in `lone`,\n  // and drawing it twice would put a redundant subpath in the markup.\n  if (last && lone.includes(last)) last = undefined\n\n  return {\n    values,\n    // Normalised sizes travel back out so the viewBox and the rendered stroke\n    // agree with the geometry they were measured against.\n    width,\n    height,\n    strokeWidth,\n    dotRadius,\n    line: linePath(runs),\n    fill: opts.area ? areaPath(runs, Math.max(pad, height - pad)) : \"\",\n    dots: dotPath(last ? [...lone, last] : lone),\n  }\n}\n\nfunction describeSeries(values: number[], format: (value: number) => string) {\n  if (!values.length) return \"No data\"\n  const last = values[values.length - 1]\n  if (values.length === 1) return `1 point, ${format(last)}`\n  const first = values[0]\n  const { lo, hi } = extent(values)\n  const direction = last > first ? \"up\" : last < first ? \"down\" : \"flat\"\n  return `${values.length} points, ${direction} from ${format(first)} to ${format(\n    last\n  )}, low ${format(lo)}, high ${format(hi)}`\n}\n\ninterface SparklineProps\n  extends Omit<React.ComponentPropsWithoutRef<\"svg\">, \"children\"> {\n  /**\n   * The series, oldest first. `null` / `undefined` / `NaN` are treated as gaps:\n   * they keep their slot on the x axis and break the line instead of being\n   * dropped or drawn as zero.\n   */\n  data: ReadonlyArray<Datum>\n  /** Intrinsic width in pixels (default 120). CSS wins, e.g. `className=\"w-full\"`. */\n  width?: number\n  /** Intrinsic height in pixels (default 32). */\n  height?: number\n  /**\n   * Pin the bottom of the scale. Pass `min`/`max` to every sparkline in a table\n   * so their shapes are comparable; values outside the range are clamped.\n   */\n  min?: number\n  /** Pin the top of the scale. */\n  max?: number\n  /** Line thickness in pixels, unaffected by stretching (default 2). */\n  strokeWidth?: number\n  /** Fill the area under the line at low opacity (default false). */\n  area?: boolean\n  /** Mark the most recent value with a dot (default false). */\n  showLast?: boolean\n  /** Format numbers in the generated accessible name (default: plain, up to 2 decimals). */\n  formatValue?: (value: number) => string\n}\n\nexport const Sparkline = React.forwardRef<SVGSVGElement, SparklineProps>(\n  function Sparkline(\n    {\n      className,\n      data,\n      width = 120,\n      height = 32,\n      min,\n      max,\n      strokeWidth = 2,\n      area = false,\n      showLast = false,\n      formatValue,\n      \"aria-label\": ariaLabel,\n      \"aria-hidden\": ariaHidden,\n      ...props\n    },\n    ref\n  ) {\n    const {\n      values,\n      width: w,\n      height: h,\n      strokeWidth: sw,\n      dotRadius,\n      line,\n      fill,\n      dots,\n    } = buildSparkline({\n      data,\n      width,\n      height,\n      strokeWidth,\n      area,\n      showLast,\n      min,\n      max,\n    })\n\n    // An explicit aria-hidden means the caller already names this elsewhere\n    // (a stat card that reads out the number, say) — don't announce it twice.\n    const decorative = ariaHidden !== undefined && ariaHidden !== false && ariaHidden !== \"false\"\n\n    return (\n      <svg\n        ref={ref}\n        width={w}\n        height={h}\n        viewBox={`0 0 ${w} ${h}`}\n        // Sparklines stretch to their container; the default (`meet`) would\n        // letterbox the line in the middle of a wide table cell instead.\n        preserveAspectRatio=\"none\"\n        role={decorative ? undefined : \"img\"}\n        aria-label={\n          decorative\n            ? undefined\n            : ariaLabel ?? describeSeries(values, formatValue ?? formatNumber)\n        }\n        aria-hidden={ariaHidden}\n        // `overflow-visible` is the backstop for the case `pad` can't cover: when\n        // CSS squashes the box below its intrinsic height, one user unit of\n        // padding renders as less than one pixel of stroke.\n        className={cn(\"overflow-visible text-primary\", className)}\n        {...props}\n      >\n        {fill ? (\n          <path d={fill} fill=\"currentColor\" fillOpacity={0.15} stroke=\"none\" />\n        ) : null}\n        {line ? (\n          <path\n            d={line}\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth={sw}\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            // Without this the stroke is scaled with the box: stretch a\n            // sparkline across a wide cell and it turns into a wedge.\n            vectorEffect=\"non-scaling-stroke\"\n          />\n        ) : null}\n        {dots ? (\n          <path\n            d={dots}\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth={dotRadius * 2}\n            strokeLinecap=\"round\"\n            vectorEffect=\"non-scaling-stroke\"\n          />\n        ) : null}\n      </svg>\n    )\n  }\n)\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}