AniUI

Streaming Text

Typewriter reveal for AI responses — blinking cursor while revealing, append-safe when tokens stream in from an API.

Web preview — components render natively on iOS & Android
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-text

Usage#

Pass the final text and it reveals at speed characters per second (default 60), cursor blinking until done.

app/assistant.tsx
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.

Web preview — components render natively on iOS & Android
// `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.

Web preview — components render natively on iOS & Android
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#

Skip the animation
// History messages shouldn't replay the animation —
// typewriter={false} renders the full text instantly, no cursor.
<StreamingText text={message} typewriter={false} />

Props#

PropTypeDefault
text
string

Target text — may grow over time; appends continue the reveal, replacements restart it.

typewriter
boolean
true

false renders the text instantly.

speed
number
60

Reveal speed in characters per second.

streaming
boolean
false

Keep the cursor blinking while waiting for more tokens.

showCursor
boolean
true
onComplete
() => void

Fired once when fully revealed and not streaming.

className
string

Also accepts all Text props from React Native. Needs react-native-reanimated for the blinking cursor.

Accessibility#

  • Renders as a single Text element — 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#

components/ui/streaming-text.tsx
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>
  );
}