AI
ResponseStream
Reveals text progressively, either typed out character by character or faded in word by word, from a string or an async stream of chunks.
Guidelines#
Use it for short AI answers and summaries that should feel live: an agent's reply summary, a generated subject line. For long markdown replies render Markdown directly as the stream arrives.
What you provide: textStream (a string, or an AsyncIterable<string> whose chunks are appended as they arrive), mode (typewriter default, fade), speed 1–100 (default 20), className, as (element, default div), onComplete, and the overrides characterChunkSize, segmentDelay, fadeDuration (ms). useTextStream exposes the same engine with pause, resume, reset.
Behaviour: a string is revealed on animation frames. Typewriter adds 1 character per step below speed 25, otherwise round((speed − 25) / 10); steps are at least round(100 / √speed) ms apart (22ms at the default). Fade splits the text into words with Intl.Segmenter; each word fades from 0 to 1 opacity over round(1000 / √speed) ms, ease-out (224ms at the default), staggered by the step delay. Changing textStream restarts from empty. There is no reduced-motion branch: when the user asks for less motion, pass a high speed or render the text directly.
Don't stream text the user must act on immediately (buttons, numbers in forms), or restart the stream on every parent render (keep the string stable).
Facts#
| Group | AI |
|---|---|
| Product | Crew OS |
| Global | window.DigitalCrew.ResponseStream |
| Source | digitalcrew-orchestrator: components/ui/response-stream.tsx |
| Import in the app | import { ResponseStream } from "@/components/ui/response-stream"; |
API#
TypeScript declarations emitted from components/ui/response-stream.tsx (the module also exports ResponseStream, useTextStream).
export type Mode = "typewriter" | "fade";
export type UseTextStreamOptions = {
textStream: string | AsyncIterable<string>;
speed?: number;
mode?: Mode;
onComplete?: () => void;
fadeDuration?: number;
segmentDelay?: number;
characterChunkSize?: number;
onError?: (error: unknown) => void;
};
export type UseTextStreamResult = {
displayedText: string;
isComplete: boolean;
segments: {
text: string;
index: number;
}[];
getFadeDuration: () => number;
getSegmentDelay: () => number;
reset: () => void;
startStreaming: () => void;
pause: () => void;
resume: () => void;
};
declare function useTextStream({ textStream, speed, mode, onComplete, fadeDuration, segmentDelay, characterChunkSize, onError, }: UseTextStreamOptions): UseTextStreamResult;
export type ResponseStreamProps = {
textStream: string | AsyncIterable<string>;
mode?: Mode;
speed?: number;
className?: string;
onComplete?: () => void;
as?: keyof React.JSX.IntrinsicElements;
fadeDuration?: number;
segmentDelay?: number;
characterChunkSize?: number;
};
declare function ResponseStream({ textStream, mode, speed, className, onComplete, as, fadeDuration, segmentDelay, characterChunkSize, }: ResponseStreamProps): any;
export { useTextStream, ResponseStream };Example#
The code of the preview above: plain React.createElement against window.DigitalCrew, with realistic data.
<div id="root" class="p-6"></div>
<script>
(function () {
var h = React.createElement, DC = window.DigitalCrew, I = DC.Icons;
var text = "Claire Dubois replied to step 2 of Q3 CFO outreach. She is interested but wants to revisit after Northwind's budget review on 14 October, so I drafted a follow-up for the 15th and moved her to Nurture.";
function Panel(p) {
return h("div", { className: "grid content-start gap-2 rounded-xl border bg-card p-4" },
h("div", { className: "flex items-center justify-between" },
h("span", { className: "text-muted-foreground text-xs font-medium" }, p.label),
h(DC.Button, { size: "sm", variant: "ghost", onClick: p.onReplay }, h(I.History), "Replay")),
h("div", { className: "min-h-[120px] text-sm leading-relaxed" },
h(DC.ResponseStream, { key: p.run, textStream: text, mode: p.mode, speed: 60, characterChunkSize: p.chunk })));
}
function App() {
var a = React.useState(0), b = React.useState(0);
return h("div", { className: "grid gap-4 sm:grid-cols-2" },
h(Panel, { label: "mode=\"typewriter\"", mode: "typewriter", run: a[0], onReplay: function () { a[1](a[0] + 1); } }),
h(Panel, { label: "mode=\"fade\"", mode: "fade", chunk: 4, run: b[0], onReplay: function () { b[1](b[0] + 1); } }));
}
ReactDOM.createRoot(document.getElementById("root")).render(h(DC.TooltipProvider, { delayDuration: 80 }, h(App)));
})();
</script>