Streaming Ink Dry

Text

Streaming LLM text where the newest tokens arrive light and translucent, then dry to full opacity a beat behind the stream head — width-stable, so committed text never reflows.

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

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

// Streaming LLM text where the still-provisional tail visibly hasn't dried
// yet. Each new token starts light — opacity .55, a hair of blur — then
// rides up to opacity 1 and no blur over ~600ms, ease-out-expo. font-weight
// is locked to 400 for the token's entire life (never part of the
// animation): interpolating the variable-font weight axis mid-flight
// changes glyph advance widths frame by frame, and because tokens arrive
// faster than one token's dry time, several spans would be mid-ramp at
// once — the trailing settled text would visibly compact as their widths
// crept. Keeping weight constant and animating only opacity/blur (neither
// affects layout) means arrival never reflows already-rendered text.
//
// Every token is a real, pure-CSS `animation: ... both` — arrival IS the
// stagger, nothing is scheduled or replayed from JS, and because tokens is
// append-only with stable index keys, React never remounts an already-dried
// span when new ones arrive. prefers-reduced-motion collapses the whole
// ramp to a plain settled span (see the media query below) rather than
// skipping the animation via JS, so there's no flash-of-wet-then-cut.
//
// Screen readers get a second, parallel channel rather than reading the
// decorative spans (which are aria-hidden): a visually-hidden log holds the
// real text, split into already-announced sentences (each its own static
// span — a plain node appended to a polite live region is itself the
// announcement) and one trailing `aria-busy` span for whatever hasn't hit a
// sentence boundary yet. Mutating a busy span's text doesn't get announced
// fragment by fragment; it's released into a settled sentence (and read
// once, whole) the moment punctuation closes it, or after a short idle gap
// if the stream stalls or ends mid-clause.

export interface WetInkProps {
  /**
   * Every token received so far, oldest first, append-only. Include
   * whatever whitespace the model actually emitted with each token — this
   * component never inserts its own spacing between them. Shrinking the
   * array (a fresh stream) resets both the ink and the live-region state.
   */
  tokens: string[];
  /** Ms for one token to fully dry. Default 600. */
  dryMs?: number;
  /**
   * Ms of arrival silence before an unfinished (no sentence-ending
   * punctuation yet) tail is flushed to the live region anyway. Default 900.
   */
  idleFlushMs?: number;
  className?: string;
}

const DEFAULT_DRY_MS = 600;
const DEFAULT_IDLE_FLUSH_MS = 900;

// Last index worth flushing a sentence at: a run of text ending in ./!/?
// followed by whitespace-or-end, or a bare newline (paragraph break) at any
// position. Deliberately a heuristic (an abbreviation like "Mr." will flush
// early) — good enough for batching, not a sentence parser.
function lastFlushBoundary(s: string): number {
  let idx = -1;
  for (let i = 0; i < s.length; i++) {
    const c = s[i];
    if (c === "\n") {
      idx = i;
      continue;
    }
    if ((c === "." || c === "!" || c === "?") && (i === s.length - 1 || /\s/.test(s[i + 1] ?? ""))) {
      idx = i;
    }
  }
  return idx;
}

export function WetInk({
  tokens,
  dryMs = DEFAULT_DRY_MS,
  idleFlushMs = DEFAULT_IDLE_FLUSH_MS,
  className = "",
}: WetInkProps) {
  const [settled, setSettled] = useState<string[]>([]);
  const [tail, setTail] = useState("");
  const bufRef = useRef({ settled: [] as string[], tail: "" });
  const consumedLenRef = useRef(0);
  const idleTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);

  useEffect(() => {
    const fullText = tokens.join("");

    // Shorter than what we've already consumed => a new stream started.
    if (fullText.length < consumedLenRef.current) {
      consumedLenRef.current = 0;
      bufRef.current = { settled: [], tail: "" };
    }

    const delta = fullText.slice(consumedLenRef.current);
    consumedLenRef.current = fullText.length;

    if (delta) {
      let nextTail = bufRef.current.tail + delta;
      let nextSettled = bufRef.current.settled;
      const boundary = lastFlushBoundary(nextTail);
      if (boundary >= 0) {
        nextSettled = [...nextSettled, nextTail.slice(0, boundary + 1)];
        nextTail = nextTail.slice(boundary + 1);
      }
      bufRef.current = { settled: nextSettled, tail: nextTail };
      setSettled(nextSettled);
      setTail(nextTail);
    }

    clearTimeout(idleTimerRef.current);
    idleTimerRef.current = setTimeout(() => {
      if (bufRef.current.tail) {
        const flushed = [...bufRef.current.settled, bufRef.current.tail];
        bufRef.current = { settled: flushed, tail: "" };
        setSettled(flushed);
        setTail("");
      }
    }, idleFlushMs);

    return () => clearTimeout(idleTimerRef.current);
  }, [tokens, idleFlushMs]);

  return (
    <div
      role="log"
      aria-live="polite"
      aria-atomic="false"
      className={className}
      style={
        dryMs !== DEFAULT_DRY_MS
          ? ({ "--ns-streaming-ink-dry-dry-ms": `${dryMs}ms` } as CSSProperties)
          : undefined
      }
    >
      <p aria-hidden="true" className="ns-streaming-ink-dry-visual whitespace-pre-wrap">
        {tokens.map((tok, i) =>
          tok === "" ? null : (
            <span key={i} className="ns-streaming-ink-dry-token">
              {tok}
            </span>
          )
        )}
      </p>
      <span className="sr-only">
        {settled.map((chunk, i) => (
          <span key={i}>{chunk}</span>
        ))}
        <span aria-busy={tail.length > 0}>{tail}</span>
      </span>
      <style>{CSS}</style>
    </div>
  );
}

const CSS = `
.ns-streaming-ink-dry-token{
  font-weight:400;
  font-variation-settings:'wght' 400;
  animation:ns-streaming-ink-dry-dry var(--ns-streaming-ink-dry-dry-ms,600ms) cubic-bezier(.16,1,.3,1) both;
}
@keyframes ns-streaming-ink-dry-dry{
  from{opacity:.55;filter:blur(.4px)}
  to{opacity:1;filter:blur(0)}
}
@media (prefers-reduced-motion: reduce){
  .ns-streaming-ink-dry-token{animation:none;font-weight:400;font-variation-settings:'wght' 400;opacity:1;filter:none}
}
`;
Use when

the daily-driver container for a live token stream (chat replies, generation UIs) where weight/opacity/blur encode a real 'not yet settled' state and keep running for the life of the stream — reach for text-decrypt instead for a one-shot scramble-to-decode entrance on static text, or text-variable-weight for a cursor-driven decorative weight effect with no state behind it.

Build spec

<WetInk tokens={string[]}> renders a live streaming-text surface: pass the cumulative, append-only array of tokens received so far (each token exactly as the model emitted it, whitespace included — the component never inserts its own spacing) and it re-renders as the array grows. Every newly appended token mounts as its own span starting at opacity .55 and a 0.4px blur, then rides a single ~600ms ease-out-expo CSS animation (`animation: ... both`, no JS scheduling) up to opacity 1, zero blur. font-weight stays locked at 400 for the token's entire life — it is never part of the animation, deliberately: interpolating a variable font's weight axis changes glyph advance widths frame by frame, and because tokens can arrive faster than one token's dry time several spans would be mid-ramp at once, so any weight animation would read as the settled text ahead of it visibly compacting while you watch. Keeping weight constant and animating only opacity/blur (neither affects layout) means committed text never shifts horizontally, and arrival time alone still provides the stagger since tokens is append-only with stable index keys (already-dried spans are never remounted when new ones land). A still frame therefore always reads as a gradient of certainty: dried body, a drying middle, and a wet tail trailing the newest token. Accessibility is a second, parallel channel: the outer element is role=log aria-live=polite holding a visually-hidden transcript, not the decorative (aria-hidden) animated spans — settled sentences are each their own static span (a plain node landing in a polite live region is itself the announcement), and unresolved text sits in one trailing span carrying aria-busy=true so a screen reader is told to hold off, not left to infer 'wet' from opacity alone; that busy span's content is released into a new settled sentence the instant sentence-ending punctuation (or a newline) closes it, or after a short configurable idle gap (default 900ms, `idleFlushMs`) if the stream stalls mid-clause, so screen readers get whole sentences, never token-by-token fragments. Shrinking the `tokens` array (a fresh message) resets both the ink and the live-region bookkeeping. prefers-reduced-motion is handled entirely in CSS: the media query drops the animation and pins every token straight to its settled opacity/blur, so reduced-motion users see plain legible text arrive with no ramp, not a stall. `dryMs` (default 600) retunes the drying duration via a CSS custom property. Pure DOM + CSS, no canvas, zero dependencies — distinct from text-decrypt (a one-shot monospace scramble-to-decode entrance on a fixed string) and text-variable-weight (a decorative, cursor-driven weight morph with no underlying state): this is a live container where opacity/blur encode a real 'not yet settled' state and keeps running for the life of the stream.

Tags
textstreamingaichatvariable-fontaccessibility