Meter Latency Capillary

Loading

Time-to-first-token rendered as calibrated capillary rise — a narrow tube climbs to a scribed p50 line, holds and trembles if it's running slow, and only surfaces retry/switch-model once it's genuinely past p95.

Install
npx shadcn add https://design.helpmarq.com/r/meter-latency-capillary.json
Source
registry/core/meter-latency-capillary/component.tsx
"use client";

import { useEffect, useRef, useState } from "react";

// ---------------------------------------------------------------------------
// MeniscusHold — time-to-first-token rendered as calibrated capillary rise.
// A narrow tube fills toward a scribed line marking the historical p50
// first-token latency for this model/tool combo; the rise is eased so the
// meniscus reaches that line exactly when a response typically lands. It
// never fakes progress past what it can justify: reaching the p50 line with
// nothing having arrived doesn't keep climbing toward "done" — the liquid
// HOLDS there and trembles (an honest "still waiting, later than usual"
// signal) while a second scribe line for p95 fades in above it. Past p95 the
// tremble stops — trembling forever would itself be a lie about how confident
// the wait still is — and a stall affordance (retry / switch model) surfaces.
// First token landing (`arrivedAt` set) drains the tube fast, ease-out-expo,
// from wherever the level currently sits.
//
// Three states, three honest phrases, announced only on the transition
// between them (role=status, not a running commentary):
//   waiting -> "Waiting, typically N seconds."
//   slow    -> "Taking longer than usual."
//   stalled -> "May be stalled, retry available."
// Distinct from status-glyph-cadence (liveness — the connection is alive, no
// notion of "how long is normal") and password-strength-tide (liquid level
// encodes input strength, not elapsed-time-vs-percentile). This is the only
// one of the three built from a calibrated latency distribution.
//
// Timing is derived from `startedAt`/`p50Ms`/`p95Ms` via two scheduled
// setTimeouts (not a rAF poll — there's nothing to simulate physically here,
// just two thresholds), so a late mount (component created a few hundred ms
// after the real request began) still lands on the correct phase and eases
// in for whatever time remains, rather than replaying the whole rise from
// zero. Pure DOM + CSS + SVG: the fill is a div, the meniscus is a 1-path SVG
// riding its top edge, both colored from --foreground/--muted, no canvas.
// prefers-reduced-motion drops the rise/tremor/fade entirely and swaps in a
// static three-segment strip (within-normal / slow / stalled) — the same
// phase machine, same announcements, nothing depends on perceiving motion.
// ---------------------------------------------------------------------------

export type MeniscusPhase = "waiting" | "slow" | "stalled" | "arrived";

const TUBE_H = 48; // px, per spec
const TUBE_W = 6; // px, per spec
const DRAIN_MS = 380;
const DRAIN_EASE = "cubic-bezier(0.16,1,0.3,1)"; // ease-out-expo
const RISE_EASE = "cubic-bezier(0.22,1,0.36,1)"; // gentle decelerate, distinct from drain
const P95_FADE_MS_FLOOR = 250;

function useReducedMotion() {
  const [reduced, setReduced] = useState(false);
  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    setReduced(mq.matches);
    const onChange = () => setReduced(mq.matches);
    mq.addEventListener("change", onChange);
    return () => mq.removeEventListener("change", onChange);
  }, []);
  return reduced;
}

function fmtSeconds(ms: number): string {
  const safe = Number.isFinite(ms) && ms > 0 ? ms : 0;
  return (safe / 1000).toFixed(1);
}

// which of the two timed phases (ignoring `arrivedAt`) a given elapsed-ms
// falls in, for a p50/p95 pair
function timedPhase(elapsedMs: number, p50Ms: number, p95Ms: number): "waiting" | "slow" | "stalled" {
  if (elapsedMs < p50Ms) return "waiting";
  if (elapsedMs < p95Ms) return "slow";
  return "stalled";
}

const CSS = `
.ns-mh-tremor { animation: ns-mh-tremor 500ms ease-in-out infinite; }
@keyframes ns-mh-tremor {
  0%, 100% { transform: translateY(0); }
  50% { transform: translateY(0.5px); }
}
@media (prefers-reduced-motion: reduce) {
  .ns-mh-tremor { animation: none; }
}
`;

export interface MeniscusHoldProps {
  /** ms epoch timestamp this request/turn began. Defaults to Date.now() at mount. */
  startedAt?: number;
  /** historical p50 (typical) first-token latency in ms, e.g. from a rolling latency store */
  p50Ms: number;
  /** historical p95 first-token latency in ms — should be greater than p50Ms */
  p95Ms: number;
  /** ms epoch timestamp the first token actually arrived; null/undefined while still waiting */
  arrivedAt?: number | null;
  /** fired when the user presses Retry after a stall */
  onRetry?: () => void;
  /** fired when the user presses "Switch model" after a stall */
  onSwitchModel?: () => void;
  /** accessible name for the root status region. Default "Time to first token" */
  label?: string;
  className?: string;
}

export function MeniscusHold({
  startedAt,
  p50Ms,
  p95Ms,
  arrivedAt,
  onRetry,
  onSwitchModel,
  label = "Time to first token",
  className = "",
}: MeniscusHoldProps) {
  const reduced = useReducedMotion();
  const [autoStart] = useState(() => Date.now());
  const start = startedAt ?? autoStart;
  const safeP50 = p50Ms > 0 ? p50Ms : 1;
  const safeP95 = p95Ms > safeP50 ? p95Ms : safeP50 + 1;

  const [timed, setTimed] = useState<"waiting" | "slow" | "stalled">(() =>
    timedPhase(Date.now() - start, safeP50, safeP95)
  );

  // re-arm the two threshold timers whenever a new request starts. A late
  // mount schedules only whatever time remains, so a component created
  // 300ms into a request still crosses p50 300ms early, not late.
  useEffect(() => {
    const elapsed = Date.now() - start;
    setTimed(timedPhase(elapsed, safeP50, safeP95));
    const toSlow = safeP50 - elapsed;
    const toStalled = safeP95 - elapsed;
    const timers: number[] = [];
    if (toSlow > 0) {
      timers.push(window.setTimeout(() => setTimed("slow"), toSlow));
    }
    if (toStalled > 0) {
      timers.push(window.setTimeout(() => setTimed("stalled"), toStalled));
    }
    return () => timers.forEach((t) => window.clearTimeout(t));
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [start, safeP50, safeP95]);

  const phase: MeniscusPhase = arrivedAt ? "arrived" : timed;

  // fill height, as a fraction of the tube, held at the p50 line once
  // reached — it never keeps climbing toward p95, that would be fake
  // progress. p95 anchors the very top of the tube.
  const p50Frac = Math.max(0, Math.min(1, safeP50 / safeP95));
  const elapsedAtMount = Date.now() - start;
  const riseRemainMs = Math.max(0, safeP50 - elapsedAtMount);

  let fillFrac: number;
  let fillMs: number;
  let fillEase: string;
  if (phase === "arrived") {
    fillFrac = 0;
    fillMs = reduced ? 0 : DRAIN_MS;
    fillEase = DRAIN_EASE;
  } else if (phase === "waiting") {
    fillFrac = p50Frac;
    fillMs = reduced ? 0 : riseRemainMs;
    fillEase = RISE_EASE;
  } else {
    // slow or stalled: held at the p50 line, no further rise
    fillFrac = p50Frac;
    fillMs = 0;
    fillEase = RISE_EASE;
  }

  // p95 tick fades in across the "slow" window, complete by the time
  // "stalled" arrives; instant under reduced motion (still legible, just
  // not animated).
  const p95FadeMs = Math.max(P95_FADE_MS_FLOOR, safeP95 - safeP50);
  // driven by the underlying timed phase, not the arrived overlay — a token
  // that lands before p50 was ever reached should never suddenly reveal a
  // mark it hadn't earned yet.
  const p95Visible = timed !== "waiting";

  // announce only on transition, never a running commentary on the level
  const [liveText, setLiveText] = useState("");
  const lastAnnouncedRef = useRef<MeniscusPhase | null>(null);
  useEffect(() => {
    if (lastAnnouncedRef.current === phase) return;
    lastAnnouncedRef.current = phase;
    if (phase === "waiting") {
      setLiveText(`Waiting, typically ${fmtSeconds(safeP50)} seconds.`);
    } else if (phase === "slow") {
      setLiveText("Taking longer than usual.");
    } else if (phase === "stalled") {
      setLiveText("May be stalled, retry available.");
    } else {
      setLiveText("Response arrived.");
    }
  }, [phase, safeP50]);

  const p50Label = `${fmtSeconds(safeP50)}s`;
  const p95Label = `${fmtSeconds(safeP95)}s`;

  const captionText =
    phase === "waiting"
      ? "waiting"
      : phase === "slow"
        ? "slower than usual"
        : phase === "stalled"
          ? "may be stalled"
          : "arrived";

  return (
    <div
      data-meniscus-root
      role="group"
      aria-label={label}
      className={`inline-flex items-start gap-4 ${className}`}
    >
      <style>{CSS}</style>

      {/* the visual tube/tick apparatus (or its reduced-motion stand-in) —
          a dedicated, spatially disjoint hook for tooling: it never overlaps
          the retry/switch buttons rendered in the sibling column below, so
          clicking it (inert — there is no handler here) can never
          accidentally land on a button that only exists once stalled. */}
      <div data-meniscus-display>
        {reduced ? (
          <ReducedIndicator phase={phase} />
        ) : (
          <div className="flex items-end" style={{ height: TUBE_H }}>
            {/* the tube */}
            <div
              className="relative rounded-sm border border-border"
              style={{ width: TUBE_W, height: TUBE_H }}
            >
              <div className="absolute inset-0 overflow-hidden rounded-sm">
                <div
                  aria-hidden
                  className="absolute inset-x-0 bottom-0"
                  style={{
                    height: `${fillFrac * 100}%`,
                    backgroundColor: "var(--muted)",
                    opacity: 0.25,
                    transition: `height ${fillMs}ms ${fillEase}`,
                  }}
                />
              </div>

              {/* meniscus — a small concave curve riding the fill's top edge,
                  a sibling of the clipped layer so it's never cut off */}
              <div
                aria-hidden
                className={phase === "slow" ? "ns-mh-tremor" : ""}
                style={{
                  position: "absolute",
                  left: -1,
                  right: -1,
                  bottom: `calc(${fillFrac * 100}% - 2px)`,
                  height: 4,
                  transition: `bottom ${fillMs}ms ${fillEase}`,
                }}
              >
                <svg width={TUBE_W + 2} height={4} viewBox="0 0 8 4" style={{ display: "block" }}>
                  <path
                    d="M0,0.5 Q4,3.2 8,0.5"
                    fill="none"
                    stroke="var(--foreground)"
                    strokeWidth="1"
                    strokeLinecap="round"
                  />
                </svg>
              </div>
            </div>

            {/* scribe marks + labels */}
            <div className="relative ml-2" style={{ height: TUBE_H, width: 34 }}>
              <Tick bottomFrac={p50Frac} label={p50Label} opacity={1} />
              <Tick
                bottomFrac={1}
                label={p95Label}
                opacity={p95Visible ? 1 : 0}
                transitionMs={reduced ? 0 : p95FadeMs}
              />
            </div>
          </div>
        )}
      </div>

      <div className="flex flex-col gap-2">
        <span className="font-mono text-[10px] uppercase tracking-[0.14em] text-muted">
          {captionText}
        </span>

        {phase === "stalled" && (
          <div className="flex items-center gap-2">
            <button
              type="button"
              data-meniscus-retry
              onClick={onRetry}
              className="rounded-sm border border-border px-2 py-1 font-mono text-[11px] text-foreground transition-colors hover:bg-foreground/10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
            >
              Retry
            </button>
            <button
              type="button"
              data-meniscus-switch
              onClick={onSwitchModel}
              className="rounded-sm border border-border px-2 py-1 font-mono text-[11px] text-muted transition-colors hover:bg-foreground/10 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
            >
              Switch model
            </button>
          </div>
        )}
      </div>

      <p role="status" aria-live="polite" className="sr-only">
        {liveText}
      </p>
    </div>
  );
}

function Tick({
  bottomFrac,
  label,
  opacity,
  transitionMs = 0,
}: {
  bottomFrac: number;
  label: string;
  opacity: number;
  transitionMs?: number;
}) {
  return (
    <div
      aria-hidden
      className="absolute left-0 flex items-center gap-1"
      style={{
        bottom: `calc(${bottomFrac * 100}% - 0.5px)`,
        opacity,
        transition: `opacity ${transitionMs}ms linear`,
      }}
    >
      <span className="block" style={{ width: 5, height: 1, backgroundColor: "var(--foreground)" }} />
      <span className="whitespace-nowrap font-mono text-[9px] text-muted">{label}</span>
    </div>
  );
}

// Static, motion-free fallback: three fixed segments, the current phase lit
// from --muted to --foreground. Same phase machine drives this branch —
// nothing here depends on perceiving a rise or a tremble.
function ReducedIndicator({ phase }: { phase: MeniscusPhase }) {
  const segments: { key: "waiting" | "slow" | "stalled"; text: string }[] = [
    { key: "waiting", text: "normal" },
    { key: "slow", text: "slow" },
    { key: "stalled", text: "stalled" },
  ];
  const activeKey = phase === "arrived" ? null : phase;
  return (
    <div className="flex items-end gap-1" style={{ height: TUBE_H }}>
      {segments.map((seg) => {
        const active = seg.key === activeKey;
        return (
          <div key={seg.key} className="flex flex-col items-center gap-1">
            <div
              className="rounded-sm border border-border"
              style={{
                width: TUBE_W,
                height: TUBE_H - 10,
                backgroundColor: active ? "var(--foreground)" : "var(--muted)",
                opacity: active ? 0.55 : 0.15,
              }}
            />
            <span className="font-mono text-[8px] uppercase tracking-[0.1em] text-muted">
              {seg.text}
            </span>
          </div>
        );
      })}
    </div>
  );
}
Use when

Time-to-first-token rendered as calibrated capillary rise — a narrow tube climbs to a scribed p50 line, holds and trembles if it's running slow, and only surfaces retry/switch-model once it's genuinely past p95. Never fakes progress.

waiting on a model/agent's first token where the question is 'is this normal, unusually slow, or actually stalled' against that model's own recorded latency history — the rise is calibrated to a real p50/p95 pair (not a generic spinner), holds honestly at the p50 line rather than faking progress toward a target it hasn't earned, and only surfaces retry/switch-model once elapsed time has genuinely passed p95. Pick status-glyph-cadence instead for connection liveness with no notion of 'how long is normal' (no percentiles, no stall detection); pick password-strength-tide instead for a liquid level that encodes input strength, not elapsed time against a calibrated distribution.

Build spec

Renders time-to-first-token as a calibrated capillary tube: a narrow column, exactly 6px wide and 48px tall, 1px --border walls, whose fill (--muted at 25% opacity) rises from the bottom toward a scribed p50 line the instant the request starts. The rise duration is set so the fill's top — a small concave SVG meniscus curve riding it, stroked in --foreground, animated separately from the fill itself — reaches that p50 line exactly when a response typically lands for this model/tool combo, using an eased (not linear) decelerating curve so the arrival reads as a settling motion, not a race. If nothing has arrived by the time the p50 line is reached, the component does the one honest thing a spinner never does: it stops rising. The fill HOLDS at the p50 level — it never keeps climbing toward p95, since that would misrepresent unearned progress — while the meniscus curve alone (not the fill, not the tube) gets a barely-there 0.5px vertical tremble at 2Hz, and a second scribed line for p95 fades in above it over the p50-to-p95 window. The moment elapsed time actually passes p95, the tremble stops outright (trembling forever would itself be dishonest about how much longer 'unusually slow' is expected to last) and a stall affordance — real Retry and Switch model buttons, not disabled decoys — mounts into the layout, entering tab order exactly when they appear. If the first token arrives at any point (an `arrivedAt` timestamp appears), the tube drains to empty fast, ease-out-expo, from wherever the fill currently sits, as the real streaming response takes over. The whole thing is driven by three props — `startedAt` (ms epoch, defaults to mount time), `p50Ms`, `p95Ms` (defaults are the caller's, drawn from a rolling per-model latency store) — via two scheduled setTimeouts rather than a rAF poll, and correctly handles a late mount: if the component is created e.g. 300ms into an already-running request, it computes remaining time to each threshold and eases in for only what's left, rather than restarting the rise from zero. Accessibility is phase-transition-only: a role=status aria-live=polite region announces exactly once per transition — 'Waiting, typically N seconds.' entering the wait, 'Taking longer than usual.' entering the slow hold, 'May be stalled, retry available.' entering the stalled state — and never narrates the continuously-changing fill level itself, which would be noise. Retry and Switch model are ordinary <button>s with visible text labels (real accessible names, not icon-only), rendered conditionally so they simply aren't in the DOM (and not in tab order) until the stalled phase actually begins. Under prefers-reduced-motion, the entire tube/meniscus/tick apparatus is replaced by a static three-segment strip (within-normal / slow / stalled) with the current segment lit from --muted to --foreground and the rest dim — driven by the exact same phase state machine and the exact same three announcements, so nothing about the component's honesty depends on a viewer being able to perceive motion. Distinct from status-glyph-cadence, which signals raw connection liveness with no concept of 'how long is normal' — no percentiles, no stall detection, just 'is it still alive'; and distinct from password-strength-tide, whose liquid level encodes input strength typed so far, not elapsed wall-clock time measured against a calibrated latency distribution. Props: startedAt (optional, ms epoch), p50Ms and p95Ms (required, ms), arrivedAt (optional ms epoch or null/undefined while waiting), onRetry, onSwitchModel (both optional callbacks fired by the two stall-state buttons), label (accessible name for the root group, default 'Time to first token'), className. Pure DOM + CSS + one small inline SVG path for the meniscus curve — no canvas, all ink and fill from --foreground/--muted/--border tokens.

Tags
loadingstatuslatencyagentaria-livesvgmetertiming