Streaming Token Settle

Text

Streaming text where provisional tokens sit off-baseline and a hair rotated like loose letterpress type, then snap into the chase with a stiff spring the instant they're confirmed — a correction slides the old word out sideways as its replacement slides in.

Install
npx shadcn add https://design.helpmarq.com/r/streaming-token-settle.json
Source
registry/core/streaming-token-settle/component.tsx
"use client";

import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";

// ---------------------------------------------------------------------------
// LooseType — a streaming-text surface where provisional tokens sit like
// loose letterpress type (off-baseline, a hair rotated, --muted) and
// physically lock into the chase the instant they're confirmed (snap to
// baseline, --foreground, a stiff low-damping spring). A corrected token
// slides out sideways as its replacement slides in, the freed width
// collapsing under it so the line reflows like a compositor swapping a
// sort — not a flash. Pure DOM/CSS transforms, no canvas.
//
// The motion IS the data: jitter vs. baseline is the only signal for
// provisional vs. committed, there's no separate status pill. Distinct from
// streaming-ink-dry (which encodes elapsed-time freshness via weight/opacity and never
// snaps) and text-decrypt (a one-shot scramble-to-reveal on a fixed string,
// no underlying state machine at all).
// ---------------------------------------------------------------------------

type Key = string | number;

export interface LooseTypeToken {
  /**
   * Stable identity. Reusing an id updates that token in place (e.g. a
   * still-provisional token settling toward its final wording without a
   * full swap). Give a *replacement* token a fresh id at the same array
   * position to represent a correction — the old id's span slides out as
   * the new id's span slides in, at that seam.
   */
  id: Key;
  text: string;
  /** Locked into the chase. Default false (provisional). */
  committed?: boolean;
}

export interface LooseTypeProps {
  /** Full ordered snapshot of the line's tokens, every render. */
  tokens: LooseTypeToken[];
  className?: string;
}

type Status = "entering" | "settled" | "removing";

interface Item {
  id: Key;
  text: string;
  committed: boolean;
  jitter: { ty: number; rot: number };
  status: Status;
  /** entering: false until the slide-in transition should start */
  animateIn?: boolean;
  /** removing: measured px width, captured before the collapse starts */
  widthPx?: number;
  /** removing: false until the collapse/slide-out transition should start */
  collapse?: boolean;
}

const ENTER_MS = 260;
const EXIT_MS = 260;
const COMMIT_MS = 420;
// A back-out curve is the pure-CSS stand-in for a stiff, low-damping spring:
// it overshoots past the resting value before settling, same read as a sort
// dropping into the chase and bouncing once against its neighbors.
const SPRING_EASE = "cubic-bezier(0.34, 1.56, 0.64, 1)";

// Deterministic per-token offset so a token's tilt/lift never rerolls across
// re-renders — "seeded per token", not per paint. Two independent floats are
// pulled from one FNV-1a hash by mixing it a second time for the pair.
function seededJitter(id: Key): { ty: number; rot: number } {
  const s = String(id);
  let h = 0x811c9dc5;
  for (let i = 0; i < s.length; i++) {
    h ^= s.charCodeAt(i);
    h = Math.imul(h, 0x01000193);
  }
  const h2 = Math.imul(h ^ 0x9e3779b9, 0x2545f491);
  const ty = (((h >>> 0) % 2001) / 1000 - 1) * 1; // -1..1 px
  const rot = (((h2 >>> 0) % 1201) / 1000 - 0.6) * 1; // -0.6..0.6 deg
  return { ty, rot };
}

function makeEntering(t: LooseTypeToken): Item {
  return {
    id: t.id,
    text: t.text,
    committed: !!t.committed,
    jitter: seededJitter(t.id),
    status: "entering",
    animateIn: false,
  };
}

function makeSettled(t: LooseTypeToken): Item {
  return {
    id: t.id,
    text: t.text,
    committed: !!t.committed,
    jitter: seededJitter(t.id),
    status: "settled",
  };
}

function carryOver(prev: Item, t: LooseTypeToken): Item {
  return { ...prev, text: t.text, committed: !!t.committed };
}

// Walks the previous render list against the new snapshot in order. A
// surviving id is carried over in place; an id that vanished becomes (or
// stays) a "removing" ghost planted exactly where it was; a brand-new id
// is inserted as "entering" right before whatever surviving token it now
// precedes. For the common case — append-only growth, or a single tail
// token swapped for its correction — this lands the ghost and its
// replacement adjacent, at the seam, which is the whole point.
function diff(prev: Item[], tokens: LooseTypeToken[]): Item[] {
  const incoming = new Set(tokens.map((t) => t.id));
  const next: Item[] = [];
  let ti = 0;

  for (const p of prev) {
    if (incoming.has(p.id)) {
      while (ti < tokens.length && tokens[ti]!.id !== p.id) {
        next.push(makeEntering(tokens[ti]!));
        ti++;
      }
      if (ti < tokens.length && tokens[ti]!.id === p.id) {
        next.push(carryOver(p, tokens[ti]!));
        ti++;
      }
    } else if (p.status === "removing") {
      next.push(p); // already ghosted, don't restart its timer/measurement
    } else {
      next.push({ ...p, status: "removing", widthPx: undefined, collapse: false });
    }
  }
  while (ti < tokens.length) {
    next.push(makeEntering(tokens[ti]!));
    ti++;
  }
  return next;
}

export function LooseType({ tokens, className = "" }: LooseTypeProps) {
  const [items, setItemsState] = useState<Item[]>(() => tokens.map(makeSettled));
  const itemsRef = useRef(items);
  // synchronous init so a mount with provisional tokens already present under
  // prefers-reduced-motion never paints the jittered branch on the first frame
  const reducedRef = useRef(
    typeof window !== "undefined" &&
      typeof window.matchMedia === "function" &&
      window.matchMedia("(prefers-reduced-motion: reduce)").matches
  );
  const nodesRef = useRef(new Map<Key, HTMLSpanElement>());
  const measuredRef = useRef(new Set<Key>());
  const flippedRef = useRef(new Set<Key>());

  const setItems = useCallback((next: Item[]) => {
    itemsRef.current = next;
    setItemsState(next);
  }, []);

  useEffect(() => {
    reducedRef.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  }, []);

  const sig = useMemo(
    () => tokens.map((t) => `${t.id}:${t.committed ? 1 : 0}:${t.text}`).join(""),
    [tokens]
  );

  useEffect(() => {
    if (reducedRef.current) {
      setItems(tokens.map(makeSettled));
      return;
    }
    setItems(diff(itemsRef.current, tokens));
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [sig]);

  // Entering tokens: flip from the pre-slide style to the animated one a
  // frame after mount, so the browser actually observes a style change to
  // transition from (setting the "final" style on the same paint as mount
  // would just render there, no motion).
  useLayoutEffect(() => {
    if (reducedRef.current) return;
    const pending = items.filter((it) => it.status === "entering" && !it.animateIn && !flippedRef.current.has(it.id));
    if (!pending.length) return;
    for (const it of pending) flippedRef.current.add(it.id);
    const raf = requestAnimationFrame(() => {
      setItems(
        itemsRef.current.map((it) =>
          it.status === "entering" && pending.some((p) => p.id === it.id) ? { ...it, animateIn: true } : it
        )
      );
    });
    // If this rAF gets cancelled (e.g. the sibling "removing" measurement
    // effect fires a synchronous setItems in the same layout-effect flush,
    // before the browser ever paints a frame), the flip never actually
    // happened — un-mark these ids so the next effect pass retries them.
    // Without this, a token that entered in the same tick a correction was
    // ghosted (exactly the provisional-correction seam) got permanently
    // stuck at opacity:0/transition:none: never animated in, never fired
    // the transitionend that settles it — invisible forever.
    return () => {
      cancelAnimationFrame(raf);
      for (const it of pending) flippedRef.current.delete(it.id);
    };
  }, [items, setItems]);

  // Removing tokens: measure the natural width first (so the collapse has a
  // real px start point — "auto" can't be transitioned), then flip to the
  // collapsed target a frame later.
  useLayoutEffect(() => {
    if (reducedRef.current) return;
    const toMeasure = items.filter((it) => it.status === "removing" && it.widthPx === undefined && !measuredRef.current.has(it.id));
    if (!toMeasure.length) return;
    for (const it of toMeasure) measuredRef.current.add(it.id);
    const widths = new Map<Key, number>();
    for (const it of toMeasure) {
      const node = nodesRef.current.get(it.id);
      widths.set(it.id, node ? node.offsetWidth : 0);
    }
    setItems(
      itemsRef.current.map((it) => (widths.has(it.id) ? { ...it, widthPx: widths.get(it.id) } : it))
    );
  }, [items, setItems]);

  useEffect(() => {
    if (reducedRef.current) return;
    const toCollapse = items.filter((it) => it.status === "removing" && it.widthPx !== undefined && !it.collapse);
    if (!toCollapse.length) return;
    const raf = requestAnimationFrame(() => {
      setItems(
        itemsRef.current.map((it) =>
          it.status === "removing" && toCollapse.some((p) => p.id === it.id) ? { ...it, collapse: true } : it
        )
      );
    });
    return () => cancelAnimationFrame(raf);
  }, [items, setItems]);

  const onGhostTransitionEnd = useCallback(
    (id: Key, e: React.TransitionEvent<HTMLSpanElement>) => {
      if (e.propertyName !== "max-width") return;
      measuredRef.current.delete(id);
      nodesRef.current.delete(id);
      setItems(itemsRef.current.filter((it) => it.id !== id));
    },
    [setItems]
  );

  const onEnterTransitionEnd = useCallback(
    (id: Key, e: React.TransitionEvent<HTMLSpanElement>) => {
      if (e.propertyName !== "opacity") return;
      flippedRef.current.delete(id);
      setItems(itemsRef.current.map((it) => (it.id === id && it.status === "entering" ? { ...it, status: "settled" } : it)));
    },
    [setItems]
  );

  const committedText = tokens
    .filter((t) => t.committed)
    .map((t) => t.text)
    .join(" ");

  return (
    <div className={`ns-streaming-token-settle ${className}`}>
      <div aria-live="polite" className="sr-only">
        {committedText}
      </div>
      <div aria-hidden="true" className="flex flex-wrap items-baseline">
        {items.map((it) => {
          const reduced = reducedRef.current;
          const base: React.CSSProperties = {
            display: "inline-block",
            marginRight: "0.28em",
            marginBottom: "0.15em",
          };

          if (reduced) {
            return (
              <span
                key={it.id}
                className={it.committed ? "ns-streaming-token-settle-token text-foreground" : "ns-streaming-token-settle-token ns-streaming-token-settle-provisional text-muted"}
                style={base}
              >
                {it.text}
              </span>
            );
          }

          if (it.status === "entering") {
            const style: React.CSSProperties = it.animateIn
              ? {
                  ...base,
                  transform: `translateX(0) translateY(${it.jitter.ty}px) rotate(${it.jitter.rot}deg)`,
                  opacity: 1,
                  transitionProperty: "transform, opacity",
                  transitionDuration: `${ENTER_MS}ms`,
                  transitionTimingFunction: "cubic-bezier(0.16, 1, 0.3, 1)",
                }
              : {
                  ...base,
                  transform: "translateX(0.4em)",
                  opacity: 0,
                  transition: "none",
                };
            return (
              <span
                key={it.id}
                ref={(node) => {
                  if (node) nodesRef.current.set(it.id, node);
                }}
                className={it.committed ? "ns-streaming-token-settle-token text-foreground" : "ns-streaming-token-settle-token ns-streaming-token-settle-provisional text-muted"}
                style={style}
                onTransitionEnd={(e) => onEnterTransitionEnd(it.id, e)}
              >
                {it.text}
              </span>
            );
          }

          if (it.status === "removing") {
            const style: React.CSSProperties = it.collapse
              ? {
                  ...base,
                  maxWidth: 0,
                  marginRight: 0,
                  opacity: 0,
                  transform: "translateX(-0.4em)",
                  overflow: "hidden",
                  whiteSpace: "nowrap",
                  transitionProperty: "max-width, margin-right, opacity, transform",
                  transitionDuration: `${EXIT_MS}ms`,
                  transitionTimingFunction: "ease-out",
                }
              : {
                  ...base,
                  maxWidth: it.widthPx !== undefined ? `${it.widthPx}px` : undefined,
                  opacity: 1,
                  transform: "translateX(0)",
                  overflow: "hidden",
                  whiteSpace: "nowrap",
                  transition: "none",
                };
            return (
              <span
                key={it.id}
                ref={(node) => {
                  if (node) nodesRef.current.set(it.id, node);
                }}
                className={it.committed ? "ns-streaming-token-settle-token text-foreground" : "ns-streaming-token-settle-token ns-streaming-token-settle-provisional text-muted"}
                style={style}
                onTransitionEnd={(e) => onGhostTransitionEnd(it.id, e)}
              >
                {it.text}
              </span>
            );
          }

          // settled: the resting spring lives here permanently, so a
          // provisional -> committed flip animates purely off the
          // `committed` boolean with no extra phase to schedule.
          return (
            <span
              key={it.id}
              className={it.committed ? "ns-streaming-token-settle-token text-foreground" : "ns-streaming-token-settle-token ns-streaming-token-settle-provisional text-muted"}
              style={{
                ...base,
                transform: it.committed ? "translateY(0) rotate(0deg)" : `translateY(${it.jitter.ty}px) rotate(${it.jitter.rot}deg)`,
                transitionProperty: "transform, color",
                transitionDuration: `${COMMIT_MS}ms, 200ms`,
                transitionTimingFunction: `${SPRING_EASE}, ease-out`,
              }}
            >
              {it.text}
            </span>
          );
        })}
      </div>
      <style>{CSS}</style>
    </div>
  );
}

const CSS = `
.ns-streaming-token-settle-provisional{
  text-decoration: none;
}
@media (prefers-reduced-motion: reduce){
  .ns-streaming-token-settle-provisional{
    text-decoration: underline;
    text-decoration-style: dotted;
    text-underline-offset: 3px;
  }
}
`;
Use when

any stream (LLM output, ASR transcription, collaborative edits) where the position/tilt/color of a token IS the provisional-vs-committed signal and a correction needs to read as a physical swap, not a flash — reach for streaming-ink-dry instead when you want elapsed-time freshness (a token drying from thin/translucent to full weight) with no revision model behind it, or text-decrypt for a one-shot scramble-to-reveal entrance on a fixed string with no streaming state at all.

Build spec

<LooseType tokens={LooseTypeToken[]}> renders a line of streaming text as a real provisional-vs-committed state machine, not a decorative flourish: pass the full ordered token snapshot every render, where each token is { id, text, committed? }. A provisional token (committed: false, the default) renders muted and sits off its baseline by a token-seeded ±1px translateY and ±0.6deg rotation — like loose type not yet locked into the chase — so a still frame reads at a glance as 'may still change' without any color-only signal to miss. The instant a token's committed flips to true it snaps to baseline, full --foreground, with a stiff, low-damping spring (a CSS back-out easing that overshoots slightly before settling, ~420ms) — no flash, the motion itself is the confirmation. To represent a correction (the model or ASR revising a word), give the replacement a fresh id at the same array position instead of editing the old token's text in place: the old id's span slides out sideways (translateX -0.4em, fades) while the new id's span slides in from the opposite side (translateX +0.4em to 0), and the space the old token occupied is width-tweened back to zero as it exits so the rest of the line reflows under it smoothly, like a compositor pulling and reseating a sort, rather than jumping. Reusing an existing id just updates that token's text/committed flag in place with no swap animation. Pure DOM + CSS transforms on real <span> elements, zero canvas, zero dependencies. Accessibility is a single channel by design: the entire visual row is aria-hidden, and a screen reader instead gets one aria-live=polite region holding only the committed text, joined with single spaces — provisional wording is never exposed to assistive tech, so a reader is never told something that might still be revised, and because the region's content is recomputed as one string on every commit or correction, a multi-token correction announces as one coalesced update rather than word-by-word churn. prefers-reduced-motion drops the jitter, the slide, and the spring entirely: provisional tokens are told apart from committed ones purely by --muted color plus a dotted underline, both applied and removed instantly with no transform or transition at all, so the component stays fully legible and non-distracting under reduced motion rather than just slowing down. Distinct from streaming-ink-dry (which encodes elapsed-time freshness via variable-font weight/opacity/blur ramping toward settled, with no revision concept and nothing ever un-arrives) and from text-decrypt (a one-shot monospace scramble-to-decode entrance on a fixed string, no ongoing state at all).

Tags
textstreamingtranscriptionstate-machineaccessibility