# Scriber — Animated Data Visualization for React

> 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.

- Page: https://crucibleui.com/components/data/scriber
- Category: [data](https://crucibleui.com/components/data)
- Tier: free (full source)
- Engine: css

## Install

```bash
npx shadcn@latest add @crucible/scriber
```

Registry JSON: https://crucibleui.com/r/scriber.json

Import from `@/components/crucible/scriber`.

## Props (`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: `Omit<React.ComponentPropsWithoutRef<"span">, "children">`.

## Usage example

```tsx
import { Scriber } from "@/components/crucible/scriber";

const CLIMB = [12, 14, 13.2, 15.1, 16.4, 15.8, 17.6, 18.9, 18.1, 19.8, 21.4, 20.6, 22.9, 24.5, 23.8, 26.2];
const CHOP = [42, 47, 39, 51, 44, 55, 48, 61, 53, 58, 50, 64, 57, 68, 62, 71];
const STEPS = [3, 5, 4, 7, 6, 9, 8, 7, 10, 12, 11, 14, 13, 16, 15, 18];

export default function ScriberDemo() {
  return (
    <div className="relative isolate flex h-full min-h-[360px] w-full items-center justify-center overflow-hidden rounded-xl bg-neutral-950">
      <div className="relative z-10 flex w-full max-w-sm flex-col gap-4 px-6">
        <p className="text-sm leading-relaxed text-neutral-400">
          A sparkline that lives inline — this trend{" "}
          <Scriber data={CLIMB} label="upward trend" className="mx-0.5" /> scores itself in the moment it scrolls into
          view.
        </p>

        <div className="flex items-center justify-between rounded-lg border border-white/5 bg-white/[0.02] px-4 py-3">
          <div>
            <p className="text-[10px] font-medium uppercase tracking-[0.2em] text-neutral-500">Area sweep</p>
            <p className="text-lg font-medium text-white">71.4k</p>
          </div>
          <Scriber variant="area" data={CHOP} className="h-10 w-32" label="volatile upward trend" />
        </div>

        <div className="flex items-center justify-between rounded-lg border border-white/5 bg-white/[0.02] px-4 py-3">
          <div>
            <p className="text-[10px] font-medium uppercase tracking-[0.2em] text-neutral-500">Micro-bars</p>
            <p className="text-lg font-medium text-white">18 / day</p>
          </div>
          <Scriber variant="bars" data={STEPS} drawDuration={1.5} className="h-10 w-32" label="steadily rising counts" />
        </div>

        <div className="flex items-center justify-between rounded-lg border border-white/5 bg-white/[0.02] px-4 py-3">
          <div>
            <p className="text-[10px] font-medium uppercase tracking-[0.2em] text-neutral-500">Live stream</p>
            <p className="text-lg font-medium text-white">Watch the dot breathe</p>
          </div>
          <Scriber
            variant="area"
            live
            interval={900}
            windowSize={24}
            seed={5}
            colors={{ stroke: "#7dd3fc", tip: "#e0f2fe", dot: "#bae6fd" }}
            className="h-10 w-32"
            label="live streaming values"
          />
        </div>

        <p className="text-center text-[11px] font-medium uppercase tracking-[0.2em] text-neutral-600">
          Draws on view · live mode streams from the right
        </p>
      </div>
    </div>
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through.

## Source (`scriber.tsx`)

```tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/hooks/use-reduced-motion";
import { useVisibilityPause } from "@/hooks/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) => {
    // Defensive re-anchor in case the clock ever restarts below our last mark.
    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

    // Live switched on after a static draw (or the window was resized): top the
    // buffer up to windowSize + 1 first so the slide never exposes a gap.
    if (pointsRef.current.length <= windowSize) {
      appendPoint();
      lastAddRef.current = elapsed;
      return;
    }

    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>
  );
}
```
