๐ React Markdown typing animation component
๐จ๐ณ ไธญๆ | ๐บ๐ธ English
A React component designed for modern AI applications, providing smooth real-time typing animation and full Markdown rendering capabilities.
-
Perfect for backend streaming data
Many AI/LLM backend APIs (like OpenAI, DeepSeek, etc.) push data chunks containing multiple characters at once. Ordinary typewriter implementations may stutter or skip characters.
react-markdown-typer automatically splits each chunk into single characters and renders them smoothly one by one, ensuring a fluid typing animation no matter how many characters are pushed at once. -
Ultimate developer experience
Rich imperative API, supports streaming data, async callbacks, plugin extensions, and flexible animation/content control. -
Lightweight & high performance
Small bundle size, high performance, works on both mobile and desktop. Core dependency is react-markdown (industry-standard, mature Markdown renderer), no heavy dependencies, ready to use out of the box. -
Multi-theme & plugin architecture
compatible with react-markdown remark/rehype plugins for advanced customization and extension. -
Wide range of use cases
- AI chatbots/assistants
- Real-time Q&A/knowledge base
- Educational/math/programming content
- Product demos, interactive docs
- Any scenario needing "typewriter" animation and streaming Markdown rendering
- react-markdown-typer
- 1:1 replica of DeepSeek official site chat response effect
- Supports both
thinking
andanswer
modes - Perfectly fits streaming data, zero-latency user response
- Full Markdown support, including code highlighting, tables, lists, etc.
- Math formula rendering (KaTeX), supports
$...$
and\[...\]
syntax - Light/dark theme support for different product styles
- Plugin architecture, supports remark/rehype plugin extensions
- Supports typing interruption with
stop
and resume withresume
- Typing animation can be enabled/disabled
- Dual timer optimization: supports both
requestAnimationFrame
andsetTimeout
modes - High-frequency typing supported (with
requestAnimationFrame
, interval can be nearly0ms
) - Frame-synced rendering, perfectly matches browser refresh
- Smart batch character handling for more natural visuals
# npm
npm install react-markdown-typer
# yarn
yarn add react-markdown-typer
# pnpm
pnpm add react-markdown-typer
No installation needed, use directly in the browser:
<!-- Import the component -->
<script type="module">
import Markdown from 'https://esm.sh/react-markdown-typer';
</script>
import MarkdownTyper from 'react-markdown-typer';
import 'react-markdown-typer/style.css';
function App() {
return (
<MarkdownTyper interval={20}>
# Hello react-markdown-typer This is a **high-performance** typing animation component! ## Features - โก Zero-latency streaming - ๐ฌ Smooth typing animation - ๐ฏ Perfect syntax support
</MarkdownTyper>
);
}
import MarkdownTyper from 'react-markdown-typer';
import 'react-markdown-typer/style.css';
function StaticDemo() {
const [disableTyping, setDisableTyping] = useState(false);
return (
<div>
<button onClick={() => setDisableTyping(!disableTyping)}>{disableTyping ? 'Enable' : 'Disable'} typewriter effect</button>
<MarkdownTyper interval={20} disableTyping={disableTyping}>
# Static Display Mode When `disableTyping` is `true`, all content is shown instantly with no typing animation. This is useful for: - ๐ Static document display - ๐ Switching display modes -
โก Quick content preview
</MarkdownTyper>
</div>
);
}
import MarkdownTyper from 'react-markdown-typer';
// If you need to display formulas, import the formula plugins
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
function MathDemo() {
return (
<MarkdownTyper interval={20} reactMarkdownProps={{ remarkPlugins: [remarkMath], rehypePlugins: [rehypeKatex] }} math={{ splitSymbol: 'dollar' }}>
# Pythagorean Theorem In a right triangle, the square of the hypotenuse equals the sum of the squares of the other two sides: $a^2 + b^2 = c^2$ Where: - $a$ and $b$ are the legs - $c$ is the
hypotenuse For the classic "3-4-5 triangle": $c = \sqrt{3 ^ (2 + 4) ^ 2} = \sqrt{25} = 5$
</MarkdownTyper>
);
}
function ChatDemo() {
const [answer, setAnswer] = useState('');
const handleAsk = () => {
setAnswer(`# About React 19
React 19 brings many exciting new features:
## ๐ Major Updates
1. **React Compiler** - Automatic performance optimization
2. **Actions** - Simplified form handling
3. **Document Metadata** - Built-in SEO support
Let's explore these new features together!`);
};
return (
<div>
<button onClick={handleAsk}>Ask AI</button>
{answer && <MarkdownTyper interval={15}>{answer}</MarkdownTyper>}
</div>
);
}
import { useRef, useState } from 'react';
import { MarkdownCMD, MarkdownCMDRef } from 'react-markdown-typer';
function AdvancedCallbackDemo() {
const markdownRef = useRef<MarkdownCMDRef>(null);
const [typingStats, setTypingStats] = useState({ progress: 0, currentChar: '', totalChars: 0 });
const handleBeforeTypedChar = async (data) => {
// Async operation before typing a character
console.log('About to type:', data.currentChar);
// You can do network requests, data validation, etc. here
if (data.currentChar === '!') {
await new Promise((resolve) => setTimeout(resolve, 500)); // Simulate delay
}
};
const handleTypedChar = (data) => {
// Update typing stats
setTypingStats({
progress: Math.round(data.percent),
currentChar: data.currentChar,
totalChars: data.currentIndex + 1,
});
// Add sound effects, animations, etc. here
if (data.currentChar === '.') {
// Play period sound effect
console.log('Play period sound');
}
};
const handleStart = (data) => {
console.log('Typing started:', data.currentChar);
};
const handleEnd = (data) => {
console.log('Typing finished:', data.str);
};
const startDemo = () => {
markdownRef.current?.clear();
markdownRef.current?.push(
'# Advanced Callback Demo\n\n' +
'This example shows how to use `onBeforeTypedChar` and `onTypedChar` callbacks:\n\n' +
'- ๐ฏ **Before typing callback**: Async operations before displaying a character\n' +
'- ๐ **After typing callback**: Real-time progress updates and effects\n' +
'- โก **Performance**: Async operations without affecting typing smoothness\n\n' +
'Current progress: ' +
typingStats.progress +
'%\n' +
'Characters typed: ' +
typingStats.totalChars +
'\n\n' +
'This is a very powerful feature!',
'answer',
);
};
return (
<div>
<button onClick={startDemo}>๐ Start Advanced Demo</button>
<div style={{ margin: '10px 0', padding: '10px', background: '#f5f5f5', borderRadius: '4px' }}>
<strong>Typing Stats:</strong> Progress {typingStats.progress}% | Current char: "{typingStats.currentChar}" | Total chars: {typingStats.totalChars}
</div>
<MarkdownCMD ref={markdownRef} interval={30} onBeforeTypedChar={handleBeforeTypedChar} onTypedChar={handleTypedChar} onStart={handleStart} onEnd={handleEnd} />
</div>
);
}
import { useRef, useState } from 'react';
import { MarkdownCMD, MarkdownCMDRef } from 'react-markdown-typer';
function RestartDemo() {
const markdownRef = useRef<MarkdownCMDRef>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [hasStarted, setHasStarted] = useState(false);
const startContent = () => {
markdownRef.current?.clear();
markdownRef.current?.push(
'# Restart Animation Demo\n\n' +
'This example shows how to use the `restart()` method:\n\n' +
'- ๐ **Restart**: Play current content from the beginning\n' +
'- โธ๏ธ **Pause/Resume**: Pause and resume anytime\n' +
'- ๐ฏ **Precise control**: Full control over animation state\n\n' +
'Current state: ' +
(isPlaying ? 'Playing' : 'Paused') +
'\n\n' +
'This is a very practical feature!',
'answer',
);
setIsPlaying(true);
};
const handleStart = () => {
if (hasStarted) {
// If already started, restart
markdownRef.current?.restart();
} else {
// First start
markdownRef.current?.start();
setHasStarted(true);
}
setIsPlaying(true);
};
const handleStop = () => {
markdownRef.current?.stop();
setIsPlaying(false);
};
const handleResume = () => {
markdownRef.current?.resume();
setIsPlaying(true);
};
const handleRestart = () => {
markdownRef.current?.restart();
setIsPlaying(true);
};
const handleEnd = () => {
setIsPlaying(false);
};
return (
<div>
<div style={{ marginBottom: '10px', display: 'flex', gap: '10px', flexWrap: 'wrap' }}>
<button onClick={startContent}>๐ Start Content</button>
<button onClick={handleStart} disabled={isPlaying}>
{hasStarted ? '๐ Restart' : 'โถ๏ธ Start'}
</button>
<button onClick={handleStop} disabled={!isPlaying}>
โธ๏ธ Pause
</button>
<button onClick={handleResume} disabled={isPlaying}>
โถ๏ธ Resume
</button>
<button onClick={handleRestart}>๐ Restart</button>
</div>
<div style={{ margin: '10px 0', padding: '10px', background: '#f5f5f5', borderRadius: '4px' }}>
<strong>Animation State:</strong> {isPlaying ? '๐ข Playing' : '๐ด Paused'}
</div>
<MarkdownCMD ref={markdownRef} interval={25} onEnd={handleEnd} />
</div>
);
}
import { useRef, useState } from 'react';
import { MarkdownCMD, MarkdownCMDRef } from 'react-markdown-typer';
function StartDemo() {
const markdownRef = useRef<MarkdownCMDRef>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [hasStarted, setHasStarted] = useState(false);
const loadContent = () => {
markdownRef.current?.clear();
markdownRef.current?.push(
'# Manual Start Animation Demo\n\n' +
'This example shows how to use the `start()` method:\n\n' +
'- ๐ฏ **Manual control**: When `autoStartTyping=false`, you need to call `start()` manually\n' +
'- โฑ๏ธ **Delayed start**: Start animation after user interaction\n' +
'- ๐ฎ **Gamification**: Suitable for scenarios requiring user trigger\n\n' +
'Click the "Start Animation" button to manually trigger typing!',
'answer',
);
setIsPlaying(false);
};
const handleStart = () => {
if (hasStarted) {
// If already started, restart
markdownRef.current?.restart();
} else {
// First start
markdownRef.current?.start();
setHasStarted(true);
}
setIsPlaying(true);
};
const handleEnd = () => {
setIsPlaying(false);
};
return (
<div>
<div style={{ marginBottom: '10px', display: 'flex', gap: '10px', flexWrap: 'wrap' }}>
<button onClick={loadContent}>๐ Load Content</button>
<button onClick={handleStart} disabled={isPlaying}>
{hasStarted ? '๐ Restart' : 'โถ๏ธ Start Animation'}
</button>
</div>
<div style={{ margin: '10px 0', padding: '10px', background: '#f5f5f5', borderRadius: '4px' }}>
<strong>Status:</strong> {isPlaying ? '๐ข Animation Playing' : '๐ด Waiting to Start'}
</div>
<MarkdownCMD ref={markdownRef} interval={30} autoStartTyping={false} onEnd={handleEnd} />
</div>
);
}
import MarkdownTyper, { MarkdownCMD } from 'react-markdown-typer';
Prop | Type | Description | Default |
---|---|---|---|
interval |
number |
Typing interval (ms) | 30 |
timerType |
'setTimeout' | 'requestAnimationFrame' |
Timer type, not dynamically changeable | Default is setTimeout , will switch to requestAnimationFrame in the future |
theme |
'light' | 'dark' |
Theme type | 'light' |
math |
IMarkdownMath | Math formula config | { splitSymbol: 'dollar' } |
onEnd |
(data: EndData) => void |
Typing end callback | - |
onStart |
(data: StartData) => void |
Typing start callback | - |
onBeforeTypedChar |
(data: IBeforeTypedChar) => Promise<void> |
Callback before typing a character, supports async, blocks next typing | - |
onTypedChar |
(data: ITypedChar) => void |
Callback after each character | - |
disableTyping |
boolean |
Disable typing animation | false |
autoStartTyping |
boolean |
Whether to auto start typing animation, set false to trigger manually, not dynamically changeable | true |
Note: If
disableTyping
changes fromtrue
tofalse
during typing, all remaining characters will be displayed at once on the next typing trigger.
Prop | Type | Description | Default |
---|---|---|---|
currentIndex |
number |
Index of current character | 0 |
currentChar |
string |
Character to be typed | - |
prevStr |
string |
Prefix string of current type | - |
percent |
number |
Typing progress percent (0-100) | 0 |
Prop | Type | Description | Default |
---|---|---|---|
currentIndex |
number |
Index of current character | 0 |
currentChar |
string |
Character just typed | - |
prevStr |
string |
Prefix string of current type | - |
currentStr |
string |
Full string of current type | - |
percent |
number |
Typing progress percent (0-100) | 0 |
Prop | Type | Description | Default |
---|---|---|---|
splitSymbol |
'dollar' | 'bracket' |
Math formula delimiter type | 'dollar' |
Delimiter Explanation:
'dollar'
: Use$...$
and$$...$$
syntax'bracket'
: Use\(...\)
and\[...\]
syntax
You can pass all react-markdown props via reactMarkdownProps
to support plugins.
Method | Params | Description |
---|---|---|
start |
- | Start typing animation |
stop |
- | Pause typing animation |
resume |
- | Resume typing animation |
restart |
- | Restart typing animation from the beginning |
Method | Params | Description |
---|---|---|
push |
(content: string, answerType: AnswerType) |
Add content and start typing |
clear |
- | Clear all content and state |
triggerWholeEnd |
- | Manually trigger completion callback |
start |
- | Start typing animation |
stop |
- | Pause typing animation |
resume |
- | Resume typing animation |
restart |
- | Restart typing animation from the beginning |
Usage Example:
markdownRef.current?.start(); // Start animation
markdownRef.current?.stop(); // Pause animation
markdownRef.current?.resume(); // Resume animation
markdownRef.current?.restart(); // Restart animation
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
// 1. Enable math formula support
<MarkdownTyper reactMarkdownProps={{ remarkPlugins: [remarkMath], rehypePlugins: [rehypeKatex]}}>
# Math Formula Example
// Inline formula
This is an inline formula: $E = mc^2$
// Block formula
$$\int_{-\infty}^{\infty} e^{-x^2} dx = \sqrt{\pi}$$
</MarkdownTyper>
// Use dollar sign delimiter (default)
<MarkdownTyper
reactMarkdownProps={{ remarkPlugins: [remarkMath], rehypePlugins: [rehypeKatex]}}
math={{ splitSymbol: 'dollar' }}
>
Inline: $a + b = c$
Block: $$\sum_{i=1}^{n} x_i = x_1 + x_2 + \cdots + x_n$$
</MarkdownTyper>
// Use bracket delimiter
<MarkdownTyper
reactMarkdownProps={{ remarkPlugins: [remarkMath], rehypePlugins: [rehypeKatex]}}
math={{ splitSymbol: 'bracket' }}
>
Inline: \(a + b = c\)
Block: \[\sum_{i=1}^{n} x_i = x_1 + x_2 + \cdots + x_n\]
</MarkdownTyper>
// Perfectly supports streaming output of math formulas
const mathContent = [
'Pythagorean theorem:',
'$a^2 + b^2 = c^2$',
'\n\n',
'Where:',
'- $a$ and $b$ are the legs\n',
'- $c$ is the hypotenuse\n\n',
'For the classic "3-4-5 triangle":\n',
'$c = \\sqrt{3^2 + 4^2} = \\sqrt{25} = 5$\n\n',
'This theorem has wide applications in geometry!',
];
mathContent.forEach((chunk) => {
markdownRef.current?.push(chunk, 'answer');
});
/* Math formula style customization */
.katex {
font-size: 1.1em;
}
.katex-display {
margin: 1em 0;
text-align: center;
}
/* Dark theme adaptation */
[data-theme='dark'] .katex {
color: #e1e1e1;
}
See react-markdown
// ๐ฏ Features
- Time-driven: Calculates character count based on real elapsed time
- Batch processing: Multiple characters per frame
- Frame sync: Syncs with browser 60fps refresh rate
- High-frequency optimization: Perfect for interval < 16ms
// ๐ฏ Use cases
- Default for modern web apps
- Pursue smooth animation
- High-frequency typing (interval > 0)
- AI real-time chat
// ๐ฏ Features
- Single character: Processes one character at a time
- Fixed interval: Executes strictly by set time
- Rhythmic: Classic typewriter rhythm
- Precise control: For specific timing needs
// ๐ฏ Use cases
- Need precise timing
- Retro typewriter effect
- High compatibility scenarios
Feature | requestAnimationFrame | setTimeout |
---|---|---|
Char proc | Multiple chars per frame | One char per call |
High freq | โ Excellent (5ms โ 3 chars) | โ May stutter |
Low freq | โ Normal (100ms โ 1 char/6f) | โ Precise |
Visual | ๐ฌ Smooth animation | โก Rhythmic |
Perf cost | ๐ข Low (frame sync) | ๐ก Medium (timer) |
High frequency: use requestAnimationFrame
, low frequency: use setTimeout
import { useRef } from 'react';
import { MarkdownCMD, MarkdownCMDRef } from 'react-markdown-typer';
function StreamingChat() {
const markdownRef = useRef<MarkdownCMDRef>(null);
// Simulate AI streaming response
const simulateAIResponse = async () => {
markdownRef.current?.clear();
// Thinking phase
markdownRef.current?.push('๐ค Analyzing your question...', 'thinking');
await delay(1000);
markdownRef.current?.push('\n\nโ
Analysis complete, starting answer', 'thinking');
// Streaming answer
const chunks = [
'# React 19 New Features\n\n',
'## ๐ React Compiler\n',
'The highlight of React 19 is the introduction of **React Compiler**:\n\n',
'- ๐ฏ **Auto optimization**: No need for manual memo/useMemo\n',
'- โก **Performance boost**: Compile-time optimization, zero runtime cost\n',
'- ๐ง **Backward compatible**: No code changes needed\n\n',
'## ๐ Actions Simplify Forms\n',
'The new Actions API makes form handling easier:\n\n',
'```tsx\n',
'function ContactForm({ action }) {\n',
' const [state, formAction] = useActionState(action, null);\n',
' return (\n',
' <form action={formAction}>\n',
' <input name="email" type="email" />\n',
' <button>Submit</button>\n',
' </form>\n',
' );\n',
'}\n',
'```\n\n',
'Hope this helps! ๐',
];
for (const chunk of chunks) {
await delay(100);
markdownRef.current?.push(chunk, 'answer');
}
};
return (
<div className="chat-container">
<button onClick={simulateAIResponse}>๐ค Ask about React 19 features</button>
<MarkdownCMD ref={markdownRef} interval={10} timerType="requestAnimationFrame" onEnd={(data) => console.log('Paragraph done:', data)} />
</div>
);
}
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
function MathStreamingDemo() {
const markdownRef = useRef<MarkdownCMDRef>(null);
const simulateMathResponse = async () => {
markdownRef.current?.clear();
const mathChunks = [
'# Pythagorean Theorem Explained\n\n',
'In a right triangle, the square of the hypotenuse equals the sum of the squares of the other two sides:\n\n',
'$a^2 + b^2 = c^2$\n\n',
'Where:\n',
'- $a$ and $b$ are the legs\n',
'- $c$ is the hypotenuse\n\n',
'For the classic "3-4-5 triangle":\n',
'$c = \\sqrt{3^2 + 4^2} = \\sqrt{25} = 5$\n\n',
'This theorem has wide applications in geometry!',
];
for (const chunk of mathChunks) {
await delay(150);
markdownRef.current?.push(chunk, 'answer');
}
};
return (
<div>
<button onClick={simulateMathResponse}>๐ Explain Pythagorean Theorem</button>
<MarkdownCMD
ref={markdownRef}
interval={20}
timerType="requestAnimationFrame"
reactMarkdownProps={{ remarkPlugins: [remarkMath], rehypePlugins: [rehypeKatex] }}
math={{ splitSymbol: 'dollar' }}
/>
</div>
);
}
import { useRef, useState } from 'react';
import { MarkdownCMD, MarkdownCMDRef } from 'react-markdown-typer';
function AdvancedCallbackDemo() {
const markdownRef = useRef<MarkdownCMDRef>(null);
const [typingStats, setTypingStats] = useState({ progress: 0, currentChar: '', totalChars: 0 });
const handleBeforeTypedChar = async (data) => {
// Async operation before typing a character
console.log('About to type:', data.currentChar);
// You can do network requests, data validation, etc. here
if (data.currentChar === '!') {
await new Promise((resolve) => setTimeout(resolve, 500)); // Simulate delay
}
};
const handleTypedChar = (data) => {
// Update typing stats
setTypingStats({
progress: Math.round(data.percent),
currentChar: data.currentChar,
totalChars: data.currentIndex + 1,
});
// Add sound effects, animations, etc. here
if (data.currentChar === '.') {
// Play period sound effect
console.log('Play period sound');
}
};
const handleStart = (data) => {
console.log('Typing started:', data.currentChar);
};
const handleEnd = (data) => {
console.log('Typing finished:', data.str);
};
const startDemo = () => {
markdownRef.current?.clear();
markdownRef.current?.push(
'# Advanced Callback Demo\n\n' +
'This example shows how to use `onBeforeTypedChar` and `onTypedChar` callbacks:\n\n' +
'- ๐ฏ **Before typing callback**: Async operations before displaying a character\n' +
'- ๐ **After typing callback**: Real-time progress updates and effects\n' +
'- โก **Performance**: Async operations without affecting typing smoothness\n\n' +
'Current progress: ' +
typingStats.progress +
'%\n' +
'Characters typed: ' +
typingStats.totalChars +
'\n\n' +
'This is a very powerful feature!',
'answer',
);
};
return (
<div>
<button onClick={startDemo}>๐ Start Advanced Demo</button>
<div style={{ margin: '10px 0', padding: '10px', background: '#f5f5f5', borderRadius: '4px' }}>
<strong>Typing Stats:</strong> Progress {typingStats.progress}% | Current char: "{typingStats.currentChar}" | Total chars: {typingStats.totalChars}
</div>
<MarkdownCMD ref={markdownRef} interval={30} onBeforeTypedChar={handleBeforeTypedChar} onTypedChar={handleTypedChar} onStart={handleStart} onEnd={handleEnd} />
</div>
);
}