Skip to content
Andrew SmithSolutions
← Component library

Streamed reply

AI

An 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.

StreamingStaggerLive regionsCSS only — no dependency

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

Show
"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

Tabs

Next 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.