import { useEffect, useRef, useState } from 'react' interface UseTypewriterOptions { /** Characters revealed per second (default: `500`). */ speed?: number /** When true, reveal the full text immediately (and snap to it if it grows). */ skipAnimation?: boolean } interface UseTypewriterResult { /** The portion of `fullText` revealed so far. */ displayedText: string /** `true` while characters are still being revealed. */ isTyping: boolean } /** * Reveals a string character-by-character at a steady rate via * `requestAnimationFrame`. Designed for bursty, streamed agent text: as * `fullText` grows the reveal keeps chasing the new end, and flipping * `skipAnimation` to `true` (e.g. once generation finishes) snaps to the full * text. Pair it with `ChatAgentMessage`. * * @example * ```tsx * const { displayedText, isTyping } = useTypewriter(message) * return ( * * {displayedText} * {isTyping ? : null} * * ) * ``` */ export function useTypewriter( fullText: string, options: UseTypewriterOptions = {}, ): UseTypewriterResult { const { speed = 500, skipAnimation = false } = options const [charIndex, setCharIndex] = useState(() => skipAnimation ? fullText.length : 0, ) // Mirror of the revealed count, read/written ONLY inside the rAF loop (never // during render). This lets the animation effect omit `charIndex` from its // deps: were it a dep, the loop would tear down and restart on every revealed // frame — resetting its frame timer — and never accumulate the elapsed time // needed to advance, which stalls completely while `fullText` is also growing // (the streaming case). const charRef = useRef(charIndex) useEffect(() => { // When `skipAnimation` is set there's nothing to animate — the full text is // shown directly via the derived `displayedText` below (no setState here, so // a streamed message snaps to its full text the moment generation finishes, // without an extra render or a `set-state-in-effect` cascade). if (skipAnimation) return // Already fully revealed (e.g. text hasn't grown since the last frame). if (charRef.current >= fullText.length) return const msPerChar = 1000 / speed let rafId: number let lastTime: number | null = null function tick(timestamp: number) { // Plain `?? =` not `??=`: the React Compiler can't lower logical-assignment // operators and would bail this hook out of optimization. lastTime = lastTime ?? timestamp const charsToAdd = Math.floor((timestamp - lastTime) / msPerChar) if (charsToAdd > 0) { lastTime = timestamp const next = Math.min(charRef.current + charsToAdd, fullText.length) charRef.current = next setCharIndex(next) if (next >= fullText.length) return // fully revealed — stop the loop } rafId = requestAnimationFrame(tick) } rafId = requestAnimationFrame(tick) return () => cancelAnimationFrame(rafId) }, [fullText, speed, skipAnimation]) return { displayedText: skipAnimation ? fullText : fullText.slice(0, charIndex), isTyping: !skipAnimation && charIndex < fullText.length, } }