{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"sparkline","type":"registry:component","title":"Sparkline","description":"An ultra-compact inline trendline with magnetic scrubber and auto-trend color for table cells and stat cards.","author":"Hairline UI","dependencies":[],"files":[{"path":"registry/sparkline.tsx","type":"registry:component","content":"// Hairline UI · sparkline · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { NumberRoll } from \"./number-roll\";\n\n/* ─────────────────────────────────────────────────────────\n * SPARKLINE: an ultra-compact inline trendline with scrubber\n *\n *   path       smooth or linear SVG stroke with an optional\n *              subtle gradient fill that fades to transparent\n *   scrubber   pointer movement magnetically tracks the nearest\n *              point, showing a vertical hairline indicator,\n *              a snapped dot, and a micro-tooltip readout\n *   auto-trend colors dynamically: emerald for positive change,\n *              rose for negative, or follows your primary token\n *   table-safe zero layout shift, designed to fit inside a single\n *              data-table cell, stat strip, or card header\n * ───────────────────────────────────────────────────────── */\n\nexport type SparklinePoint = number | { value: number; date?: string; label?: string };\n\nexport interface SparklineProps extends React.HTMLAttributes<HTMLDivElement> {\n  data: SparklinePoint[];\n  width?: number | string;\n  height?: number;\n  /** Stroke color mode: \"auto\" (green if up, red if down), \"primary\", or custom class. */\n  color?: \"auto\" | \"primary\" | \"muted\" | string;\n  /** Render style: line only or with gradient area fill. */\n  variant?: \"line\" | \"area\" | \"bars\";\n  /** Shows the interactive magnetic scrubber and micro-tooltip on hover. */\n  showScrubber?: boolean;\n  /** Highlights the latest (last) data point with a persistent dot when not hovering. */\n  showLastPoint?: boolean;\n  /** Stroke line width. Defaults to 1.35px hairline. */\n  strokeWidth?: number;\n  /** Value format for tooltip. */\n  format?: Intl.NumberFormatOptions;\n  prefix?: string;\n  suffix?: string;\n  /** Custom accessible label for screen readers. */\n  label?: string;\n}\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\nfunction parsePoint(p: SparklinePoint): { val: number; date?: string; label?: string } {\n  if (typeof p === \"number\") return { val: p };\n  return { val: p.value, date: p.date, label: p.label };\n}\n\nexport function Sparkline({\n  data,\n  width = \"100%\",\n  height = 32,\n  color = \"auto\",\n  variant = \"area\",\n  showScrubber = true,\n  showLastPoint = true,\n  strokeWidth = 1.35,\n  format,\n  prefix,\n  suffix,\n  label = \"Metric trend\",\n  className = \"\",\n  style,\n  ...rest\n}: SparklineProps) {\n  const uid = React.useId();\n  const gradId = `spark-grad-${uid.replace(/:/g, \"\")}`;\n  const reduced = useReducedMotion();\n  const containerRef = React.useRef<HTMLDivElement>(null);\n\n  const [hoverIndex, setHoverIndex] = React.useState<number | null>(null);\n  const [containerWidth, setContainerWidth] = React.useState<number>(100);\n\n  // Parse points\n  const points = React.useMemo(() => data.map(parsePoint), [data]);\n  const values = React.useMemo(() => points.map((p) => p.val), [points]);\n\n  // Track container width via ResizeObserver\n  React.useLayoutEffect(() => {\n    const el = containerRef.current;\n    if (!el) return;\n    const ro = new ResizeObserver((entries) => {\n      for (const entry of entries) {\n        const w = entry.contentRect.width;\n        if (w > 0) setContainerWidth(w);\n      }\n    });\n    ro.observe(el);\n    setContainerWidth(el.offsetWidth || 100);\n    return () => ro.disconnect();\n  }, []);\n\n  const minVal = values.length ? Math.min(...values) : 0;\n  const maxVal = values.length ? Math.max(...values) : 1;\n  const range = maxVal - minVal || 1;\n\n  // Compute trend color\n  const isPositive = values.length >= 2 ? values[values.length - 1] >= values[0] : true;\n  const colorClass = React.useMemo(() => {\n    if (color === \"auto\") {\n      return isPositive\n        ? \"text-emerald-600 dark:text-emerald-400\"\n        : \"text-rose-600 dark:text-rose-400\";\n    }\n    if (color === \"primary\") return \"text-primary\";\n    if (color === \"muted\") return \"text-muted-foreground\";\n    return color;\n  }, [color, isPositive]);\n\n  // Coordinates\n  const padY = 4;\n  const usableH = Math.max(1, height - padY * 2);\n  const n = points.length;\n\n  const coords = React.useMemo(() => {\n    if (n === 0) return [];\n    if (n === 1) return [{ x: containerWidth / 2, y: height / 2 }];\n\n    return points.map((p, i) => {\n      const x = (i / (n - 1)) * containerWidth;\n      const normalizedY = (p.val - minVal) / range;\n      const y = height - padY - normalizedY * usableH;\n      return { x, y };\n    });\n  }, [containerWidth, height, minVal, n, padY, points, range, usableH]);\n\n  // Build SVG path strings\n  const { linePath, areaPath } = React.useMemo(() => {\n    if (coords.length < 2) return { linePath: \"\", areaPath: \"\" };\n\n    // Smooth bezier curve generator\n    let d = `M ${coords[0].x.toFixed(1)} ${coords[0].y.toFixed(1)}`;\n    for (let i = 0; i < coords.length - 1; i++) {\n      const p0 = coords[Math.max(0, i - 1)];\n      const p1 = coords[i];\n      const p2 = coords[i + 1];\n      const p3 = coords[Math.min(coords.length - 1, i + 2)];\n\n      const cp1x = p1.x + (p2.x - p0.x) / 6;\n      const cp1y = p1.y + (p2.y - p0.y) / 6;\n      const cp2x = p2.x - (p3.x - p1.x) / 6;\n      const cp2y = p2.y - (p3.y - p1.y) / 6;\n\n      d += ` C ${cp1x.toFixed(1)} ${cp1y.toFixed(1)}, ${cp2x.toFixed(1)} ${cp2y.toFixed(1)}, ${p2.x.toFixed(1)} ${p2.y.toFixed(1)}`;\n    }\n\n    const last = coords[coords.length - 1];\n    const first = coords[0];\n    const area = `${d} L ${last.x.toFixed(1)} ${height} L ${first.x.toFixed(1)} ${height} Z`;\n\n    return { linePath: d, areaPath: area };\n  }, [coords, height]);\n\n  // Pointer interactions for magnetic scrubber\n  const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {\n    if (!showScrubber || n < 2 || !containerRef.current) return;\n    const rect = containerRef.current.getBoundingClientRect();\n    const relX = e.clientX - rect.left;\n    const ratio = Math.max(0, Math.min(1, relX / rect.width));\n    const nearestIdx = Math.round(ratio * (n - 1));\n    setHoverIndex(nearestIdx);\n  };\n\n  const handlePointerLeave = () => {\n    setHoverIndex(null);\n  };\n\n  const activeIdx = hoverIndex;\n  const activeCoord = activeIdx !== null && coords[activeIdx] ? coords[activeIdx] : null;\n  const activePoint = activeIdx !== null && points[activeIdx] ? points[activeIdx] : null;\n  const lastCoord = coords.length ? coords[coords.length - 1] : null;\n\n  return (\n    <div\n      ref={containerRef}\n      role=\"img\"\n      aria-label={label}\n      onPointerDown={handlePointerMove}\n      onPointerMove={handlePointerMove}\n      onPointerLeave={handlePointerLeave}\n      className={`relative inline-flex items-center overflow-visible select-none touch-pan-y ${colorClass} ${className}`}\n      style={{\n        width,\n        height,\n        ...style,\n      }}\n      {...rest}\n    >\n      <svg\n        width={containerWidth}\n        height={height}\n        className=\"overflow-visible\"\n        style={{ display: \"block\" }}\n      >\n        <defs>\n          <linearGradient id={gradId} x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n            <stop offset=\"0%\" stopColor=\"currentColor\" stopOpacity=\"0.18\" />\n            <stop offset=\"100%\" stopColor=\"currentColor\" stopOpacity=\"0.0\" />\n          </linearGradient>\n        </defs>\n\n        {/* Variant Area Fill */}\n        {variant === \"area\" && areaPath && (\n          <path\n            d={areaPath}\n            fill={`url(#${gradId})`}\n            className=\"transition-opacity duration-300\"\n          />\n        )}\n\n        {/* Variant Bars */}\n        {variant === \"bars\" && (\n          <g>\n            {coords.map((c, i) => {\n              const barW = Math.max(2, Math.min(8, (containerWidth / n) * 0.55));\n              const barH = height - c.y;\n              const isHovered = activeIdx === i;\n              return (\n                <rect\n                  key={i}\n                  x={c.x - barW / 2}\n                  y={c.y}\n                  width={barW}\n                  height={Math.max(2, barH)}\n                  rx={1.5}\n                  fill=\"currentColor\"\n                  opacity={isHovered ? 1 : 0.45}\n                  className=\"transition-[opacity,height] duration-200\"\n                />\n              );\n            })}\n          </g>\n        )}\n\n        {/* Stroke Line */}\n        {variant !== \"bars\" && linePath && (\n          <path\n            d={linePath}\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth={strokeWidth}\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            style={{\n              transition: reduced ? \"none\" : \"d 300ms cubic-bezier(0.16,1,0.3,1)\",\n            }}\n          />\n        )}\n\n        {/* Persistent Last Point dot */}\n        {showLastPoint && lastCoord && activeIdx === null && variant !== \"bars\" && (\n          <circle\n            cx={lastCoord.x}\n            cy={lastCoord.y}\n            r={2.25}\n            fill=\"currentColor\"\n            className=\"transition-transform duration-200\"\n          />\n        )}\n\n        {/* Scrubber Vertical Line & Snapped Dot */}\n        {activeCoord && showScrubber && (\n          <g className=\"transition-opacity duration-150\">\n            {/* 1px Vertical Guide Hairline */}\n            <line\n              x1={activeCoord.x}\n              y1={0}\n              x2={activeCoord.x}\n              y2={height}\n              stroke=\"var(--border)\"\n              strokeWidth={1}\n              strokeDasharray=\"2 2\"\n            />\n\n            {/* Glowing Scrubber Dot */}\n            <circle\n              cx={activeCoord.x}\n              cy={activeCoord.y}\n              r={3.25}\n              fill=\"var(--background)\"\n              stroke=\"currentColor\"\n              strokeWidth={1.75}\n            />\n          </g>\n        )}\n      </svg>\n\n      {/* Floating Micro-Tooltip */}\n      {activeCoord && activePoint && showScrubber && (\n        <div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute -top-8 z-30 flex -translate-x-1/2 flex-col items-center whitespace-nowrap rounded-md border border-border bg-popover px-1.5 py-0.5 text-popover-foreground shadow-[0_1px_4px_rgba(0,0,0,0.06)] dark:shadow-none animate-in fade-in-0 zoom-in-95 duration-100\"\n          style={{\n            left: `${activeCoord.x}px`,\n          }}\n        >\n          <div className=\"flex items-center gap-1 font-mono text-[11px] font-medium leading-none\">\n            {prefix && <span>{prefix}</span>}\n            <span>{activePoint.val.toLocaleString()}</span>\n            {suffix && <span>{suffix}</span>}\n          </div>\n          {activePoint.date && (\n            <span className=\"mt-0.5 text-[9.5px] text-muted-foreground leading-none\">\n              {activePoint.date}\n            </span>\n          )}\n        </div>\n      )}\n\n      {/* Screen reader summary */}\n      <span className=\"sr-only\">\n        {label}: start {values[0] ?? 0}, end {values[values.length - 1] ?? 0}, {isPositive ? \"upward trend\" : \"downward trend\"}.\n      </span>\n    </div>\n  );\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/sparkline. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}