Streamed reply
AIAn assistant reply that arrives a word at a time, with the typing indicator and the caret — staggered in CSS, with the whole message in the DOM from the first frame.
Preview
Assistant
The booking data and the accounting data disagree because nothing reconciles them nightly.
Controls
Notes
Every word is present on the first frame; only the paint is staggered, by an animation-delay computed per word. So assistive tech reads the finished answer, which is the only version worth reading.
The corollary for a real token stream: do not wrap the region in aria-live. A polite region announces every mutation and a stream mutates dozens of times a second. Batch to the finished message and announce that once.
The caret lands with the last word rather than riding the leading edge. A caret that tracks a wrapping text edge needs a layout read every frame, which is a lot to pay for a blinking rectangle.
Under reduced motion the text is simply there. The fallback is the finished state, never the empty one.
Source
ShowHide
"use client";
import { Fragment, useState } from "react";
import { cn } from "@/lib/cn";
/**
* An assistant reply that arrives a word at a time.
*
* The whole message is in the DOM on the first frame — only its PAINT is
* staggered, by an animation-delay computed per word. That is the accessible
* shape of this effect: assistive tech reads the finished answer, which is the
* only version worth reading, while the screen fills in.
*
* The corollary, for a real token stream: do not wrap the region in aria-live.
* A polite region announces every mutation and a stream mutates dozens of times
* a second. Batch to the finished message and announce that.
*/
const css = `
@keyframes stream-word {
from { opacity: 0; translate: 0 3px; }
to { opacity: 1; translate: 0 0; }
}
@keyframes stream-caret { 50% { opacity: 0; } }
@keyframes stream-dot {
0%, 60%, 100% { opacity: 0.3; translate: 0 0; }
30% { opacity: 1; translate: 0 -3px; }
}
@keyframes stream-dots-out { to { opacity: 0; visibility: hidden; } }
.stream-word {
display: inline-block;
opacity: 0;
animation: stream-word 0.26s ease forwards;
animation-delay: calc(var(--stream-start, 0ms) + var(--i) * var(--stream-step, 55ms));
}
.stream-caret {
display: inline-block;
inline-size: 0.5ch;
block-size: 1.05em;
vertical-align: text-bottom;
margin-left: 0.15ch;
background: var(--accent);
opacity: 0;
animation:
stream-word 0.2s ease forwards,
stream-caret 1s steps(1) infinite;
animation-delay: var(--stream-end, 0ms), var(--stream-end, 0ms);
}
.stream-dots {
animation: stream-dots-out 0.25s ease forwards;
animation-delay: var(--stream-start, 0ms);
}
.stream-dots > span { animation: stream-dot 1.1s ease-in-out infinite; }
.stream-dots > span:nth-child(2) { animation-delay: 0.15s; }
.stream-dots > span:nth-child(3) { animation-delay: 0.3s; }
/* The fallback is the FINISHED state, never the empty one. */
@media (prefers-reduced-motion: reduce) {
.stream-word { animation: none; opacity: 1; }
.stream-caret { animation: none; opacity: 1; }
.stream-dots { display: none; }
}
`;
const steps = { slow: 90, medium: 55, fast: 26 } as const;
export type StreamSpeed = keyof typeof steps;
export function StreamedText({
text,
speed = "medium",
typing = true,
caret = true,
className,
}: {
text: string;
speed?: StreamSpeed;
/** Hold the dots for a beat before the words start. */
typing?: boolean;
caret?: boolean;
className?: string;
}) {
const words = text.split(/\s+/).filter(Boolean);
const step = steps[speed];
const start = typing ? 700 : 0;
return (
<div
className={cn("relative", className)}
style={
{
"--stream-step": `${step}ms`,
"--stream-start": `${start}ms`,
"--stream-end": `${start + words.length * step}ms`,
} as React.CSSProperties
}
>
<style href="stream-in" precedence="default">
{css}
</style>
{typing && (
// Absolutely positioned so the words do not shift when the dots go.
<span
aria-hidden="true"
className="stream-dots absolute top-1.5 left-0 flex items-center gap-1"
>
{[0, 1, 2].map((dot) => (
<span key={dot} className="size-1.5 rounded-full bg-ink-muted" />
))}
</span>
)}
<p className="text-[15px] leading-relaxed text-ink">
{words.map((word, i) => (
<Fragment key={`${i}-${word}`}>
{i > 0 && " "}
<span className="stream-word" style={{ "--i": i } as React.CSSProperties}>
{word}
</span>
</Fragment>
))}
{/* Decoration: it lands with the last word rather than riding the
leading edge. Tracking a wrapping text edge costs a layout read
every frame, which is a lot to pay for a blinking rectangle. */}
{caret && <span className="stream-caret" aria-hidden="true" />}
</p>
</div>
);
}
export default function Demo({
message = "The booking data and the accounting data disagree because nothing reconciles them nightly. I would start there before touching the reporting layer.",
speed = "medium",
typing = true,
caret = true,
}: {
message?: string;
speed?: StreamSpeed;
typing?: boolean;
caret?: boolean;
}) {
// Remounting is what replays a CSS animation, so the key carries every knob
// as well as the replay counter.
const [run, setRun] = useState(0);
const key = `${run}-${speed}-${typing}-${caret}-${message}`;
return (
<div className="w-full max-w-md">
<div className="flex items-center gap-2">
<span aria-hidden="true" className="size-5 rounded-full bg-accent-soft ring-1 ring-accent/40" />
<p className="eyebrow">Assistant</p>
</div>
<div className="mt-3 min-h-24 rounded-2xl rounded-tl-sm border border-rule bg-paper-raised px-5 py-4">
<StreamedText key={key} text={message} speed={speed} typing={typing} caret={caret} />
</div>
<button
type="button"
onClick={() => setRun((n) => n + 1)}
className="mt-4 rounded-full border border-control-border px-4 py-1.5 text-[13px] text-ink transition-colors hover:border-ink hover:bg-paper-sunken"
>
Replay
</button>
</div>
);
}
Next
TabsNext step
Tell me where the business is losing time.
No pitch deck and no discovery-call funnel. Describe the bottleneck and I will tell you plainly whether it is worth building something for, and roughly what that would take.