Most chat UIs dump a full response the moment the stream closes, which feels abrupt after ChatGPT’s smooth character-by-character reveal. This react chatgpt typing effect tutorial walks through building that effect from scratch in React, using a real token stream and a small state machine—no animation libraries required.
Prerequisites
- Node 18+ and a React 18 project (Vite, Next.js, or plain CRA).
- Comfort with
useState,useEffect,useRef, and async generators. - A streaming LLM endpoint or the mock generator we provide below.
- TypeScript is optional; the examples use plain JS with minimal structure.
You do not need a component library. We render raw text inside a <div> and control pacing entirely in code. The goal is a predictable, abortable typewriter that works with any token source.
Project Setup
Create a component file TypewriterChat.jsx. Start with a static shell that holds messages and an input box. We will fill the streaming logic in later sections.
import { useState, useRef } from "react";
export default function TypewriterChat() {
const [messages, setMessages] = useState([]);
const [input, setInput] = useState("");
const send = async () => {
if (!input.trim()) return;
const userMsg = { role: "user", content: input };
setMessages((m) => [...m, userMsg]);
setInput("");
// streaming call goes here
};
return (
<div className="chat">
{messages.map((m, i) => (
<div key={i} className={`row ${m.role}`}>
{m.content}
</div>
))}
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && send()}
/>
</div>
);
}
At this checkpoint the UI shows user messages only. Assistant replies are stubbed. Run the dev server and confirm you can type and see your own messages appear.
Streaming Tokens from an API
A ChatGPT-style effect needs a source of incremental text. If you want a real backend, point fetch at any OpenAI-compatible endpoint. For example, n4n.ai exposes one endpoint covering 240+ models with automatic fallback on provider degradation, so you can stream without wiring multiple providers. The response is a Server-Sent-Events stream where each chunk carries a delta.content token.
For a self-contained react chatgpt typing effect tutorial, we use a mock async iterator that yields words on a timer. Swap it for fetch later.
async function* mockStream(prompt, signal) {
const reply = `Echo: ${prompt} — the quick brown fox jumps over the lazy dog.`;
for (const token of reply.split(" ")) {
if (signal?.aborted) return;
await new Promise((r) => setTimeout(r, 40));
yield token + " ";
}
}
A minimal real stream reader looks like this:
async function* realStream(prompt, signal) {
const res = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: "gpt-4o-mini",
messages: [{ role: "user", content: prompt }],
stream: true,
}),
signal,
});
const reader = res.body.getReader();
const dec = new TextDecoder();
while (true) {
const { value, done } = await reader.read();
if (done) break;
const lines = dec.decode(value).split("\n");
for (const l of lines) {
if (l.startsWith("data:")) {
const json = JSON.parse(l.slice(5));
yield json.choices[0]?.delta?.content ?? "";
}
}
}
}
The Typing State Machine
The core mistake is rendering tokens as they arrive. That produces instant text. Instead, push incoming tokens into a queue and drain it on a fixed cadence.
We keep two strings per assistant message:
full: the complete text received so far (useful for abort/resume).shown: the substring currently rendered.
Use a ref for the queue to avoid re-render churn.
function useTypewriter() {
const queueRef = useRef("");
const [shown, setShown] = useState("");
const timerRef = useRef(null);
const push = (text) => {
queueRef.current += text;
if (!timerRef.current) tick();
};
const tick = () => {
if (queueRef.current.length === 0) {
timerRef.current = null;
return;
}
const step = Math.max(1, Math.round(queueRef.current.length / 30));
const next = queueRef.current.slice(0, step);
queueRef.current = queueRef.current.slice(step);
setShown((s) => s + next);
timerRef.current = setTimeout(tick, 16);
};
const reset = () => {
queueRef.current = "";
setShown("");
clearTimeout(timerRef.current);
timerRef.current = null;
};
return { shown, push, reset };
}
This drains the queue in batches sized so long messages don’t take forever, while short ones still feel character-by-character. The 16 ms timeout approximates a requestAnimationFrame pace without layout thrash.
Rendering with a Blinking Cursor
A static text block is not enough. Add a cursor span that blinks via CSS while typing and disappears when idle.
.cursor {
display: inline-block;
width: 8px;
background: #22c55e;
margin-left: 2px;
animation: blink 1s steps(2) infinite;
}
@keyframes blink {
0% { opacity: 1; }
50% { opacity: 0; }
}
In the component, show the cursor only when the queue is non-empty or typing just finished:
const { shown, push, reset } = useTypewriter();
// inside render for the active assistant message:
<span>{shown}</span>
{isTyping && <span className="cursor"> </span>}
Why not pure CSS typing?
CSS steps() animations require knowing the final string length upfront, which breaks with streaming. The queue approach decouples receipt from display.
Handling Abort
Users hit stop. You must halt the network stream and freeze the typed text. Pass an AbortController signal into the stream and clear the typewriter timer.
const controllerRef = useRef(null);
const stop = () => {
controllerRef.current?.abort();
reset();
};
const send = async () => {
const controller = new AbortController();
controllerRef.current = controller;
// ... after creating assistant message:
try {
for await (const tok of mockStream(input, controller.signal)) {
push(tok);
}
} catch (e) {
if (e.name !== "AbortError") throw e;
}
};
Aborting leaves shown exactly where it stopped. No flicker, no lost text.
Full Component
Putting it together:
import { useState, useRef } from "react";
async function* mockStream(prompt, signal) {
const reply = `Echo: ${prompt} — the quick brown fox jumps.`;
for (const token of reply.split(" ")) {
if (signal.aborted) return;
await new Promise((r) => setTimeout(r, 40));
yield token + " ";
}
}
export default function TypewriterChat() {
const [messages, setMessages] = useState([]);
const [input, setInput] = useState("");
const queueRef = useRef("");
const [shown, setShown] = useState("");
const timerRef = useRef(null);
const [typing, setTyping] = useState(false);
const controllerRef = useRef(null);
const tick = () => {
if (queueRef.current.length === 0) {
timerRef.current = null;
setTyping(false);
return;
}
setTyping(true);
const step = Math.max(1, Math.round(queueRef.current.length / 30));
setShown((s) => s + queueRef.current.slice(0, step));
queueRef.current = queueRef.current.slice(step);
timerRef.current = setTimeout(tick, 16);
};
const push = (t) => {
queueRef.current += t;
if (!timerRef.current) tick();
};
const send = async () => {
if (!input.trim()) return;
setMessages((m) => [...m, { role: "user", content: input }]);
setMessages((m) => [...m, { role: "assistant", content: "" }]);
setShown("");
queueRef.current = "";
const controller = new AbortController();
controllerRef.current = controller;
try {
for await (const tok of mockStream(input, controller.signal)) {
push(tok);
}
} catch (e) {
if (e.name !== "AbortError") throw e;
}
};
const stop = () => {
controllerRef.current?.abort();
clearTimeout(timerRef.current);
timerRef.current = null;
setTyping(false);
};
return (
<div>
{messages.map((m, i) => (
<div key={i}>
{m.role === "assistant" && i === messages.length - 1
? shown
: m.content}
{m.role === "assistant" && i === messages.length - 1 && typing && (
<span className="cursor"> </span>
)}
</div>
))}
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && send()}
/>
<button onClick={stop}>Stop</button>
</div>
);
}
Checkpoint Outputs
After sending “hello”, the finished state looks like:
user: hello
assistant: Echo: hello — the quick brown fox jumps.
During streaming you see the green cursor blinking at the end of partially revealed text:
assistant: Echo: hello — the quick br[█]
Abort mid-way leaves the last revealed substring in place, no cursor:
assistant: Echo: hello — the quick
Tuning the Feel
Adjust the 16 ms timer and the /30 divisor to change speed. For code snippets, reveal line-by-line instead of char-by-char to avoid awkward mid-tag breaks. The same queue pattern works; just push newlines as boundaries.
If you stream from a gateway that honors provider cache-control hints, prefix repeated prompts to hit cache and reduce latency before the first token. The pattern from this react chatgpt typing effect tutorial scales to multi-turn conversations: keep full per message and only animate the last one.
You now have a streaming-ready, abortable typewriter without dependencies. Swap mockStream for your gateway call and ship.