useSpeechSynthesis
A React hook for text-to-speech functionality using the Web Speech Synthesis API. Read text aloud with customizable voices, rate, pitch, and volume controls.
Installation
npx shadcn@latest add https://r.fiberui.com/r/hooks/use-speech-synthesis.jsonA React hook that provides text-to-speech functionality using the Web Speech Synthesis API. Perfect for accessibility features, reading assistants, voice notifications, and any application that needs to speak to users.
Source Code
View the full hook implementation in the Hook Source Code section below.
Related Hook
Need speech-to-text instead? See useSpeechRecognition.
Features
- Multiple Voices - Access all system voices with language filtering
- Rate & Pitch Controls - Customize speech speed (0.1x-10x) and pitch (0-2)
- Playback Controls -
pause,resume, andcancelfor full control - Word Boundaries - Track current word position for text highlighting
- Callbacks -
onStart,onEnd,onError,onBoundaryfor events - SSR Safe - Gracefully handles server-side rendering
Learn More
Basic Usage
The simplest usage - enter text and click speak to hear it read aloud. The hook handles all the complexity of the Speech Synthesis API.
Speech Synthesis is not supported in your browser.
"use client";
import { useState } from "react";
import { useSpeechSynthesis } from "@repo/hooks/speech/use-speech-synthesis";
import { Volume2, VolumeX, Square } from "lucide-react";
/* BASIC USAGE - Text to Speech */
export const Example1 = () => {
const [text, setText] = useState(
"Hello! I am a text-to-speech demo. You can type anything here and I will read it aloud for you.",
);
const { speak, cancel, isSpeaking, isSupported, errorMessage } =
useSpeechSynthesis();
if (!isSupported) {
return (
<div className="bg-destructive/10 text-destructive rounded-lg p-4 text-center">
<p className="font-medium">
Speech Synthesis is not supported in your browser.
</p>
</div>
);
}
return (
<div className="flex w-full max-w-md flex-col gap-4">
{/* Text Input */}
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Enter text to speak..."
className="border-input bg-background min-h-32 w-full rounded-lg border p-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{/* Controls */}
<div className="flex items-center gap-3">
<button
onClick={() => (isSpeaking ? cancel() : speak(text))}
disabled={!text.trim()}
className={`flex flex-1 items-center justify-center gap-2 rounded-lg px-4 py-3 font-medium transition-colors ${
isSpeaking
? "bg-red-500 text-white hover:bg-red-600"
: "bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
}`}
>
{isSpeaking ? (
<>
<Square className="h-4 w-4" />
Stop
</>
) : (
<>
<Volume2 className="h-4 w-4" />
Speak
</>
)}
</button>
</div>
{/* Status */}
<div className="flex items-center justify-center gap-2">
{isSpeaking ? (
<>
<div className="h-2 w-2 animate-pulse rounded-full bg-green-500" />
<span className="text-muted-foreground text-sm">
Speaking...
</span>
</>
) : (
<>
<VolumeX className="text-muted-foreground h-4 w-4" />
<span className="text-muted-foreground text-sm">
Ready
</span>
</>
)}
</div>
{/* Error Display */}
{errorMessage && (
<div className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm">
{errorMessage}
</div>
)}
</div>
);
};
Voice & Rate Controls
Customize the voice, speed, and pitch. The hook provides access to all available system voices, which vary by browser and operating system.
"use client";
import { useState } from "react";
import { useSpeechSynthesis } from "@repo/hooks/speech/use-speech-synthesis";
import { Volume2, Pause, Play, Square, User } from "lucide-react";
/* VOICE AND RATE CONTROLS */
export const Example2 = () => {
const [text, setText] = useState(
"Welcome to the voice customization demo! Adjust the voice, speed, and pitch to find your preferred settings.",
);
const [selectedVoiceIndex, setSelectedVoiceIndex] = useState(0);
const [rate, setRate] = useState(1);
const [pitch, setPitch] = useState(1);
// Get voices first without passing voice option
const { voices, isSupported, isSpeaking, isPaused, cancel, pause, resume } =
useSpeechSynthesis({
rate,
pitch,
});
console.log("voices", { voices });
// Get selected voice safely
const selectedVoice = voices[selectedVoiceIndex] || null;
// Create a new speech synthesis instance with the selected voice
const speakWithVoice = (textToSpeak: string) => {
if (!isSupported || !textToSpeak.trim()) return;
// Cancel any ongoing speech
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(textToSpeak);
if (selectedVoice) utterance.voice = selectedVoice;
utterance.rate = rate;
utterance.pitch = pitch;
window.speechSynthesis.speak(utterance);
};
if (!isSupported) {
return (
<div className="bg-destructive/10 text-destructive rounded-lg p-4 text-center">
Speech Synthesis is not supported in your browser.
</div>
);
}
return (
<div className="flex w-full max-w-md flex-col gap-4">
{/* Text Input */}
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Enter text to speak..."
className="border-input bg-background min-h-24 w-full rounded-lg border p-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{/* Voice Selector */}
<div className="flex items-center gap-2">
<User className="text-muted-foreground h-4 w-4" />
<select
value={selectedVoiceIndex}
onChange={(e) =>
setSelectedVoiceIndex(Number(e.target.value))
}
className="border-input bg-background flex-1 rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{voices.map((voice, index) => (
<option key={`${voice.name}-${index}`} value={index}>
{voice.name} ({voice.lang})
</option>
))}
</select>
</div>
{/* Rate Slider */}
<div className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Speed</span>
<span className="font-mono">{rate.toFixed(1)}x</span>
</div>
<input
type="range"
min="0.5"
max="2"
step="0.1"
value={rate}
onChange={(e) => setRate(Number(e.target.value))}
className="w-full accent-blue-500"
/>
</div>
{/* Pitch Slider */}
<div className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Pitch</span>
<span className="font-mono">{pitch.toFixed(1)}</span>
</div>
<input
type="range"
min="0.5"
max="2"
step="0.1"
value={pitch}
onChange={(e) => setPitch(Number(e.target.value))}
className="w-full accent-blue-500"
/>
</div>
{/* Controls */}
<div className="flex items-center gap-2">
<button
onClick={() => speakWithVoice(text)}
disabled={!text.trim() || isSpeaking}
className="bg-primary text-primary-foreground hover:bg-primary/90 flex flex-1 items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium disabled:opacity-50"
>
<Volume2 className="h-4 w-4" />
Speak
</button>
<button
onClick={() => (isPaused ? resume() : pause())}
disabled={!isSpeaking}
className="bg-muted hover:bg-muted/80 flex h-10 w-10 items-center justify-center rounded-lg disabled:opacity-50"
>
{isPaused ? (
<Play className="h-4 w-4" />
) : (
<Pause className="h-4 w-4" />
)}
</button>
<button
onClick={cancel}
disabled={!isSpeaking}
className="bg-muted hover:bg-muted/80 flex h-10 w-10 items-center justify-center rounded-lg disabled:opacity-50"
>
<Square className="h-4 w-4" />
</button>
</div>
{/* Status */}
<p className="text-muted-foreground text-center text-xs">
{isSpeaking
? isPaused
? "Paused"
: "Speaking..."
: `${voices.length} voices available`}
</p>
</div>
);
};
Word Highlighting
Use the onBoundary callback to track the current word being spoken. This enables karaoke-style text highlighting as the speech progresses.
"use client";
import { useState, useMemo } from "react";
import { useSpeechSynthesis } from "@repo/hooks/speech/use-speech-synthesis";
import { Volume2, Square } from "lucide-react";
const SAMPLE_TEXT = `The quick brown fox jumps over the lazy dog. This pangram contains every letter of the alphabet at least once. Speech synthesis technology has advanced significantly over the years, making it possible to create natural-sounding voices that can read any text aloud.`;
/* TEXT HIGHLIGHTING - Word boundary tracking */
export const Example3 = () => {
const [currentCharIndex, setCurrentCharIndex] = useState(0);
const { speak, cancel, isSpeaking, isSupported } = useSpeechSynthesis({
rate: 0.9,
onBoundary: (event) => {
setCurrentCharIndex(event.charIndex);
},
onEnd: () => {
setCurrentCharIndex(0);
},
});
// Split text into before, current word, and after
const highlightedText = useMemo(() => {
if (!isSpeaking || currentCharIndex === 0) {
return { before: "", current: "", after: SAMPLE_TEXT };
}
// Find word boundaries
let wordStart = currentCharIndex;
let wordEnd = currentCharIndex;
// Find start of current word
while (wordStart > 0 && SAMPLE_TEXT[wordStart - 1] !== " ") {
wordStart--;
}
// Find end of current word
while (wordEnd < SAMPLE_TEXT.length && SAMPLE_TEXT[wordEnd] !== " ") {
wordEnd++;
}
return {
before: SAMPLE_TEXT.slice(0, wordStart),
current: SAMPLE_TEXT.slice(wordStart, wordEnd),
after: SAMPLE_TEXT.slice(wordEnd),
};
}, [currentCharIndex, isSpeaking]);
if (!isSupported) {
return (
<div className="bg-destructive/10 text-destructive rounded-lg p-4 text-center">
Speech Synthesis is not supported in your browser.
</div>
);
}
return (
<div className="flex w-full max-w-md flex-col gap-4">
{/* Text Display with Highlighting */}
<div className="bg-muted/50 rounded-lg p-4">
<p className="text-muted-foreground mb-2 text-xs font-medium uppercase tracking-wide">
Text with Word Highlighting
</p>
<p className="text-sm leading-relaxed">
<span>{highlightedText.before}</span>
<span className="rounded bg-yellow-300 px-0.5 font-medium text-yellow-900 dark:bg-yellow-500/30 dark:text-yellow-200">
{highlightedText.current}
</span>
<span className="text-muted-foreground">
{highlightedText.after}
</span>
</p>
</div>
{/* Controls */}
<button
onClick={() => {
if (isSpeaking) {
cancel();
setCurrentCharIndex(0);
} else {
speak(SAMPLE_TEXT);
}
}}
className={`flex items-center justify-center gap-2 rounded-lg px-4 py-3 font-medium transition-colors ${
isSpeaking
? "bg-red-500 text-white hover:bg-red-600"
: "bg-primary text-primary-foreground hover:bg-primary/90"
}`}
>
{isSpeaking ? (
<>
<Square className="h-4 w-4" />
Stop
</>
) : (
<>
<Volume2 className="h-4 w-4" />
Read Aloud
</>
)}
</button>
{/* Info */}
<p className="text-muted-foreground text-center text-xs">
{isSpeaking
? "Watch the highlighted word as it speaks"
: "Click to see word-by-word highlighting"}
</p>
</div>
);
};
API Reference
Hook Signature
function useSpeechSynthesis(
options?: UseSpeechSynthesisOptions,
): UseSpeechSynthesisReturn;Options
| Property | Type | Default | Description |
|---|---|---|---|
voice | SpeechSynthesisVoice | null | null | The voice to use (from voices array) |
rate | number | 1 | Speech rate from 0.1 to 10 |
pitch | number | 1 | Speech pitch from 0 to 2 |
volume | number | 1 | Volume from 0 to 1 |
onStart | () => void | - | Callback when speech starts |
onEnd | () => void | - | Callback when speech ends |
onError | (error: SpeechSynthesisErrorEvent) => void | - | Callback when an error occurs |
onBoundary | (event: SpeechSynthesisEvent) => void | - | Callback at word/sentence boundaries |
Return Value
| Property | Type | Description |
|---|---|---|
speak | (text: string) => void | Speak the given text |
cancel | () => void | Cancel all speech |
pause | () => void | Pause current speech |
resume | () => void | Resume paused speech |
isSpeaking | boolean | Whether speech is playing |
isPaused | boolean | Whether speech is paused |
isSupported | boolean | Whether the API is supported |
voices | SpeechSynthesisVoice[] | Available voices |
currentCharIndex | number | Current character position (for highlighting) |
error | SpeechSynthesisErrorEvent | null | Error event if speech failed |
errorMessage | string | null | Human-readable error message |
Voice Object
Each voice in the voices array has these properties:
| Property | Type | Description |
|---|---|---|
name | string | Voice name (e.g., "Samantha") |
lang | string | BCP 47 language code (e.g., "en-US") |
default | boolean | Whether this is the default voice |
localService | boolean | Whether the voice is local or remote |
Common Patterns
Reading Page Content
const { speak, cancel } = useSpeechSynthesis();
// Read an article aloud
const readArticle = () => {
const content = document.querySelector("article")?.textContent;
if (content) speak(content);
};Accessibility Announcements
const { speak } = useSpeechSynthesis({ rate: 1.2 });
// Announce form errors
const announceError = (message: string) => {
speak(`Error: ${message}`);
};Browser Support
| Browser | Support |
|---|---|
| Chrome | ✅ Full support |
| Edge | ✅ Full support |
| Safari | ✅ Full support |
| Firefox | ✅ Full support |
Hook Source Code
import { useState, useEffect, useCallback, useRef } from "react";
/**
* Options for the useSpeechSynthesis hook
*/
export interface UseSpeechSynthesisOptions {
/** The voice to use for speech (default: system default) */
voice?: SpeechSynthesisVoice | null;
/** Speech rate from 0.1 to 10 (default: 1) */
rate?: number;
/** Speech pitch from 0 to 2 (default: 1) */
pitch?: number;
/** Speech volume from 0 to 1 (default: 1) */
volume?: number;
/** Callback when speech starts */
onStart?: () => void;
/** Callback when speech ends */
onEnd?: () => void;
/** Callback when an error occurs */
onError?: (error: SpeechSynthesisErrorEvent) => void;
/** Callback when a word boundary is reached (for highlighting) */
onBoundary?: (event: SpeechSynthesisEvent) => void;
}
/**
* Return type for useSpeechSynthesis hook
*/
export interface UseSpeechSynthesisReturn {
/** Speak the given text */
speak: (text: string) => void;
/** Cancel all speech */
cancel: () => void;
/** Pause speech */
pause: () => void;
/** Resume paused speech */
resume: () => void;
/** Whether speech is currently playing */
isSpeaking: boolean;
/** Whether speech is paused */
isPaused: boolean;
/** Whether the Speech Synthesis API is supported */
isSupported: boolean;
/** Available voices */
voices: SpeechSynthesisVoice[];
/** Current character index being spoken (for highlighting) */
currentCharIndex: number;
/** Error event if speech failed */
error: SpeechSynthesisErrorEvent | null;
/** Human-readable error message */
errorMessage: string | null;
}
/**
* Human-readable error messages for speech synthesis errors
*/
function getErrorMessage(error: SpeechSynthesisErrorEvent): string {
switch (error.error) {
case "canceled":
return "Speech was canceled.";
case "interrupted":
return "Speech was interrupted.";
case "audio-busy":
return "Audio output is busy.";
case "audio-hardware":
return "Audio hardware error occurred.";
case "network":
return "Network error during speech synthesis.";
case "synthesis-unavailable":
return "Speech synthesis is not available.";
case "synthesis-failed":
return "Speech synthesis failed.";
case "language-unavailable":
return "The specified language is not available.";
case "voice-unavailable":
return "The specified voice is not available.";
case "text-too-long":
return "The text is too long to synthesize.";
case "invalid-argument":
return "Invalid argument provided.";
case "not-allowed":
return "Speech synthesis is not allowed.";
default:
return "An unknown error occurred during speech synthesis.";
}
}
/**
* A React hook that provides text-to-speech functionality using the
* Web Speech Synthesis API.
*
* @param options - Configuration options for the hook
* @returns UseSpeechSynthesisReturn object with speak function and state
*
* @example
* ```tsx
* // Basic usage
* const { speak, cancel, isSpeaking, voices } = useSpeechSynthesis();
*
* // With options
* const { speak, voices } = useSpeechSynthesis({
* voice: voices.find(v => v.lang === 'en-GB'),
* rate: 1.2,
* pitch: 1.1
* });
*
* // Speak text
* speak("Hello, world!");
* ```
*/
export function useSpeechSynthesis(
options: UseSpeechSynthesisOptions = {},
): UseSpeechSynthesisReturn {
const {
voice = null,
rate = 1,
pitch = 1,
volume = 1,
onStart,
onEnd,
onError,
onBoundary,
} = options;
const [voices, setVoices] = useState<SpeechSynthesisVoice[]>([]);
const [isSpeaking, setIsSpeaking] = useState(false);
const [isPaused, setIsPaused] = useState(false);
const [currentCharIndex, setCurrentCharIndex] = useState(0);
const [error, setError] = useState<SpeechSynthesisErrorEvent | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const utteranceRef = useRef<SpeechSynthesisUtterance | null>(null);
// Check if API is supported
const isSupported =
typeof window !== "undefined" && "speechSynthesis" in window;
// Load available voices
useEffect(() => {
if (!isSupported) return;
const loadVoices = () => {
const availableVoices = window.speechSynthesis.getVoices();
setVoices(availableVoices);
};
// Voices may load asynchronously
loadVoices();
// Chrome requires this event listener
if (window.speechSynthesis.onvoiceschanged !== undefined) {
window.speechSynthesis.onvoiceschanged = loadVoices;
}
return () => {
if (window.speechSynthesis.onvoiceschanged !== undefined) {
window.speechSynthesis.onvoiceschanged = null;
}
};
}, [isSupported]);
// Sync speaking state with the API
useEffect(() => {
if (!isSupported) return;
const checkSpeakingState = () => {
setIsSpeaking(window.speechSynthesis.speaking);
setIsPaused(window.speechSynthesis.paused);
};
// Poll for state changes (some browsers don't fire events reliably)
const interval = setInterval(checkSpeakingState, 100);
return () => clearInterval(interval);
}, [isSupported]);
const speak = useCallback(
(text: string) => {
if (!isSupported) return;
// Cancel any ongoing speech
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text);
// Apply options
if (voice) utterance.voice = voice;
utterance.rate = Math.max(0.1, Math.min(10, rate));
utterance.pitch = Math.max(0, Math.min(2, pitch));
utterance.volume = Math.max(0, Math.min(1, volume));
// Event handlers
utterance.onstart = () => {
setIsSpeaking(true);
setIsPaused(false);
setError(null);
setErrorMessage(null);
setCurrentCharIndex(0);
onStart?.();
};
utterance.onend = () => {
setIsSpeaking(false);
setIsPaused(false);
setCurrentCharIndex(0);
onEnd?.();
};
utterance.onerror = (event) => {
// Ignore 'interrupted' and 'canceled' as they're not real errors
if (
event.error === "interrupted" ||
event.error === "canceled"
) {
setIsSpeaking(false);
setIsPaused(false);
return;
}
setError(event);
setErrorMessage(getErrorMessage(event));
setIsSpeaking(false);
setIsPaused(false);
onError?.(event);
};
utterance.onboundary = (event) => {
setCurrentCharIndex(event.charIndex);
onBoundary?.(event);
};
utteranceRef.current = utterance;
// Chrome has a bug where long texts stop after ~15 seconds
// Workaround: pause and resume periodically
window.speechSynthesis.speak(utterance);
},
[
isSupported,
voice,
rate,
pitch,
volume,
onStart,
onEnd,
onError,
onBoundary,
],
);
const cancel = useCallback(() => {
if (!isSupported) return;
window.speechSynthesis.cancel();
setIsSpeaking(false);
setIsPaused(false);
setCurrentCharIndex(0);
}, [isSupported]);
const pause = useCallback(() => {
if (!isSupported) return;
window.speechSynthesis.pause();
setIsPaused(true);
}, [isSupported]);
const resume = useCallback(() => {
if (!isSupported) return;
window.speechSynthesis.resume();
setIsPaused(false);
}, [isSupported]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (isSupported) {
window.speechSynthesis.cancel();
}
};
}, [isSupported]);
return {
speak,
cancel,
pause,
resume,
isSpeaking,
isPaused,
isSupported,
voices,
currentCharIndex,
error,
errorMessage,
};
}
export default useSpeechSynthesis;
useSpeechRecognition
A React hook for speech-to-text functionality using the Web Speech Recognition API. Convert voice to text in real-time with support for multiple languages, continuous listening, and interim results.
Intro to WebRTC Hooks
A comprehensive guide to the FiberUI WebRTC React Hooks stack. Learn how to build production-ready real-time communication apps with composable React hooks.