Scriber
A tiny inline sparkline — line, gradient area, or micro-bars — that scores itself in left to right with a bright cooling tip, and can stream live data from the right with a breathing endpoint dot. Pure SVG, no chart library, no axes.
cssfree
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/use-reduced-motion";
import { useVisibilityPause } from "@/registry/default/hooks/use-visibility-pause/use-visibility-pause";
export type ScriberVariant = "line" | "area" | "bars";
export type ScriberEasing = "linear" | "easeIn" | "easeOut" | "easeInOut" | ((t: number) => number);
export interface ScriberColors {
/** Body stroke of the line (and bar fill). @default "#e2e8f0" */
stroke?: string;
/** Gradient fill swept beneath the `area` variant. @default the stroke color */
fill?: string;
/** Bright leading tip of the stroke while it draws. @default "#ffffff" */
tip?: string;
/** Endpoint (latest-value) dot. @default "#f8fafc" */
dot?: string;
/** Faint graphite track the stroke is scored into. @default "rgba(148,163,184,0.16)" */
track?: string;
}
export interface ScriberProps extends Omit<React.ComponentPropsWithoutRef<"span">, "children"> {
/**
* Points to plot, in any numeric range (normalized internally).
* @default a seeded demo series (see `seed`)
*/
data?: number[];
/** Chart geometry: a plain line, a gradient-filled area, or micro-bars. @default "line" */
variant?: ScriberVariant;
/** Stroke width in px. @default 1.5 */
strokeWidth?: number;
/** Direction the area gradient fades: bright at the line fading down, or reversed. @default "down" */
fillDirection?: "down" | "up";
/** Peak opacity of the area gradient where it meets the line. @default 0.22 */
fillOpacity?: number;
/** Seconds the trace takes to score in, left to right, once in view. `0` skips the draw. @default 1.2 */
drawDuration?: number;
/** Easing for the draw: a named curve or a custom `(t) => number`. @default "easeOut" */
drawEasing?: ScriberEasing;
/**
* Live streaming mode: after the initial draw, new points slide in from the
* right while old points slide off the left. Pass `true` for a seeded random
* walk, or a callback `(last, index) => next` generating each point.
* @default false
*/
live?: boolean | ((last: number, index: number) => number);
/** Milliseconds between streamed points in live mode. @default 1000 */
interval?: number;
/** Number of points visible at once in live mode (the stream window). @default 32 */
windowSize?: number;
/** Show the latest-value marker dot (breathes at ~0.5 Hz while live). @default true */
endpointDot?: boolean;
/** Fixed `[min, max]` normalization bounds. @default auto from the visible data */
bounds?: [min: number, max: number];
/** Seed for the deterministic demo series and the default live walk. @default 11 */
seed?: number;
/** Palette overrides — graphite track, ice-white stroke by default. */
colors?: ScriberColors;
/** Freeze all animation (draw and live stream). @default false */
paused?: boolean;
/**
* Visually-hidden trend text for screen readers, e.g. "up 12% over 30 days".
* Without it the sparkline is decorative (`aria-hidden`).
*/
label?: string;
}
/** Length of the bright leading segment, as a fraction of the whole path. */
const HOT = 0.085;
const EASINGS: Record<string, (t: number) => number> = {
linear: (t) => t,
easeIn: (t) => t * t * t,
easeOut: (t) => 1 - Math.pow(1 - t, 3),
easeInOut: (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2),
};
function clamp(v: number, lo: number, hi: number): number {
return Math.min(hi, Math.max(lo, v));
}
function round2(n: number): number {
return Math.round(n * 100) / 100;
}
/** Deterministic PRNG — seeded demo data must never hydration-mismatch. */
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** Seeded demo series: a gentle upward drift for static charts, a flat walk for live. */
function demoSeries(seed: number, n: number, flat: boolean): number[] {
const rnd = mulberry32(seed);
const out: number[] = [];
let v = flat ? 0.5 : 0.32;
for (let i = 0; i < n; i++) {
v = clamp(v + (rnd() - (flat ? 0.5 : 0.44)) * 0.16, 0.06, 0.97);
out.push(round2(v));
}
return out;
}
/**
* Scriber — a tiny inline sparkline that scores itself into the page like a
* scriber cutting a line in metal: one confident left-to-right stroke with a
* bright leading tip that cools to the body color behind it, the gradient fill
* sweeping in beneath. In `live` mode new points stream in from the right while
* old points slide off the left, the endpoint dot breathing at ~0.5 Hz. Pure
* SVG — no axes, no legend, no chart library — sized for sentences, table
* cells, and stat cards. Reduced motion renders the final fully-drawn path with
* a static endpoint dot; rAF runs only while drawing or live, idle-silent
* otherwise, and suspends offscreen.
*/
export function Scriber({
data,
variant = "line",
strokeWidth = 1.5,
fillDirection = "down",
fillOpacity = 0.22,
drawDuration = 1.2,
drawEasing = "easeOut",
live = false,
interval = 1000,
windowSize = 32,
endpointDot = true,
bounds,
seed = 11,
colors,
paused = false,
label,
className,
...props
}: ScriberProps) {
const reducedMotion = useReducedMotion();
const isLive = Boolean(live) && !reducedMotion;
const rawId = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
const gradId = `scriber-grad-${rawId}`;
const clipId = `scriber-clip-${rawId}`;
const stroke = colors?.stroke ?? "#e2e8f0";
const fillColor = colors?.fill ?? stroke;
const tipColor = colors?.tip ?? "#ffffff";
const dotColor = colors?.dot ?? "#f8fafc";
const trackColor = colors?.track ?? "rgba(148,163,184,0.16)";
const dotR = Math.max(2, strokeWidth * 1.3);
// ---- data -----------------------------------------------------------------
const basePoints = React.useMemo(() => {
const src = data && data.length >= 2 ? data : demoSeries(seed, isLive ? Math.max(windowSize, 8) : 24, isLive);
// Live windows are left-padded to a fixed size so the slide geometry never
// has to grow — a short flat lead-in beats a gap at the right edge.
const arr = isLive
? src.length >= windowSize
? src.slice(-windowSize)
: [...Array<number>(windowSize - src.length).fill(src[0]), ...src]
: src;
return arr.length >= 2 ? arr : [arr[0] ?? 0.5, arr[0] ?? 0.5];
}, [data, seed, isLive, windowSize]);
const [streamed, setStreamed] = React.useState<number[] | null>(null);
const points = streamed ?? basePoints;
const pointsRef = React.useRef(points);
pointsRef.current = points;
React.useEffect(() => {
setStreamed(null);
}, [basePoints]);
// ---- sizing (svg has no viewBox: user units === CSS px) --------------------
const [size, setSize] = React.useState({ w: 120, h: 32 });
// ---- geometry ---------------------------------------------------------------
const geo = React.useMemo(() => {
const { w, h } = size;
const pad = Math.ceil(dotR + strokeWidth / 2 + 1);
const innerW = Math.max(1, w - pad * 2);
const innerH = Math.max(1, h - pad * 2);
const visible = isLive ? windowSize : points.length;
const step = innerW / Math.max(1, visible - 1);
let lo: number;
let hi: number;
if (bounds) {
[lo, hi] = bounds;
} else {
lo = Math.min(...points);
hi = Math.max(...points);
}
if (hi - lo < 1e-9) {
lo -= 0.5;
hi += 0.5;
}
const xs = points.map((_, i) => round2(pad + i * step));
const ys = points.map((v) => round2(pad + (1 - clamp((v - lo) / (hi - lo), 0, 1)) * innerH));
const dLine = xs.map((x, i) => `${i === 0 ? "M" : "L"}${x} ${ys[i]}`).join(" ");
const baseY = round2(h - pad);
const dArea = `${dLine} L${xs[xs.length - 1]} ${baseY} L${xs[0]} ${baseY} Z`;
const barW = round2(Math.max(1.25, step * 0.55));
const bars = points.map((_, i) => {
const bh = round2(Math.max(0.75, baseY - ys[i]));
return { x: round2(xs[i] - barW / 2), y: round2(baseY - bh), w: barW, h: bh };
});
return { pad, step, xs, ys, dLine, dArea, baseY, bars };
}, [points, size, bounds, isLive, windowSize, strokeWidth, dotR]);
// ---- animation state --------------------------------------------------------
const [drawn, setDrawn] = React.useState(false);
const isDrawn = drawn || reducedMotion || drawDuration <= 0;
const groupRef = React.useRef<SVGGElement>(null);
const mainPathRef = React.useRef<SVGPathElement>(null);
const hotPathRef = React.useRef<SVGPathElement>(null);
const clipRectRef = React.useRef<SVGRectElement>(null);
const tipDotRef = React.useRef<SVGCircleElement>(null);
const tipHaloRef = React.useRef<SVGCircleElement>(null);
const dotRef = React.useRef<SVGCircleElement>(null);
const dotHaloRef = React.useRef<SVGCircleElement>(null);
const barRefs = React.useRef<(SVGRectElement | null)[]>([]);
const drawDoneRef = React.useRef(false);
const lastAddRef = React.useRef(0);
const lenCacheRef = React.useRef({ d: "", len: 0 });
const liveIndexRef = React.useRef(0);
const walkRef = React.useRef<{ rnd: () => number; lo: number; hi: number; span: number } | null>(null);
const ease = typeof drawEasing === "function" ? drawEasing : (EASINGS[drawEasing] ?? EASINGS.easeOut);
const appendPoint = () => {
const arr = pointsRef.current;
const last = arr[arr.length - 1] ?? 0.5;
let next: number;
if (typeof live === "function") {
next = live(last, liveIndexRef.current++);
} else {
if (!walkRef.current) {
const lo = bounds ? bounds[0] : Math.min(...arr);
const hi = bounds ? bounds[1] : Math.max(...arr);
const span = hi - lo || 1;
walkRef.current = { rnd: mulberry32((seed ^ 0x9e3779b9) >>> 0), lo: lo - span * 0.1, hi: hi + span * 0.1, span };
}
const wk = walkRef.current;
next = clamp(last + (wk.rnd() - 0.5) * wk.span * 0.35, wk.lo, wk.hi);
}
const nextArr = [...arr, next].slice(-(windowSize + 1));
pointsRef.current = nextArr;
setStreamed(nextArr);
};
/** One frame of the score-in: dashoffset frontier, hot tip, fill sweep, bar stagger. */
const drawFrame = (p: number) => {
if (variant === "bars") {
const n = geo.bars.length;
geo.bars.forEach((b, i) => {
const rect = barRefs.current[i];
if (!rect) return;
const bp = clamp((p * (n + 2.4) - i) / 2.4, 0, 1);
rect.setAttribute("y", String(b.y + b.h * (1 - bp)));
rect.setAttribute("height", String(Math.max(0.001, b.h * bp)));
rect.setAttribute("fill-opacity", String(0.3 + 0.6 * bp));
});
return;
}
const main = mainPathRef.current;
main?.setAttribute("stroke-dashoffset", String(1 - p));
const hot = hotPathRef.current;
const midDraw = p > 0.001 && p < 0.995;
if (hot) {
hot.setAttribute("stroke-dashoffset", String(HOT - p));
hot.setAttribute("opacity", midDraw ? "0.9" : "0");
}
clipRectRef.current?.setAttribute("width", String(p * size.w));
if (main && tipDotRef.current) {
if (lenCacheRef.current.d !== geo.dLine) {
lenCacheRef.current = { d: geo.dLine, len: main.getTotalLength() };
}
const pt = main.getPointAtLength(Math.min(0.9995, p) * lenCacheRef.current.len);
tipDotRef.current.setAttribute("cx", String(pt.x));
tipDotRef.current.setAttribute("cy", String(pt.y));
tipDotRef.current.setAttribute("opacity", midDraw ? "1" : "0");
if (tipHaloRef.current) {
tipHaloRef.current.setAttribute("cx", String(pt.x));
tipHaloRef.current.setAttribute("cy", String(pt.y));
tipHaloRef.current.setAttribute("opacity", midDraw ? "0.22" : "0");
}
}
};
const tick = (elapsed: number) => {
// The hook's clock restarts from 0 when `paused` toggles — re-anchor.
if (lastAddRef.current > elapsed) lastAddRef.current = elapsed;
if (!drawDoneRef.current) {
const raw = drawDuration > 0 ? Math.min(1, elapsed / drawDuration) : 1;
drawFrame(ease(clamp(raw, 0, 1)));
if (raw >= 1) {
drawDoneRef.current = true;
lastAddRef.current = elapsed;
setDrawn(true);
// Seed the first incoming point so the window is windowSize + 1 and
// the leftward slide is seamless from the first frame.
if (isLive) appendPoint();
}
return;
}
if (!isLive) return; // loop is paused by state; guard for the final frame
const intervalS = Math.max(0.05, interval / 1000);
const phase = (elapsed - lastAddRef.current) / intervalS;
// Endpoint dot breathes at ~0.5 Hz while data is live.
const breath = Math.sin(elapsed * Math.PI);
dotRef.current?.setAttribute("r", String(dotR * (1 + 0.22 * breath)));
dotHaloRef.current?.setAttribute("opacity", String(0.16 + 0.1 * (0.5 + 0.5 * breath)));
if (phase >= 1) {
appendPoint();
lastAddRef.current = elapsed;
return; // skip the transform this frame; next frame reads fresh geometry
}
groupRef.current?.setAttribute("transform", `translate(${-phase * geo.step} 0)`);
// The dot rides the incoming segment, pinned to the right edge.
const ys = geo.ys;
if (dotRef.current && ys.length >= 2) {
const cy = ys[ys.length - 2] + (ys[ys.length - 1] - ys[ys.length - 2]) * phase;
dotRef.current.setAttribute("cy", String(cy));
dotHaloRef.current?.setAttribute("cy", String(cy));
}
};
// Drives draw + live stream; starts in view, suspends offscreen and on hidden
// tabs, and goes fully idle once a static chart has finished drawing.
const containerRef = useVisibilityPause<HTMLSpanElement>(tick, {
paused: paused || reducedMotion || (drawn && !isLive),
});
React.useLayoutEffect(() => {
const el = containerRef.current;
if (!el) return;
const measure = () => {
const r = el.getBoundingClientRect();
if (r.width > 0 && r.height > 0) {
setSize((s) => (Math.abs(s.w - r.width) < 0.5 && Math.abs(s.h - r.height) < 0.5 ? s : { w: r.width, h: r.height }));
}
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ---- render -------------------------------------------------------------------
const n = points.length;
const dotX = isLive ? size.w - geo.pad : geo.xs[n - 1];
const dotY = isLive ? (geo.ys[n - 2] ?? geo.ys[n - 1]) : geo.ys[n - 1];
return (
<span
ref={containerRef}
data-crucible="scriber"
aria-hidden={label ? undefined : true}
className={cn("relative inline-block h-[1.25em] w-[6em] align-[-0.25em]", className)}
{...props}
>
<svg className="block h-full w-full overflow-hidden" aria-hidden focusable="false">
{variant === "area" && (
<defs>
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor={fillColor} stopOpacity={fillDirection === "down" ? fillOpacity : 0} />
<stop offset="1" stopColor={fillColor} stopOpacity={fillDirection === "down" ? 0 : fillOpacity} />
</linearGradient>
{!isDrawn && (
<clipPath id={clipId}>
<rect ref={clipRectRef} x="0" y="0" width={0} height={size.h} />
</clipPath>
)}
</defs>
)}
{variant === "bars" ? (
<>
<line x1={geo.pad} x2={size.w - geo.pad} y1={geo.baseY} y2={geo.baseY} stroke={trackColor} strokeWidth={1} />
<g ref={groupRef}>
{geo.bars.map((b, i) => (
<rect
key={i}
ref={(el) => {
barRefs.current[i] = el;
}}
x={b.x}
width={b.w}
y={isDrawn ? b.y : geo.baseY}
height={isDrawn ? b.h : 0.001}
rx={Math.min(1, b.w / 2)}
fill={stroke}
fillOpacity={isDrawn ? (i === n - 1 ? 1 : 0.9) : 0}
/>
))}
</g>
</>
) : (
<g ref={groupRef}>
{/* Graphite track the stroke is scored into. */}
<path d={geo.dLine} fill="none" stroke={trackColor} strokeWidth={Math.max(1, strokeWidth - 0.25)} />
{variant === "area" && (
<path d={geo.dArea} fill={`url(#${gradId})`} clipPath={isDrawn ? undefined : `url(#${clipId})`} />
)}
<path
ref={mainPathRef}
d={geo.dLine}
fill="none"
stroke={stroke}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
pathLength={1}
{...(isDrawn ? {} : { strokeDasharray: "1", strokeDashoffset: 1 })}
/>
{/* Freshly-scored heat: a short bright segment cooling to the body color. */}
{!isDrawn && (
<path
ref={hotPathRef}
d={geo.dLine}
fill="none"
stroke={tipColor}
strokeWidth={strokeWidth + 0.5}
strokeLinecap="round"
strokeLinejoin="round"
pathLength={1}
strokeDasharray={`${HOT} 1`}
strokeDashoffset={HOT}
opacity={0}
/>
)}
</g>
)}
{/* Pen tip riding the frontier while the stroke draws. */}
{!isDrawn && variant !== "bars" && (
<>
<circle ref={tipHaloRef} r={dotR * 2.4} fill={tipColor} opacity={0} />
<circle ref={tipDotRef} r={Math.max(1.5, strokeWidth * 0.9 + 0.6)} fill={tipColor} opacity={0} />
</>
)}
{/* Latest-value marker: static once drawn, breathing while live. */}
{endpointDot && isDrawn && (
<>
<circle ref={dotHaloRef} cx={dotX} cy={dotY} r={dotR * 2} fill={dotColor} opacity={isLive ? 0.18 : 0.14} />
<circle ref={dotRef} cx={dotX} cy={dotY} r={dotR} fill={dotColor} />
</>
)}
</svg>
{label ? <span className="sr-only">{label}</span> : null}
</span>
);
}
Installation
CLI
npx shadcn@latest add @crucible/scriberProps
| Prop | Type | Default | Description |
|---|---|---|---|
| data | number[] | a seeded demo series (see seed) | Points to plot, in any numeric range (normalized internally). |
| variant | ScriberVariant | "line" | Chart geometry: a plain line, a gradient-filled area, or micro-bars. |
| strokeWidth | number | 1.5 | Stroke width in px. |
| fillDirection | "down" | "up" | "down" | Direction the area gradient fades: bright at the line fading down, or reversed. |
| fillOpacity | number | 0.22 | Peak opacity of the area gradient where it meets the line. |
| drawDuration | number | 1.2 | Seconds the trace takes to score in, left to right, once in view. 0 skips the draw. |
| drawEasing | ScriberEasing | "easeOut" | Easing for the draw: a named curve or a custom (t) => number. |
| live | boolean | ((last: number, index: number) => number) | false | Live streaming mode: after the initial draw, new points slide in from the right while old points slide off the left. Pass true for a seeded random walk, or a callback (last, index) => next generating each point. |
| interval | number | 1000 | Milliseconds between streamed points in live mode. |
| windowSize | number | 32 | Number of points visible at once in live mode (the stream window). |
| endpointDot | boolean | true | Show the latest-value marker dot (breathes at ~0.5 Hz while live). |
| bounds | [min: number, max: number] | auto from the visible data | Fixed [min, max] normalization bounds. |
| seed | number | 11 | Seed for the deterministic demo series and the default live walk. |
| colors | ScriberColors | Palette overrides — graphite track, ice-white stroke by default. | |
| paused | boolean | false | Freeze all animation (draw and live stream). |
| label | string | Visually-hidden trend text for screen readers, e.g. "up 12% over 30 days". Without it the sparkline is decorative (aria-hidden). | |
Also accepts all props of Omit<React.ComponentPropsWithoutRef<"span">, "children"> — they pass through to the underlying element. | |||
Honors prefers-reduced-motion with a designed static fallback, and passes className through.