Fiber UI LogoFiberUI

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.json

A 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, and cancel for full control
  • Word Boundaries - Track current word position for text highlighting
  • Callbacks - onStart, onEnd, onError, onBoundary for 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.

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, 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.

Speech Synthesis is not supported in your browser.
"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

PropertyTypeDefaultDescription
voiceSpeechSynthesisVoice | nullnullThe voice to use (from voices array)
ratenumber1Speech rate from 0.1 to 10
pitchnumber1Speech pitch from 0 to 2
volumenumber1Volume 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

PropertyTypeDescription
speak(text: string) => voidSpeak the given text
cancel() => voidCancel all speech
pause() => voidPause current speech
resume() => voidResume paused speech
isSpeakingbooleanWhether speech is playing
isPausedbooleanWhether speech is paused
isSupportedbooleanWhether the API is supported
voicesSpeechSynthesisVoice[]Available voices
currentCharIndexnumberCurrent character position (for highlighting)
errorSpeechSynthesisErrorEvent | nullError event if speech failed
errorMessagestring | nullHuman-readable error message

Voice Object

Each voice in the voices array has these properties:

PropertyTypeDescription
namestringVoice name (e.g., "Samantha")
langstringBCP 47 language code (e.g., "en-US")
defaultbooleanWhether this is the default voice
localServicebooleanWhether 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

BrowserSupport
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;