Live Line
NewInstallation
npx shadcn@latest add https://uibrik.com/r/live-line.jsonInstall the packages
npm install @number-flow/react @tabler/icons-react motion react-use-measureAdd the cn helper
Imports
cnfrom@/lib/utils, whichshadcn initcreates. Add it by hand without shadcn.Copy the source into your project
components/uibrik/live-line/live-line.tsx"use client"; import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type PointerEvent } from "react"; import { animate, motion, useMotionValue } from "motion/react"; import { useReducedMotion } from "../_shared/use-reduced-motion"; import { cn } from "@/lib/utils"; import { Announce, ChartCard, DataTable, Headline, Tooltip, useChartCursor, useWidth } from "../_charts/parts"; import { areaPath, clamp, extent, linear, linePath, niceTicks, type Point } from "../_charts/scale"; import { accentVars, AREA, DOT, GRID, LINE, SETTLE, STATUS, seriesColor } from "../_charts/theme"; import { formatCompact, formatValue, type ValueFormat } from "../_charts/format"; import { seeded } from "../_charts/sample"; const HEIGHT = 160; // Left-hand room for the tick labels, as in the area chart, so the line runs // all the way to the right edge where new readings arrive. const GUTTER = 36; // Room above and below the line for the dot and its ring. const TOP = DOT + 6; const BOTTOM = DOT + 6; // ─── Sample traffic ─────────────────────────────────────────────────────────── const SEED = 21; const BASE = 140; /** One more visitor count: drifts back toward a typical level, with noise and the odd burst. */ function nextVisitors(prev: number, rand: () => number) { const pull = (BASE - prev) * 0.12; const noise = (rand() + rand() + rand() - 1.5) * 14; const burst = rand() < 0.04 ? prev * (0.25 + rand() * 0.3) : 0; return Math.max(0, Math.round(prev + pull + noise + burst)); } /** `count` seeded samples, the same on the server and the client. */ function simulate(count: number) { const rand = seeded(SEED); const out: number[] = []; let v = BASE; for (let i = 0; i < count; i++) out.push((v = nextVisitors(v, rand))); return out; } type Sample = number | null; /** The buffer cut or padded to `length`, newest last. Pads with history, or gaps when controlled. */ function fit(buf: readonly Sample[], length: number, controlled: boolean): Sample[] { if (buf.length >= length) return buf.slice(buf.length - length); const pad = controlled ? Array<Sample>(length - buf.length).fill(null) : simulate(length - buf.length); return [...pad, ...buf]; } /** * Height of the monotone curve `linePath` draws, at `x`. Lets the head dot * ride the line exactly while it scrolls between two samples. */ function curveY(pts: readonly Point[], x: number) { const n = pts.length; if (n < 2) return pts[0]?.[1] ?? NaN; let k = 0; while (k < n - 2 && pts[k + 1][0] < x) k++; const slope = (i: number) => (pts[i + 1][1] - pts[i][1]) / (pts[i + 1][0] - pts[i][0] || 1); const tangent = (i: number) => { if (i === 0) return slope(0); if (i === n - 1) return slope(n - 2); const a = slope(i - 1); const b = slope(i); const d0 = pts[i][0] - pts[i - 1][0]; const d1 = pts[i + 1][0] - pts[i][0]; return a * b <= 0 ? 0 : (3 * (d0 + d1)) / ((2 * d1 + d0) / a + (d1 + 2 * d0) / b); }; const [x0, y0] = pts[k]; const [x1, y1] = pts[k + 1]; const h = (x1 - x0) / 3; // The control points sit at thirds, so x is linear in t. const t = clamp((x - x0) / (x1 - x0 || 1), 0, 1); const c0 = y0 + h * tangent(k); const c1 = y1 - h * tangent(k + 1); const u = 1 - t; return u * u * u * y0 + 3 * u * u * t * c0 + 3 * u * t * t * c1 + t * t * t * y1; } /** 12000 -> "12s ago", 95000 -> "1m 35s ago". */ function ago(ms: number) { const s = Math.round(ms / 100) / 10; if (s <= 0) return "now"; if (s < 60) return `${s}s ago`; const m = Math.floor(s / 60); const rest = Math.round(s - m * 60); return rest ? `${m}m ${rest}s ago` : `${m}m ago`; } export type LiveLineProps = { /** Names the metric. */ title?: string; /** Latest reading. Sampled once every `interval`. Leave out to show simulated traffic. */ value?: number; /** Samples across the plot. */ points?: number; /** Time between samples, in ms. */ interval?: number; /** Intl.NumberFormat options for every number. */ format?: ValueFormat; /** Sits beside the headline, e.g. "visitors". */ unit?: string; /** Stops sampling. Leave out to let the Live button manage it. */ paused?: boolean; onPausedChange?: (paused: boolean) => void; /** Line colour. Empty uses the chart palette's first colour. */ accent?: string; className?: string; }; export function LiveLine({ title = "Visitors right now", value, points = 60, interval = 1000, format = {}, unit = "visitors", paused, onPausedChange, accent = "", className, }: LiveLineProps) { const reduce = useReducedMotion(); const ids = useId(); const clipId = `${ids}-clip`; const gradient = `${ids}-wash`; const [ref, width] = useWidth(); const controlled = value !== undefined; // `points` intervals are visible, plus one sample off each edge, so the line // can enter and leave without a seam. const length = points + 2; const [buffer, setBuffer] = useState<Sample[]>(() => value === undefined ? simulate(length) : [...Array<Sample>(length - 2).fill(null), value, value], ); const samples = useMemo(() => fit(buffer, length, controlled), [buffer, length, controlled]); const [ownPaused, setOwnPaused] = useState(false); const isPaused = paused ?? ownPaused; const setPaused = (next: boolean) => { if (paused === undefined) setOwnPaused(next); onPausedChange?.(next); }; // A hidden tab stops sampling, so timers never pile up in the background. const [hidden, setHidden] = useState(false); useEffect(() => { const sync = () => setHidden(document.visibilityState === "hidden"); sync(); document.addEventListener("visibilitychange", sync); return () => document.removeEventListener("visibilitychange", sync); }, []); const cursor = useChartCursor(points); const { active } = cursor; // Scrubbing freezes the window, so the point under the pointer stays put. const running = !isPaused && !hidden && active === null; // ─── Scale ────────────────────────────────────────────────────────────────── const ticks = useMemo(() => { const values = samples.filter((v): v is number => v !== null); const [lo, hi] = values.length ? extent(values) : [0, 1]; const pad = (hi - lo) * 0.15 || Math.abs(hi) * 0.1 || 1; return niceTicks(Math.max(lo >= 0 ? 0 : -Infinity, lo - pad), hi + pad, 2); }, [samples]); const t0 = ticks[0]; const t1 = ticks[ticks.length - 1]; // The domain glides to its new range, so a spike never jolts the line. const lo = useMotionValue(t0); const hi = useMotionValue(t1); useEffect(() => { const opts = reduce ? { duration: 0 } : SETTLE; const a = animate(lo, t0, opts); const b = animate(hi, t1, opts); return () => { a.stop(); b.stop(); }; }, [t0, t1, lo, hi, reduce]); // ─── Drawing ──────────────────────────────────────────────────────────────── // How far the line has scrolled toward the next sample, 0 to 1. Drawn // straight to the DOM every frame instead of through React. const progress = useMotionValue(0); const lineRef = useRef<SVGPathElement>(null); const areaRef = useRef<SVGPathElement>(null); const scrollRef = useRef<SVGGElement>(null); const headRef = useRef<SVGGElement>(null); const crossRef = useRef<SVGGElement>(null); const crossDotRef = useRef<SVGGElement>(null); const tickRefs = useRef(new Map<number, SVGGElement>()); const state = useRef({ samples, width, reduce, active }); const draw = useCallback(() => { const s = state.current; if (!s.width) return; const right = s.width - GUTTER; const n = s.samples.length - 2; const dx = right / n; const p = s.reduce ? 0 : progress.get(); const y = linear([lo.get(), hi.get()], [HEIGHT - BOTTOM, TOP]); const pts: Point[] = []; s.samples.forEach((v, i) => v !== null && pts.push([i * dx, y(v)])); lineRef.current?.setAttribute("d", linePath(pts)); areaRef.current?.setAttribute("d", areaPath(pts, HEIGHT)); scrollRef.current?.setAttribute("transform", `translate(${-p * dx},0)`); const hy = curveY(pts, right + p * dx); headRef.current?.setAttribute("transform", `translate(${right},${Number.isFinite(hy) ? hy : -99})`); for (const [v, el] of tickRefs.current) el.setAttribute("transform", `translate(0,${y(v)})`); const at = s.active !== null ? s.samples[s.active + 1] : null; if (at != null && s.active !== null) { crossRef.current?.setAttribute("transform", `translate(${(s.active + 1) * dx},0)`); crossDotRef.current?.setAttribute("transform", `translate(0,${y(at)})`); } }, [progress, lo, hi]); useEffect(() => { const offs = [progress.on("change", draw), lo.on("change", draw), hi.on("change", draw)]; return () => offs.forEach((off) => off()); }, [progress, lo, hi, draw]); // A new sample shifts the data one step left; resetting the scroll in the // same frame hides the swap, so the line never jumps. useLayoutEffect(() => { progress.jump(0); }, [buffer, progress]); useLayoutEffect(() => { state.current = { samples, width, reduce, active }; draw(); }); // ─── Clock ────────────────────────────────────────────────────────────────── const valueRef = useRef(value); const randRef = useRef<(() => number) | null>(null); useEffect(() => { valueRef.current = value; }, [value]); useEffect(() => { if (!running) return; // A linear tween lasting one interval, picking up where a pause left it. const tween = animate(progress, 1, { duration: (Math.max(0, 1 - progress.get()) * interval) / 1000, ease: "linear", onComplete: () => { let next: Sample; if (valueRef.current !== undefined) next = valueRef.current; else { randRef.current ??= seeded(SEED + 1); next = nextVisitors(samples[samples.length - 1] ?? BASE, randRef.current); } setBuffer([...samples.slice(1), next]); }, }); return () => tween.stop(); }, [running, samples, interval, progress]); // ─── Reading ──────────────────────────────────────────────────────────────── const fmt = (v: number) => formatValue(v, format); const color = seriesColor(0); const now = samples[length - 1] ?? 0; const recent = samples.slice(1).filter((v): v is number => v !== null); const average = recent.length ? recent.reduce((a, b) => a + b, 0) / recent.length : 0; const ageOf = (k: number) => ago((points + 1 - k) * interval); const span = ago(points * interval); const right = Math.max(0, width - GUTTER); const dx = right / points; const activeValue = active !== null ? samples[active + 1] : null; const tip = active !== null && activeValue != null && width ? { x: GUTTER + (active + 1 - (reduce ? 0 : progress.get())) * dx, y: linear([lo.get(), hi.get()], [HEIGHT - BOTTOM, TOP])(activeValue), } : null; const onPointerMove = (e: PointerEvent<HTMLDivElement>) => { if (!dx) return; const x = e.clientX - e.currentTarget.getBoundingClientRect().left - GUTTER; const p = reduce ? 0 : progress.get(); cursor.point(clamp(Math.round(x / dx + p) - 1, 0, points - 1)); }; const pulse = running && !reduce; return ( <ChartCard title={title} value={<Headline value={now} format={format} />} meta={ // A live count swings every second, so a red/green change pill would // flicker. The window's average gives the same context, calmly. <span className="text-[13px] text-muted-foreground"> {unit} {unit && average ? " · " : ""} {average ? `avg ${fmt(Math.round(average))}` : ""} </span> } aside={ <button type="button" aria-pressed={!isPaused} aria-label="Live updates" onClick={() => setPaused(!isPaused)} className="flex h-6 cursor-pointer items-center gap-1.5 rounded-[10px] bg-foreground/5 px-2 text-xs font-medium text-muted-foreground outline-none transition-colors duration-150 hover:bg-foreground/8 hover:text-foreground focus-visible:ring-2 focus-visible:ring-foreground/20" > <span className="relative flex size-1.5" aria-hidden> {!isPaused && !reduce && ( <motion.span className="absolute inset-0 rounded-full" style={{ background: STATUS.good }} animate={{ scale: [1, 2.8], opacity: [0.5, 0] }} transition={{ duration: 1.6, repeat: Infinity, ease: "easeOut" }} /> )} <span className={cn("relative size-1.5 rounded-full transition-colors duration-200", isPaused && "bg-muted-foreground")} style={isPaused ? undefined : { background: STATUS.good }} /> </span> {isPaused ? "Paused" : "Live"} </button> } className={cn("max-w-120", className)} style={accentVars(accent)} > <div ref={ref} role="group" aria-label={`${title}, last ${span.replace(" ago", "")}. Arrow keys move between samples.`} className="relative cursor-crosshair touch-none rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-foreground/20" style={{ height: HEIGHT }} onPointerMove={onPointerMove} onPointerDown={onPointerMove} {...cursor.bind} > {width > 0 && ( <svg width={width} height={HEIGHT} className="block overflow-visible" aria-hidden> <defs> <clipPath id={clipId}> <rect x={0} y={-DOT} width={right} height={HEIGHT + DOT * 2} /> </clipPath> <linearGradient id={gradient} x1="0" x2="0" y1="0" y2="1"> <stop offset="0" stopColor={color} stopOpacity={AREA / 100} /> <stop offset="1" stopColor={color} stopOpacity={0} /> </linearGradient> </defs> {ticks.map((t) => ( <g key={t} ref={(el) => { if (el) tickRefs.current.set(t, el); else tickRefs.current.delete(t); }} > <line x1={GUTTER} x2={width} stroke={GRID} strokeWidth={1} shapeRendering="crispEdges" /> <text x={GUTTER - 10} dy="0.32em" textAnchor="end" className="fill-muted-foreground text-[11px] font-medium tabular-nums"> {formatCompact(t, format)} </text> </g> ))} {/* The plot, shifted past the tick labels. */} <g transform={`translate(${GUTTER},0)`}> <g clipPath={`url(#${clipId})`}> <g ref={scrollRef}> <path ref={areaRef} fill={`url(#${gradient})`} /> <path ref={lineRef} fill="none" stroke={color} strokeWidth={LINE} strokeLinecap="round" strokeLinejoin="round" /> {active !== null && activeValue != null && ( <g ref={crossRef}> <line x1={0} x2={0} y1={0} y2={HEIGHT} className="stroke-foreground/15" strokeWidth={1} /> </g> )} </g> </g> {/* The point under the cursor, outside the clip so its ring is never cut. */} {active !== null && activeValue != null && ( <g transform={`translate(${-(reduce ? 0 : progress.get()) * dx},0)`}> <g transform={`translate(${(active + 1) * dx},0)`}> <g ref={crossDotRef}> <circle r={DOT} fill={color} className="stroke-background" strokeWidth={2} /> {cursor.keyboard && <circle r={DOT + 4} fill="none" className="stroke-foreground/40" strokeWidth={2} />} </g> </g> </g> )} {/* The head: where the newest reading enters. Its ring pulses only while live. */} <g ref={headRef}> {pulse && ( <motion.circle fill={color} initial={{ r: DOT, opacity: 0.35 }} animate={{ r: DOT + 8, opacity: 0 }} transition={{ duration: 1.6, repeat: Infinity, ease: "easeOut" }} /> )} <circle r={DOT} fill={color} className="stroke-background" strokeWidth={2} /> </g> </g> </svg> )} {tip && activeValue != null && ( <Tooltip x={tip.x} y={tip.y - DOT - 4} bounds={width} visible> <span className="opacity-60">{active !== null && ageOf(active + 1)}</span> · {fmt(activeValue)} </Tooltip> )} </div> <div className="mt-2 flex justify-between text-[11px] font-medium text-muted-foreground tabular-nums" style={{ paddingLeft: GUTTER }} aria-hidden> <span>{span}</span> <span>now</span> </div> <Announce>{active !== null && activeValue != null ? `${ageOf(active + 1)}: ${fmt(activeValue)}` : ""}</Announce> <DataTable caption={title} columns={["Time", title]} rows={samples.slice(1).flatMap((v, k) => (v === null ? [] : [[ageOf(k + 1), fmt(v)]]))} /> </ChartCard> ); }
Adds the files to
components/uibrik/live-line and installs @number-flow/react, @tabler/icons-react, motion and react-use-measure. With the @uibrik namespace set up: npx shadcn@latest add @uibrik/live-line.Props
LiveLine- title
- Type string
- "Visitors right now"
- Names the metric.
- interval
- Type number
- 1000
- Time between samples.
- points
- Type number
- 60
- Samples across the plot.
- unit
- Type string
- "visitors"
- Sits beside the headline.
- accent
- Type string
- ""
- Line colour. Empty uses the chart palette.
- value
- Type number
- –
- Latest reading, sampled once every interval. Leave out to show simulated traffic.
- format
- Type Intl.NumberFormatOptions
- {}
- Formats every number, e.g. { style: "percent" }.
- paused
- Type boolean
- –
- Stops sampling. Leave out to let the Live button manage it.
- onPausedChange
- Type (paused: boolean) => void
- –
- Called when the Live button is pressed.
- className
- Type string
- –
- Classes for the outer shell.