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.
npx shadcn add https://design.helpmarq.com /r/streaming-ink-dry.jsonregistry/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}
}
`;
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.
<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.