Table of Contents
1. Introduction to Web Speech API
The Web Speech API provides two interfaces: SpeechRecognition for speech-to-text and SpeechSynthesis for text-to-speech. We'll focus on SpeechSynthesis, which lets browsers convert text into spoken audio using built-in or downloadable voices.
Browser Support
- Chrome: Full support (including multiple languages)
- Edge: Full support (uses same engine as Chrome)
- Firefox: Supported with some voice limitations
- Safari: Supported on macOS and iOS
Key Interfaces
// The SpeechSynthesis interface
window.speechSynthesis // The main controller
.getVoices() // Get available voices
.speak(utterance) // Start speaking
.pause() // Pause
.resume() // Resume
.cancel() // Stop
// SpeechSynthesisUtterance - what to speak
const utterance = new SpeechSynthesisUtterance('Hello world');
utterance.voice = voice; // Set the voice
utterance.rate = 1; // Speed (0.1 to 10)
utterance.pitch = 1; // Pitch (0 to 2)
utterance.volume = 1; // Volume (0 to 1)
utterance.lang = 'en-US'; // Language code
Note: Voices load asynchronously. On Chrome, you may need to wait for the voiceschanged event before accessing the full list of available voices.
2. Project Setup
Create a new React project with Vite and set up the project structure.
npm create vite@latest text-to-speech -- --template react
cd text-to-speech
npm install
Project Structure
text-to-speech/
├── src/
│ ├── components/
│ │ ├── TextInput.jsx
│ │ ├── PlayerControls.jsx
│ │ ├── VoiceSelector.jsx
│ │ ├── SpeedPitchControls.jsx
│ │ ├── HighlightedText.jsx
│ │ └── SavedTexts.jsx
│ ├── hooks/
│ │ └── useSpeechSynthesis.js
│ ├── App.jsx
│ ├── App.css
│ └── main.jsx
└── package.json
Tip: No external dependencies are needed for this project — the Web Speech API is built into modern browsers. This keeps our bundle size minimal.
3. Basic Text to Speech
Let's start with the core functionality — reading text aloud using window.speechSynthesis.
The useSpeechSynthesis Hook
// src/hooks/useSpeechSynthesis.js
import { useState, useEffect, useCallback, useRef } from 'react';
export function useSpeechSynthesis() {
const [voices, setVoices] = useState([]);
const [isSpeaking, setIsSpeaking] = useState(false);
const [isPaused, setIsPaused] = useState(false);
const [currentWordIndex, setCurrentWordIndex] = useState(-1);
const utteranceRef = useRef(null);
useEffect(() => {
const loadVoices = () => {
const availableVoices = window.speechSynthesis.getVoices();
setVoices(availableVoices);
};
loadVoices();
window.speechSynthesis.addEventListener('voiceschanged', loadVoices);
return () => {
window.speechSynthesis.removeEventListener('voiceschanged', loadVoices);
};
}, []);
const speak = useCallback((text, options = {}) => {
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text);
if (options.voice) utterance.voice = options.voice;
if (options.rate) utterance.rate = options.rate;
if (options.pitch) utterance.pitch = options.pitch;
if (options.volume) utterance.volume = options.volume;
utterance.onstart = () => {
setIsSpeaking(true);
setIsPaused(false);
};
utterance.onend = () => {
setIsSpeaking(false);
setIsPaused(false);
setCurrentWordIndex(-1);
};
utterance.onboundary = (event) => {
if (event.name === 'word') {
const wordIndex = text.substring(0, event.charIndex)
.split(/\s+/).length - 1;
setCurrentWordIndex(wordIndex);
}
};
utterance.onerror = (event) => {
console.error('Speech error:', event);
setIsSpeaking(false);
setIsPaused(false);
};
utteranceRef.current = utterance;
window.speechSynthesis.speak(utterance);
}, []);
const pause = useCallback(() => {
window.speechSynthesis.pause();
setIsPaused(true);
}, []);
const resume = useCallback(() => {
window.speechSynthesis.resume();
setIsPaused(false);
}, []);
const stop = useCallback(() => {
window.speechSynthesis.cancel();
setIsSpeaking(false);
setIsPaused(false);
setCurrentWordIndex(-1);
}, []);
return {
voices,
isSpeaking,
isPaused,
currentWordIndex,
speak,
pause,
resume,
stop,
};
}
Browser quirk: Some browsers limit the total amount of text that can be spoken at once. For very long texts, you may need to split the text into chunks and queue them.
4. Building the UI
We'll create a clean interface with a text area, playback controls, and visual feedback.
PlayerControls Component
// src/components/PlayerControls.jsx
export default function PlayerControls({ isSpeaking, isPaused, onPlay, onPause, onResume, onStop }) {
return (
<div className="player-controls">
{!isSpeaking ? (
<button className="control-btn play-btn" onClick={onPlay}>
<i className="fas fa-play"></i>
<span>Play</span>
</button>
) : isPaused ? (
<button className="control-btn resume-btn" onClick={onResume}>
<i className="fas fa-play"></i>
<span>Resume</span>
</button>
) : (
<button className="control-btn pause-btn" onClick={onPause}>
<i className="fas fa-pause"></i>
<span>Pause</span>
</button>
)}
<button
className="control-btn stop-btn"
onClick={onStop}
disabled={!isSpeaking}
>
<i className="fas fa-stop"></i>
<span>Stop</span>
</button>
</div>
);
}
Putting It Together in App.jsx
// src/App.jsx
import { useState } from 'react';
import TextInput from './components/TextInput';
import PlayerControls from './components/PlayerControls';
import VoiceSelector from './components/VoiceSelector';
import SpeedPitchControls from './components/SpeedPitchControls';
import HighlightedText from './components/HighlightedText';
import { useSpeechSynthesis } from './hooks/useSpeechSynthesis';
import './App.css';
function App() {
const [text, setText] = useState('Hello! This is a text to speech demo built with React and the Web Speech API.');
const [selectedVoice, setSelectedVoice] = useState(null);
const [rate, setRate] = useState(1);
const [pitch, setPitch] = useState(1);
const { voices, isSpeaking, isPaused, currentWordIndex, speak, pause, resume, stop } =
useSpeechSynthesis();
const handlePlay = () => {
speak(text, { voice: selectedVoice, rate, pitch });
};
return (
<div className="app">
<header className="app-header">
<h1>🔊 Text to Speech</h1>
<p>Convert your text into natural-sounding speech</p>
</header>
<main className="tts-main">
<TextInput text={text} onChange={setText} />
<div className="controls-section">
<VoiceSelector
voices={voices}
selected={selectedVoice}
onSelect={setSelectedVoice}
/>
<SpeedPitchControls
rate={rate}
pitch={pitch}
onRateChange={setRate}
onPitchChange={setPitch}
/>
<PlayerControls
isSpeaking={isSpeaking}
isPaused={isPaused}
onPlay={handlePlay}
onPause={pause}
onResume={resume}
onStop={stop}
/>
</div>
<HighlightedText
text={text}
currentWordIndex={currentWordIndex}
isSpeaking={isSpeaking}
/>
</main>
</div>
);
}
export default App;
5. Voice Selection
The available voices vary by browser and operating system. We'll create a selector that groups voices by language.
// src/components/VoiceSelector.jsx
import { useMemo } from 'react';
export default function VoiceSelector({ voices, selected, onSelect }) {
const groupedVoices = useMemo(() => {
const groups = {};
voices.forEach((voice) => {
const lang = voice.lang.split('-')[0];
if (!groups[lang]) groups[lang] = [];
groups[lang].push(voice);
});
return groups;
}, [voices]);
const languages = Object.keys(groupedVoices).sort();
return (
<div className="voice-selector">
<label htmlFor="voice-select">
<i className="fas fa-microphone"></i> Voice
</label>
<select
id="voice-select"
value={selected?.name || ''}
onChange={(e) => {
const voice = voices.find((v) => v.name === e.target.value);
onSelect(voice);
}}
>
<option value="">Default Voice</option>
{languages.map((lang) => (
<optgroup key={lang} label={lang.toUpperCase()}>
{groupedVoices[lang].map((voice) => (
<option key={voice.name} value={voice.name}>
{voice.name} ({voice.lang})
{voice.localService ? ' — Local' : ' — Remote'}
</option>
))}
</optgroup>
))}
</select>
</div>
);
}
Voice types: localService: true means the voice is installed on the device and works offline. Remote voices may have slightly higher quality but require an internet connection.
6. Speed & Pitch Controls
Customize the speech output with rate (speed) and pitch sliders.
// src/components/SpeedPitchControls.jsx
export default function SpeedPitchControls({ rate, pitch, onRateChange, onPitchChange }) {
return (
<div className="speed-pitch-controls">
<div className="control-group">
<label htmlFor="rate-slider">
<i className="fas fa-tachometer-alt"></i>
Speed: {rate.toFixed(1)}x
</label>
<input
id="rate-slider"
type="range"
min="0.1"
max="3"
step="0.1"
value={rate}
onChange={(e) => onRateChange(parseFloat(e.target.value))}
className="slider"
/>
<div className="slider-labels">
<span>0.1x</span>
<span>1x (normal)</span>
<span>3x</span>
</div>
</div>
<div className="control-group">
<label htmlFor="pitch-slider">
<i className="fas fa-music"></i>
Pitch: {pitch.toFixed(1)}
</label>
<input
id="pitch-slider"
type="range"
min="0"
max="2"
step="0.1"
value={pitch}
onChange={(e) => onPitchChange(parseFloat(e.target.value))}
className="slider"
/>
<div className="slider-labels">
<span>0 (low)</span>
<span>1 (normal)</span>
<span>2 (high)</span>
</div>
</div>
</div>
);
}
Slider Styling
.slider {
width: 100%;
height: 6px;
-webkit-appearance: none;
appearance: none;
background: rgba(99, 102, 241, 0.2);
border-radius: 3px;
outline: none;
cursor: pointer;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: #6366f1;
cursor: pointer;
border: 2px solid #1a1a2e;
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.4);
}
.slider::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: #6366f1;
cursor: pointer;
border: 2px solid #1a1a2e;
}
.slider-labels {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: #94a3b8;
margin-top: 0.25rem;
}
UX Tip: Show the current value next to the label (e.g., "Speed: 1.5x") so users immediately see the effect of their adjustment without having to remember the slider position.
7. Highlighting Spoken Words
We'll use the onboundary event of SpeechSynthesisUtterance to highlight each word as it's spoken.
// src/components/HighlightedText.jsx
import { useMemo } from 'react';
export default function HighlightedText({ text, currentWordIndex, isSpeaking }) {
const words = useMemo(() => text.split(/(\s+)/), [text]);
if (!text.trim()) {
return (
<div className="highlighted-text empty">
<p>Enter some text above to see it highlighted as it's read aloud.</p>
</div>
);
}
let wordCounter = -1;
return (
<div className="highlighted-text">
<h3>
<i className="fas fa-eye"></i> Live Preview
</h3>
<p className="text-display">
{words.map((segment, index) => {
if (/^\s+$/.test(segment)) {
return <span key={index}>{segment}</span>;
}
wordCounter++;
const wordIndex = wordCounter;
const isHighlighted = isSpeaking && wordIndex === currentWordIndex;
const isSpoken = isSpeaking && wordIndex < currentWordIndex;
return (
<span
key={index}
className={`word ${isHighlighted ? 'highlighted' : ''} ${isSpoken ? 'spoken' : ''}`}
>
{segment}
</span>
);
})}
</p>
</div>
);
}
Highlighting CSS
.highlighted-text {
background: #1a1a2e;
border-radius: 12px;
padding: 1.5rem;
margin-top: 1.5rem;
}
.text-display {
font-size: 1.1rem;
line-height: 2;
}
.word {
padding: 2px 4px;
border-radius: 4px;
transition: all 0.15s ease;
}
.word.highlighted {
background: #6366f1;
color: white;
border-radius: 4px;
transform: scale(1.05);
display: inline-block;
}
.word.spoken {
color: #94a3b8;
}
How it works: The onboundary event fires at word boundaries with a charIndex. We convert this character index to a word index and use it to highlight the current word in the UI.
8. Saving Favorite Texts
Let users save frequently-used texts to localStorage for quick access.
// src/components/SavedTexts.jsx
import { useState, useEffect } from 'react';
export default function SavedTexts({ onSelect }) {
const [savedTexts, setSavedTexts] = useState(() => {
const saved = localStorage.getItem('tts-saved-texts');
return saved ? JSON.parse(saved) : [];
});
const [newName, setNewName] = useState('');
useEffect(() => {
localStorage.setItem('tts-saved-texts', JSON.stringify(savedTexts));
}, [savedTexts]);
const saveText = (text) => {
if (!newName.trim() || !text.trim()) return;
setSavedTexts((prev) => [
...prev,
{ id: Date.now(), name: newName.trim(), text },
]);
setNewName('');
};
const deleteText = (id) => {
setSavedTexts((prev) => prev.filter((t) => t.id !== id));
};
return (
<div className="saved-texts">
<h3><i class="fas fa-bookmark"></i> Saved Texts</h3>
<div className="save-form">
<input
type="text"
placeholder="Name this text..."
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
</div>
{savedTexts.length === 0 ? (
<p className="empty-msg">No saved texts yet.</p>
) : (
<ul className="saved-list">
{savedTexts.map((item) => (
<li key={item.id}>
<button className="saved-name" onClick={() => onSelect(item.text)}>
{item.name}
</button>
<button className="delete-btn" onClick={() => deleteText(item.id)}>
<i className="fas fa-times"></i>
</button>
</li>
))}
</ul>
)}
</div>
);
}
Extension idea: Add an import/export feature so users can share their saved texts as JSON files. This is useful for accessibility tools used across devices.
9. Responsive Design & Polish
Make the app look great on all devices with mobile-first CSS and dark mode support.
Mobile-First Layout
.app {
min-height: 100vh;
background: #0f0f1a;
color: #e2e8f0;
}
.app-header {
text-align: center;
padding: 3rem 1rem 2rem;
}
.tts-main {
max-width: 800px;
margin: 0 auto;
padding: 0 1rem 3rem;
}
/* Text Input */
.text-input {
width: 100%;
min-height: 150px;
padding: 1rem;
background: #1a1a2e;
border: 2px solid rgba(99, 102, 241, 0.2);
border-radius: 12px;
color: #e2e8f0;
font-size: 1rem;
font-family: 'Inter', sans-serif;
line-height: 1.7;
resize: vertical;
outline: none;
transition: border-color 0.3s;
}
.text-input:focus {
border-color: #6366f1;
}
/* Controls Section */
.controls-section {
display: flex;
flex-direction: column;
gap: 1.5rem;
margin-top: 1.5rem;
}
/* Player Controls */
.player-controls {
display: flex;
gap: 0.75rem;
justify-content: center;
}
.control-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.875rem 1.5rem;
border: none;
border-radius: 50px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.play-btn, .resume-btn {
background: #6366f1;
color: white;
}
.play-btn:hover, .resume-btn:hover {
background: #4f46e5;
transform: scale(1.05);
}
.pause-btn {
background: #f59e0b;
color: #1a1a2e;
}
.stop-btn {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
border: 1px solid rgba(239, 68, 68, 0.3);
}
.stop-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
/* Voice Selector */
.voice-selector select {
width: 100%;
padding: 0.75rem 1rem;
background: #1a1a2e;
border: 2px solid rgba(99, 102, 241, 0.2);
border-radius: 8px;
color: #e2e8f0;
font-size: 0.95rem;
outline: none;
cursor: pointer;
}
.voice-selector label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #cbd5e1;
}
/* Dark mode is default, add light mode toggle */
@media (prefers-color-scheme: light) {
.app {
background: #f8fafc;
color: #1e293b;
}
.text-input, .voice-selector select, .highlighted-text {
background: white;
border-color: rgba(99, 102, 241, 0.3);
color: #1e293b;
}
}
/* Responsive */
@media (max-width: 640px) {
.player-controls {
flex-direction: column;
}
.control-btn {
justify-content: center;
}
.app-header h1 {
font-size: 1.75rem;
}
}
Accessibility: Text-to-speech is itself an accessibility feature. Make sure your app is keyboard-navigable and that all controls have proper ARIA labels for screen reader users.
This project demonstrates how the Web Speech API can be integrated into a React application to create a powerful accessibility tool. The key takeaways are managing the speech lifecycle with custom hooks, handling asynchronous voice loading, and providing visual feedback through word highlighting. You can extend this with language translation, document reading, or a Chrome extension for reading any webpage aloud.