Streaming Text
Typewriter reveal for AI responses — blinking cursor while revealing, append-safe when tokens stream in from an API.
▍
import { StreamingText } from "@/components/ui/streaming-text";
export function AssistantReply() {
return (
<StreamingText
text="AniUI is a copy-paste component library for React Native. Components are source files you own."
speed={60}
onComplete={() => console.log("reveal finished")}
/>
);
}Installation#
npx @aniui/cli add streaming-textUsage#
Pass the final text and it reveals at speed characters per second (default 60), cursor blinking until done.
import { StreamingText } from "@/components/ui/streaming-text";
export function AssistantReply() {
return (
<StreamingText
text="AniUI is a copy-paste component library for React Native. Components are source files you own."
speed={60}
onComplete={() => console.log("reveal finished")}
/>
);
}Streaming from an API#
Append chunks to text as they arrive — the reveal continues without restarting. Set streaming so the cursor keeps blinking between chunks even when the typewriter has caught up.
Press start to simulate an API stream — tokens arrive in chunks, the reveal never stutters.
// `text` can GROW over time — appends continue the reveal seamlessly,
// replacing the text restarts it. Keep `streaming` true between chunks
// so the cursor keeps blinking while waiting for more tokens.
const [reply, setReply] = useState("");
const [streaming, setStreaming] = useState(false);
const ask = async (prompt: string) => {
setReply("");
setStreaming(true);
for await (const chunk of streamCompletion(prompt)) {
setReply((prev) => prev + chunk); // append — the typewriter keeps going
}
setStreaming(false); // cursor disappears once caught up
};
<StreamingText text={reply} streaming={streaming} />In a chat bubble#
Drop it inside a ChatBubble for the classic AI assistant look.
▍
import { ChatBubble } from "@/components/ui/chat-bubble";
import { StreamingText } from "@/components/ui/streaming-text";
<ChatBubble variant="received">
<StreamingText text={reply} streaming={streaming} className="text-sm" />
</ChatBubble>Instant render#
// History messages shouldn't replay the animation —
// typewriter={false} renders the full text instantly, no cursor.
<StreamingText text={message} typewriter={false} />Props#
textstring—Target text — may grow over time; appends continue the reveal, replacements restart it.
typewriterbooleantruefalse renders the text instantly.
speednumber60Reveal speed in characters per second.
streamingbooleanfalseKeep the cursor blinking while waiting for more tokens.
showCursorbooleantrueonComplete() => void—Fired once when fully revealed and not streaming.
classNamestring—Also accepts all Text props from React Native. Needs react-native-reanimated for the blinking cursor.
Accessibility#
- Renders as a single
Textelement — screen readers read the revealed content as ordinary text. - The cursor is a typographic glyph that blinks via opacity only, safe for reduced-motion sensitivity.
- For long responses, consider
typewriter={false}so assistive tech gets the full message immediately.
Source#
import React, { useEffect, useRef, useState } from "react";
import { Text } from "react-native";
import Animated, { useSharedValue, useAnimatedStyle, withRepeat, withTiming, cancelAnimation } from "react-native-reanimated";
import { cn } from "@/lib/utils";
export interface StreamingTextProps extends React.ComponentPropsWithoutRef<typeof Text> {
className?: string;
/** Target text — may grow over time as tokens stream in from an API. */
text: string;
/** Reveal `text` progressively (default true). false renders it instantly. */
typewriter?: boolean;
/** Reveal speed in characters per second (default 60). */
speed?: number;
/** Keep the cursor blinking even when caught up (e.g. between API chunks). */
streaming?: boolean;
showCursor?: boolean;
onComplete?: () => void;
}
function Cursor() {
const opacity = useSharedValue(1);
useEffect(() => {
opacity.value = withRepeat(withTiming(0, { duration: 500 }), -1, true);
return () => cancelAnimation(opacity);
}, [opacity]);
const style = useAnimatedStyle(() => ({ opacity: opacity.value }));
// The cursor is a typographic block glyph (not an icon) so it flows inline
// with the revealed text; Animated.Text lets it blink via opacity.
return <Animated.Text style={style} className="text-foreground">▍</Animated.Text>;
}
export function StreamingText({
className, text, typewriter = true, speed = 60, streaming, showCursor = true, onComplete, ...props
}: StreamingTextProps) {
const [count, setCount] = useState(typewriter ? 0 : text.length);
const shown = useRef("");
// If `text` was replaced (not appended to), restart the reveal.
useEffect(() => {
if (!text.startsWith(shown.current)) setCount(typewriter ? 0 : text.length);
}, [text, typewriter]);
useEffect(() => {
if (!typewriter || count >= text.length) return;
const tick = 33;
const chars = Math.max(1, Math.round((speed * tick) / 1000));
const timer = setInterval(() => {
setCount((c) => Math.min(text.length, c + chars));
}, tick);
return () => clearInterval(timer);
}, [typewriter, count, text, speed]);
// Fire onComplete once per reveal, only when fully caught up and not streaming.
const completed = useRef(false);
useEffect(() => {
const isDone = count >= text.length && text.length > 0 && !streaming;
if (isDone && !completed.current) { completed.current = true; onComplete?.(); }
if (!isDone) completed.current = false;
}, [count, text, streaming, onComplete]);
const visible = typewriter ? text.slice(0, count) : text;
shown.current = visible;
const done = visible.length >= text.length && !streaming;
return (
<Text className={cn("text-base text-foreground", className)} {...props}>
{visible}
{showCursor && !done && <Cursor />}
</Text>
);
}