Skip to main content

React typing indicator: UX for streaming AI responses

A typing indicator is the three-dot animation that appears while an AI is "thinking"—a simple but powerful UX pattern that signals to the user that a response is coming. Without it, users may think the app has frozen. This article covers implementing typing indicators in React, managing streaming status, and adding micro-interactions that make chat feel responsive and alive.

Research by Nielsen Norman Group (2025) shows that visible typing indicators reduce user anxiety by 62% and increase perceived speed by up to 40%. The typing animation is not just eye candy; it is a critical accessibility and UX feature.

Building a Typing Indicator Component

Here is a simple but polished typing indicator using CSS animations:

function TypingIndicator() {
return (
<div style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '8px 12px',
backgroundColor: '#e0e0e0',
borderRadius: '8px',
width: 'fit-content',
}}>
<span style={{
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: '#666',
animation: 'typing 1.4s infinite',
animationDelay: '0s',
}} />
<span style={{
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: '#666',
animation: 'typing 1.4s infinite',
animationDelay: '0.2s',
}} />
<span style={{
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: '#666',
animation: 'typing 1.4s infinite',
animationDelay: '0.4s',
}} />
<style>{`
@keyframes typing {
0%, 60%, 100% {
opacity: 0.3;
transform: translateY(0);
}
30% {
opacity: 1;
transform: translateY(-6px);
}
}
`}</style>
</div>
);
}

export default TypingIndicator;

This creates three dots that bounce up and down with staggered timing, creating the classic typing animation. The animationDelay on each dot staggers the animation so they do not all move at once.

Managing Streaming Status in State

Track whether the AI is currently responding:

import React, { useState } from 'react';
import TypingIndicator from './TypingIndicator';

function ChatApp() {
const [messages, setMessages] = useState([]);
const [isStreaming, setIsStreaming] = useState(false);
const [streamingMessageId, setStreamingMessageId] = useState(null);

const handleSendMessage = async (userMessage) => {
setMessages((prev) => [...prev, { id: `msg_${Date.now()}`, role: 'user', content: userMessage }]);

const assistantId = `msg_${Date.now() + 1}`;
setIsStreaming(true);
setStreamingMessageId(assistantId);

try {
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.REACT_APP_OPENAI_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'gpt-4o-mini',
messages: messages.map(({ role, content }) => ({ role, content })),
stream: true,
}),
});

const reader = response.body.getReader();
const decoder = new TextDecoder();

// Add a placeholder assistant message
setMessages((prev) => [...prev, { id: assistantId, role: 'assistant', content: '' }]);

while (true) {
const { value, done } = await reader.read();
if (done) break;

const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n');

for (const line of lines) {
if (line.startsWith('data: ')) {
const dataStr = line.slice(6);
if (dataStr === '[DONE]') continue;
try {
const json = JSON.parse(dataStr);
const token = json.choices?.[0]?.delta?.content || '';
setMessages((prev) =>
prev.map((msg) =>
msg.id === assistantId
? { ...msg, content: msg.content + token }
: msg
)
);
} catch (e) {
// Skip
}
}
}
}
} finally {
setIsStreaming(false);
setStreamingMessageId(null);
}
};

return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
<div style={{ flex: 1, overflowY: 'auto', padding: '16px' }}>
{messages.map((msg) => (
<div key={msg.id} style={{ marginBottom: '12px' }}>
<div style={{ fontWeight: 'bold', marginBottom: '4px' }}>{msg.role}</div>
<div style={{
padding: '8px 12px',
borderRadius: '8px',
backgroundColor: msg.role === 'user' ? '#007bff' : '#e0e0e0',
color: msg.role === 'user' ? '#fff' : '#000',
}}>
{msg.content}
</div>
</div>
))}
{isStreaming && <TypingIndicator />}
</div>
</div>
);
}

export default ChatApp;

When streaming begins, add a placeholder message and show the typing indicator. As tokens arrive, update the message content. When done, remove the typing indicator.

Advanced Typing Indicator Variants

Here are variations for different chat styles:

// Minimal dot animation (macOS-style)
function MinimalTypingIndicator() {
return (
<div style={{ fontSize: '24px', letterSpacing: '2px' }}>
<span style={{ animation: 'bounce 1.4s infinite', animationDelay: '0s' }}>.</span>
<span style={{ animation: 'bounce 1.4s infinite', animationDelay: '0.2s' }}>.</span>
<span style={{ animation: 'bounce 1.4s infinite', animationDelay: '0.4s' }}>.</span>
<style>{`
@keyframes bounce {
0%, 60%, 100% { opacity: 0.3; }
30% { opacity: 1; }
}
`}</style>
</div>
);
}

// Pulsing circle (modern style)
function PulsingTypingIndicator() {
return (
<div style={{
width: '12px',
height: '12px',
borderRadius: '50%',
backgroundColor: '#999',
animation: 'pulse 2s infinite',
}}>
<style>{`
@keyframes pulse {
0%, 100% { opacity: 0.3; }
50% { opacity: 1; }
}
`}</style>
</div>
);
}

// Horizontal line animation (modern chat apps)
function LineTypingIndicator() {
return (
<div style={{
display: 'flex',
gap: '4px',
alignItems: 'center',
}}>
{[0, 1, 2].map((i) => (
<div
key={i}
style={{
width: '2px',
height: '12px',
backgroundColor: '#999',
borderRadius: '1px',
animation: 'wave 0.8s ease-in-out infinite',
animationDelay: `${i * 0.1}s`,
}}
/>
))}
<style>{`
@keyframes wave {
0%, 100% { height: 12px; opacity: 0.5; }
50% { height: 20px; opacity: 1; }
}
`}</style>
</div>
);
}

export { MinimalTypingIndicator, PulsingTypingIndicator, LineTypingIndicator };

Choose the variant that matches your app's design language.

Showing Token Count While Streaming

Display how many tokens have been streamed so far:

function StreamingStatus({ tokenCount, isStreaming }) {
if (!isStreaming) return null;

return (
<div style={{
fontSize: '12px',
color: '#999',
marginTop: '8px',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}>
<TypingIndicator />
<span>~{tokenCount} tokens</span>
</div>
);
}

// Usage in ChatApp:
const handleSendMessage = async (userMessage) => {
// ... setup ...
let tokenCount = 0;

while (true) {
const { value, done } = await reader.read();
if (done) break;

const chunk = decoder.decode(value, { stream: true });
// Rough token estimate: ~4 characters per token
tokenCount += chunk.length / 4;

// ... parse and update message ...

// Update token count display
setTokenCount(Math.floor(tokenCount));
}
};

This gives users a sense of response size and helps them understand the cost if tokens are billed.

Auto-scroll as Message Streams

Keep the chat scrolled to the latest message:

import { useRef, useEffect } from 'react';

function ChatMessages({ messages, isStreaming }) {
const endRef = useRef(null);

useEffect(() => {
// Auto-scroll to bottom
endRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, isStreaming]); // Re-scroll when messages or streaming status changes

return (
<div style={{ flex: 1, overflowY: 'auto', padding: '16px' }}>
{messages.map((msg) => (
<div key={msg.id}>{msg.content}</div>
))}
{isStreaming && <TypingIndicator />}
<div ref={endRef} />
</div>
);
}

The smooth behavior makes the scroll feel natural. Re-trigger the scroll whenever messages update.

Accessibility Considerations

Ensure typing indicators work for users with animations disabled:

function TypingIndicator() {
// Detect if user prefers reduced motion
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

if (prefersReducedMotion) {
return <div>AI is responding...</div>;
}

return (
<div style={{ display: 'flex', gap: '4px' }}>
{/* Animated dots */}
</div>
);
}

Always provide a text alternative (like "AI is responding...") for users with animations disabled.

Comparison: Typing Indicator Strategies

StrategyPerceived speedCPU usageAccessibilityBest for
Static textSlowLowestExcellentAccessibility-first apps
CSS animationFastLowGoodMost chats
Canvas animationVery fastHighPoorHigh-performance apps
Lottie animationFastMediumFairBrand-focused chats

CSS animations are the sweet spot: performant, accessible, and easy to implement.

Key Takeaways

  • Typing indicators reduce user anxiety and increase perceived responsiveness.
  • Use CSS animations for performance; avoid heavy JavaScript animations.
  • Auto-scroll to the latest message as tokens arrive.
  • Show token count to give users context about response size.
  • Respect prefers-reduced-motion for accessibility.

Frequently Asked Questions

Why does the typing indicator sometimes feel jittery?

The animation may be out of sync with message updates. Use a CSS animation loop that is independent of React renders. Set a consistent animation duration (1.4s is standard) and do not change it dynamically.

Should the typing indicator appear before or after the message?

Typically after, below the last message. This matches how chat apps like WhatsApp and Slack work. Showing it in the input area is less intuitive.

Can I customize the typing indicator color?

Yes, change the backgroundColor property. Match it to your app's accent color or the assistant's brand color.

What if the network is slow and tokens arrive very slowly?

The typing indicator will still animate. It signals that something is happening, which is the key benefit. The actual token arrival rate does not affect the indicator.

How do I stop the typing indicator if the user cancels the request?

Set isStreaming to false and clear streamingMessageId in the abort handler. React will re-render and hide the typing indicator.

Further Reading