{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"live-line-chart","type":"registry:component","title":"Live line chart","description":"One metric as it happens: each point slides in at an even pace, the scale eases, and a Live pill pauses the view.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/number-roll.json?as=dependency","https://hairlineui.com/r/text-morph.json?as=dependency"],"files":[{"path":"registry/live-line-chart.tsx","type":"registry:component","content":"// Hairline UI · live-line-chart · 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\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * LIVE LINE CHART: one metric as it happens\n *\n *   stream    each new point slides in from the right over the\n *             time it took to arrive, so the line moves at an\n *             even pace instead of jumping a step at a time; the\n *             end dot rides the line and pings\n *   scale     when the range changes the line rescales smoothly\n *             under fixed gridlines and the axis figures roll\n *   point     point at the line and the header becomes the\n *             readout: the figure rolls to that moment and\n *             \"now\" morphs to how long ago it was\n *   pause     the Live pill holds the view still while data\n *             keeps coming; press again to catch up\n *   waiting   when points stop coming the pill says so and its\n *             pulse stops\n *   limit     an optional threshold is drawn as a dashed line\n *             with its label riding the scale\n *\n * The line is drawn in value space inside one scaled group, so\n * a new range is a single transform transition; the slide is a\n * second transform on the group around it. No chart library,\n * no frame loop.\n * ───────────────────────────────────────────────────────── */\n\nexport interface LivePoint {\n  /** When, in ms (e.g. Date.now() when it was measured). */\n  t: number;\n  value: number;\n}\n\nexport interface LiveLineChartProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"title\"> {\n  /** Points oldest first; keep appending. Anything older than the window is left out. */\n  data: LivePoint[];\n  /** How much time the chart shows, in ms. */\n  window?: number;\n  /** Over the figure, e.g. \"Requests\". */\n  label?: React.ReactNode;\n  /** After the figure, e.g. \"req/s\". */\n  unit?: string;\n  format?: Intl.NumberFormatOptions;\n  locales?: string | string[];\n  /** A limit drawn as a dashed line, e.g. { value: 1200, label: \"Rate limit\" }. */\n  threshold?: { value: number; label?: string };\n  color?: string;\n  /** Plot height in px. */\n  height?: number;\n  defaultPaused?: boolean;\n  onPausedChange?: (paused: boolean) => void;\n  /** Ms without a new point before the pill says Waiting; defaults to three times the last gap. */\n  staleAfter?: number;\n}\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\nconst AXIS = 44;\nconst COMPACT: Intl.NumberFormatOptions = { notation: \"compact\", maximumFractionDigits: 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\n/** A round step near x: 1, 2, 2.5 or 5 times a power of ten. */\nfunction nice(x: number) {\n  if (x <= 0 || !Number.isFinite(x)) return 1;\n  const p = 10 ** Math.floor(Math.log10(x));\n  const f = x / p;\n  return (f <= 1 ? 1 : f <= 2 ? 2 : f <= 2.5 ? 2.5 : f <= 5 ? 5 : 10) * p;\n}\n\n/** Three round gridlines that hold every value in view, with room above and below. */\nfunction scaleFor(values: number[], extra?: number) {\n  const all = extra === undefined ? values : [...values, extra];\n  if (!all.length) return { lo: 0, hi: 1 };\n  let lo = Math.min(...all);\n  let hi = Math.max(...all);\n  const pad = (hi - lo) * 0.15 || Math.abs(hi) * 0.1 || 1;\n  const step = nice((hi - lo + pad * 2) / 2);\n  lo = Math.floor((lo - pad) / step) * step;\n  hi = Math.ceil((hi + pad) / step) * step;\n  if (Math.min(...all) >= 0) lo = Math.max(0, lo);\n  return { lo, hi: hi > lo ? hi : lo + step };\n}\n\nexport function LiveLineChart({\n  data,\n  window: span = 60_000,\n  label,\n  unit,\n  format = { maximumFractionDigits: 0 },\n  locales,\n  threshold,\n  color = \"var(--chart-1, var(--primary))\",\n  height = 140,\n  defaultPaused = false,\n  onPausedChange,\n  staleAfter,\n  className = \"\",\n  ...props\n}: LiveLineChartProps) {\n  const reduced = useReducedMotion();\n  const gradient = React.useId().replace(/:/g, \"\");\n  const plotRef = React.useRef<HTMLDivElement>(null);\n  const slideRef = React.useRef<SVGGElement>(null);\n  const previous = React.useRef<number | null>(null);\n  const plotLeft = React.useRef(0);\n  const [width, setWidth] = React.useState(0);\n  const [frozen, setFrozen] = React.useState<LivePoint[] | null>(defaultPaused ? data : null);\n  const [staleFor, setStaleFor] = React.useState<number | null>(null);\n  const [hover, setHover] = React.useState<number | null>(null);\n\n  React.useLayoutEffect(() => {\n    const el = plotRef.current;\n    if (!el) return;\n    const observer = new ResizeObserver(([entry]) => {\n      const w = Math.floor(entry.contentRect.width);\n      setWidth((prev) => (prev === w ? prev : w));\n    });\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, []);\n\n  const paused = frozen !== null;\n  const view = frozen ?? data;\n  const latest = view[view.length - 1];\n  const before = view[view.length - 2];\n  const gap = latest && before ? latest.t - before.t : 1000;\n  const plotW = Math.max(0, width - AXIS);\n  const k = plotW / span;\n\n  // The points in view, plus the one before the window so the line reaches the left edge.\n  const firstIn = latest ? view.findIndex((p) => p.t >= latest.t - span) : 0;\n  const shown = latest ? view.slice(Math.max(0, firstIn - 1)) : [];\n  const { lo, hi } = scaleFor(\n    shown.map((p) => p.value),\n    threshold?.value,\n  );\n  const range = hi - lo;\n  const yOf = (v: number) => height - ((v - lo) / range) * height;\n  const xOf = (t: number) => (latest ? plotW - (latest.t - t) * k : 0);\n\n  const line = shown.map((p, i) => `${i ? \"L\" : \"M\"}${xOf(p.t).toFixed(1)} ${p.value}`).join(\"\");\n  const area = shown.length ? `${line}L${xOf(shown[shown.length - 1].t).toFixed(1)} ${lo}L${xOf(shown[0].t).toFixed(1)} ${lo}Z` : \"\";\n\n  // A new point: start the line one step to the right and slide it into place over the gap it took to arrive.\n  React.useLayoutEffect(() => {\n    const g = slideRef.current;\n    const t = latest?.t ?? null;\n    const last = previous.current;\n    previous.current = t;\n    if (!g || t === null || last === null || t <= last || reduced || !k) return;\n    const dt = Math.min(t - last, 4000);\n    g.style.transition = \"none\";\n    g.style.transform = `translateX(${(dt * k).toFixed(2)}px)`;\n    g.getBoundingClientRect();\n    g.style.transition = `transform ${dt}ms linear`;\n    g.style.transform = \"translateX(0px)\";\n  }, [latest?.t, reduced, k]);\n\n  // No new point for a while: say so.\n  const waitFor = staleAfter ?? Math.max(2000, gap * 3);\n  React.useEffect(() => {\n    if (!latest || paused) return;\n    const at = latest.t;\n    const timer = window.setTimeout(() => setStaleFor(at), waitFor);\n    return () => window.clearTimeout(timer);\n  }, [latest, paused, waitFor]);\n  const stale = !paused && latest !== undefined && staleFor === latest.t;\n\n  const togglePause = () => {\n    const next = !paused;\n    setFrozen(next ? data : null);\n    onPausedChange?.(next);\n  };\n\n  const onMove = (e: React.PointerEvent) => {\n    if (!latest) return;\n    // Measured once per visit, not on every move.\n    if (!plotLeft.current) plotLeft.current = e.currentTarget.getBoundingClientRect().left;\n    const x = e.clientX - plotLeft.current - AXIS;\n    const t = latest.t - (plotW - x) / (k || 1);\n    let best = -1;\n    let bestD = Infinity;\n    shown.forEach((p, i) => {\n      const d = Math.abs(p.t - t);\n      if (d < bestD) {\n        bestD = d;\n        best = i;\n      }\n    });\n    const pick = best >= 0 ? shown[best].t : null;\n    setHover((prev) => (prev === pick ? prev : pick));\n  };\n\n  const pointed = hover !== null ? shown.find((p) => p.t === hover) ?? null : null;\n  const start = shown.find((p) => p.t >= (latest?.t ?? 0) - span) ?? shown[0];\n  const change = latest && start && start.value ? (latest.value - start.value) / Math.abs(start.value) : 0;\n  const up = change >= 0;\n  const ago = pointed && latest ? Math.round((latest.t - pointed.t) / 1000) : 0;\n  const caption = pointed ? (ago === 0 ? \"now\" : `${ago}s ago`) : \"now\";\n  const status = paused ? \"Paused\" : stale ? \"Waiting\" : \"Live\";\n  const live = status === \"Live\";\n  const scaleMove = reduced ? \"none\" : `transform 600ms ${EASE}`;\n\n  return (\n    <div className={`w-full rounded-[22px] bg-background shadow-[0_0_0_1px_var(--border)] ${className}`} {...props}>\n      {/* Its natural width where the parent sizes to its content (a centred flex column), without forcing it on narrow screens. */}\n      <div aria-hidden=\"true\" data-natural-width className=\"grid h-0\" style={{ gridTemplateColumns: \"minmax(0, 520px)\" }} />\n      <div className=\"@container w-full\">\n      <div className=\"p-4 @md:p-5\">\n        <div className=\"flex items-start justify-between gap-3\">\n          <div className=\"min-w-0\">\n            {label && <p className=\"text-[12px] text-muted-foreground @md:text-[13px]\">{label}</p>}\n            <p className=\"mt-0.5 flex items-baseline gap-1.5\">\n              <span className=\"text-[26px] font-semibold leading-none tracking-[-0.03em] text-foreground tabular-nums @md:text-[32px]\">\n                <NumberRoll value={pointed ? pointed.value : (latest?.value ?? 0)} format={format} locales={locales} duration={500} />\n              </span>\n              {unit && <span className=\"text-[12px] text-muted-foreground @md:text-[13px]\">{unit}</span>}\n              <span className=\"ml-1 text-[11.5px] text-muted-foreground @md:text-[12px]\">\n                <TextMorph duration={320}>{caption}</TextMorph>\n              </span>\n            </p>\n            <p className=\"mt-1.5 flex items-center gap-1 text-[11.5px] text-muted-foreground @md:text-[12px]\">\n              <span className={`inline-flex items-center gap-0.5 font-medium tabular-nums ${up ? \"text-emerald-600 dark:text-emerald-400\" : \"text-red-600 dark:text-red-400\"}`}>\n                <svg aria-hidden=\"true\" viewBox=\"0 0 10 10\" className=\"size-2.5\" style={{ transform: up ? \"none\" : \"rotate(180deg)\", transition: reduced ? \"none\" : `transform 400ms ${EASE}` }}>\n                  <path d=\"M5 1.5 9 8.5H1z\" fill=\"currentColor\" />\n                </svg>\n                <NumberRoll value={Math.abs(change)} format={{ style: \"percent\", maximumFractionDigits: 1 }} duration={600} />\n              </span>\n              <span>over the last {Math.round(span / 1000)}s</span>\n            </p>\n          </div>\n\n          <button\n            type=\"button\"\n            aria-pressed={paused}\n            aria-label={paused ? \"Resume live updates\" : \"Pause live updates\"}\n            onClick={togglePause}\n            className={`flex h-7 shrink-0 cursor-pointer items-center gap-1.5 rounded-full px-2.5 text-[12px] font-medium text-foreground shadow-[inset_0_0_0_1px_var(--border)] transition-colors hover:bg-accent ${FOCUS}`}\n          >\n            <span aria-hidden=\"true\" className=\"relative flex size-2 items-center justify-center\">\n              {live && !reduced && <span className=\"absolute inset-0 animate-[ui-ping_1.6s_ease-out_infinite] rounded-full bg-emerald-500\" />}\n              <span className={`relative size-2 rounded-full transition-colors duration-300 ${live ? \"bg-emerald-500\" : stale ? \"bg-amber-500\" : \"bg-muted-foreground\"}`} />\n            </span>\n            <TextMorph duration={360}>{status}</TextMorph>\n          </button>\n        </div>\n\n        <div\n          ref={plotRef}\n          className=\"relative mt-4 w-full touch-pan-y\"\n          style={{ height: height + 22 }}\n          onPointerMove={onMove}\n          onPointerLeave={() => {\n            plotLeft.current = 0;\n            setHover(null);\n          }}\n        >\n          {/* Axis figures: fixed places, rolling values. */}\n          {[hi, (hi + lo) / 2, lo].map((v, i) => (\n            <span key={i} aria-hidden=\"true\" className=\"absolute left-0 -translate-y-1/2 text-[10.5px] text-muted-foreground tabular-nums @md:text-[11px]\" style={{ top: (height / 2) * i }}>\n              <NumberRoll value={v} format={COMPACT} locales={locales} duration={600} />\n            </span>\n          ))}\n\n          <svg aria-hidden=\"true\" width={plotW} height={height} className=\"absolute top-0 overflow-hidden\" style={{ left: AXIS }}>\n            <defs>\n              <linearGradient id={gradient} x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n                {/* The area is drawn in value space, flipped: offset 0 is the bottom of the plot. */}\n                <stop offset=\"0%\" stopColor={color} stopOpacity=\"0\" />\n                <stop offset=\"100%\" stopColor={color} stopOpacity=\"0.24\" />\n              </linearGradient>\n            </defs>\n            {[0, 0.5, 1].map((f) => (\n              <line key={f} x1={0} x2={plotW} y1={f * height} y2={f * height} stroke=\"var(--border)\" strokeWidth={1} shapeRendering=\"crispEdges\" />\n            ))}\n            <g ref={slideRef}>\n              {/* Drawn in value space; the range is one transform, so a new range eases instead of jumping. */}\n              <g style={{ transform: `translateY(${height}px) scaleY(${-height / range}) translateY(${-lo}px)`, transformOrigin: \"0 0\", transition: scaleMove }}>\n                <path d={area} fill={`url(#${gradient})`} />\n                {threshold && (\n                  <line x1={-plotW} x2={plotW * 2} y1={threshold.value} y2={threshold.value} className=\"stroke-amber-500 dark:stroke-amber-400\" strokeWidth={1.5} strokeDasharray=\"5 4\" vectorEffect=\"non-scaling-stroke\" />\n                )}\n                <path d={line} fill=\"none\" stroke={color} strokeWidth={2} strokeLinejoin=\"round\" strokeLinecap=\"round\" vectorEffect=\"non-scaling-stroke\" />\n              </g>\n              {pointed && (\n                <g>\n                  <line x1={xOf(pointed.t)} x2={xOf(pointed.t)} y1={0} y2={height} stroke=\"var(--foreground)\" strokeOpacity={0.25} strokeWidth={1} />\n                  <circle cx={xOf(pointed.t)} cy={yOf(pointed.value)} r={4} fill=\"var(--background)\" stroke={color} strokeWidth={2} />\n                </g>\n              )}\n            </g>\n          </svg>\n\n          {/* The newest point: a dot that rides the line's right end, pinging while live. */}\n          {latest && plotW > 0 && (\n            <span\n              aria-hidden=\"true\"\n              className=\"absolute size-2 -translate-x-1/2 -translate-y-1/2 rounded-full\"\n              style={{\n                left: AXIS + plotW,\n                top: yOf(latest.value),\n                background: color,\n                boxShadow: \"0 0 0 2px var(--background)\",\n                transition: reduced ? \"none\" : `top ${Math.min(gap, 4000)}ms linear`,\n              }}\n            >\n              {live && !reduced && <span className=\"absolute inset-0 animate-[ui-ping_1.6s_ease-out_infinite] rounded-full\" style={{ background: color }} />}\n            </span>\n          )}\n\n          {threshold && (\n            <span\n              aria-hidden=\"true\"\n              className=\"absolute right-0 -translate-y-full pb-0.5 text-[10.5px] font-medium text-amber-600 tabular-nums dark:text-amber-400\"\n              style={{ top: yOf(threshold.value), transition: reduced ? \"none\" : `top 600ms ${EASE}` }}\n            >\n              {threshold.label ?? \"Limit\"}\n            </span>\n          )}\n\n          <div aria-hidden=\"true\" className=\"absolute inset-x-0 bottom-0 flex justify-between text-[10.5px] text-muted-foreground @md:text-[11px]\" style={{ paddingLeft: AXIS }}>\n            <span>−{Math.round(span / 1000)}s</span>\n            <span>−{Math.round(span / 2000)}s</span>\n            <span>now</span>\n          </div>\n        </div>\n\n        <p className=\"sr-only\" aria-live=\"off\">\n          {latest ? `Latest ${new Intl.NumberFormat(locales ?? \"en-US\", format).format(latest.value)}${unit ? ` ${unit}` : \"\"}, ${up ? \"up\" : \"down\"} ${Math.round(Math.abs(change) * 1000) / 10}% over the last ${Math.round(span / 1000)} seconds.` : \"No data yet.\"}\n        </p>\n      </div>\n      </div>\n    </div>\n  );\n}\n"}],"css":{"@keyframes ui-ping":{"from":{"transform":"scale(1)","opacity":"0.55"},"to":{"transform":"scale(2.6)","opacity":"0"}}},"docs":"Docs and live preview: https://hairlineui.com/ui/live-line-chart. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}