Skip to content
    Style: Wireframe

    Components · Actions

    Read aloud

    Listen to an article: the browser's own speech reads it, with pause, resume and stop. Nothing leaves the page.

    51% of the 2,073 sites in The UI Index have a button (1,063 sites).

    Field notes · 6 November

    Why the caseroom kept every letter in its own box

    A compositor reached for type without looking. Each letter had a fixed place in the case, the common ones closest to the hand.

    When a box ran empty, the job stopped. So the room was sorted again every evening, ready for the morning's first line.

    Both previews render the same markup; the HTML tab below is that markup without a framework.

    Install

    React
    npx shadcn@latest add https://www.themes.dev/ui/r/read-aloud.json
    Astro
    npx shadcn@latest add https://www.themes.dev/ui/r/astro/read-aloud.json

    Or copy the files below into your project. They need clsx, tailwind-merge, and the themes.dev UI theme. The HTML needs only the compiled CSS and the script.

    read-aloud.client.ts
    // Read aloud behaviour for Astro, HTML and React: [data-read-aloud] roots hold a play/pause button
    // ([data-read-toggle]) and a Stop button ([data-read-stop]). The text of the element in data-read-from is read by the
    // browser's speech synthesis in short chunks (long utterances stall in some browsers), so pause, resume and stop work
    // anywhere in it. One reader speaks at a time: starting one stops the others. Leaving the page stops it too.
    // Where speech synthesis is missing, the button says so and turns off.
    declare global {
      interface Window {
        __caseroomRead?: boolean;
      }
    }
    
    type State = 'idle' | 'speaking' | 'paused';
    
    const LABELS: Record<State, string> = { idle: 'Listen', speaking: 'Pause', paused: 'Resume' };
    
    /** The text of an element as sentences grouped into chunks of about 200 characters. */
    const chunks = (el: Element) => {
      const blocks = el.querySelectorAll('h1,h2,h3,h4,h5,h6,p,li,blockquote,figcaption');
      const text = (blocks.length ? [...blocks].map((b) => b.textContent ?? '') : [el.textContent ?? ''])
        .map((t) => t.replace(/\s+/g, ' ').trim())
        .filter(Boolean)
        .map((t) => (/[.!?:]$/.test(t) ? t : `${t}.`))
        .join(' ');
      const sentences = text.match(/[^.!?]+[.!?]+["')\]]*\s*|[^.!?]+$/g) ?? [];
      const out: string[] = [];
      for (const s of sentences) {
        if (out.length && out[out.length - 1].length + s.length < 200) out[out.length - 1] += s;
        else out.push(s);
      }
      return out.map((c) => c.trim()).filter(Boolean);
    };
    
    if (typeof window !== 'undefined' && !window.__caseroomRead) {
      window.__caseroomRead = true;
      let active: HTMLElement | null = null;
      let run = 0;
    
      const set = (root: HTMLElement, state: State) => {
        root.dataset.state = state;
        const label = root.querySelector<HTMLElement>('[data-read-label]');
        if (label) label.textContent = state === 'idle' ? (root.dataset.labelIdle ?? LABELS.idle) : LABELS[state];
        const stop = root.querySelector<HTMLElement>('[data-read-stop]');
        if (stop) stop.hidden = state === 'idle';
      };
    
      const stop = () => {
        run++;
        window.speechSynthesis?.cancel();
        if (active) {
          const root = active;
          active = null;
          const hadFocus = root.querySelector('[data-read-stop]')?.contains(document.activeElement);
          set(root, 'idle');
          // Stop hides itself: keep focus on the reader instead of losing it to the page.
          if (hadFocus) root.querySelector<HTMLElement>('[data-read-toggle]')?.focus();
        }
      };
    
      const start = (root: HTMLElement) => {
        const synth = window.speechSynthesis;
        const toggle = root.querySelector<HTMLButtonElement>('[data-read-toggle]');
        if (!synth || typeof SpeechSynthesisUtterance === 'undefined') {
          const label = root.querySelector<HTMLElement>('[data-read-label]');
          if (label) label.textContent = 'Not available in this browser';
          if (toggle) toggle.disabled = true;
          return;
        }
        stop();
        const source = document.querySelector(root.dataset.readFrom ?? '');
        const parts = source ? chunks(source) : [];
        if (!parts.length) return;
        const lang = root.dataset.readLang || source?.closest('[lang]')?.getAttribute('lang') || document.documentElement.lang;
        const rate = Math.min(2, Math.max(0.5, Number(root.dataset.readRate) || 1));
        const mine = ++run;
        active = root;
        set(root, 'speaking');
        parts.forEach((part, i) => {
          const u = new SpeechSynthesisUtterance(part);
          if (lang) u.lang = lang;
          u.rate = rate;
          if (i === parts.length - 1) u.onend = () => mine === run && stop();
          u.onerror = (e) => mine === run && e.error !== 'interrupted' && e.error !== 'canceled' && stop();
          synth.speak(u);
        });
      };
    
      document.addEventListener('click', (e) => {
        const target = e.target as Element;
        const root = target.closest<HTMLElement>('[data-read-aloud]');
        if (!root || e.defaultPrevented) return;
        if (target.closest('[data-read-stop]')) return stop();
        const toggle = target.closest<HTMLButtonElement>('[data-read-toggle]');
        if (!toggle || toggle.disabled) return;
        const state = (root === active && root.dataset.state) || 'idle';
        if (state === 'speaking') {
          window.speechSynthesis.pause();
          set(root, 'paused');
        } else if (state === 'paused') {
          window.speechSynthesis.resume();
          set(root, 'speaking');
        } else start(root);
      });
    
      window.addEventListener('pagehide', stop);
    }
    
    export {};
    read-aloud.tsx
    import { cn } from '@/lib/utils';
    import { buttonVariants, type ButtonVariants } from '../button/button.variants';
    import { PAUSE_PATH, PLAY_PATH, STOP_PATH, readAloud, type ReadAloudSource } from './read-aloud.variants';
    import './read-aloud.client';
    
    export interface ReadAloudProps extends ReadAloudSource {
      /** The idle label, e.g. "Listen" or "Listen to this article". Pause and Resume replace it while it reads. */
      label?: string;
      /** Estimated listening time shown after the label, e.g. "4 min". */
      duration?: string;
      variant?: ButtonVariants['variant'];
      className?: string;
    }
    
    /** Reads an element's text aloud with the browser's speech synthesis: play, pause, resume and stop. */
    export function ReadAloud({ from, lang, rate, label = 'Listen', duration, variant = 'outline', className }: ReadAloudProps) {
      const svg = { viewBox: '0 0 16 16', fill: 'none', stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true } as const;
      return (
        <div data-read-aloud="" data-read-from={from} data-read-lang={lang} data-read-rate={rate} data-label-idle={label} data-state="idle" className={cn(readAloud.root, className)}>
          <button type="button" data-read-toggle="" className={buttonVariants({ variant, size: 'sm' })}>
            <svg {...svg} className={readAloud.play}><path d={PLAY_PATH} /></svg>
            <svg {...svg} className={readAloud.pause}><path d={PAUSE_PATH} /></svg>
            <span data-read-label="">{label}</span>
            {duration && <span className={readAloud.meta}><span className="sr-only">, </span>{duration}</span>}
          </button>
          <button type="button" data-read-stop="" hidden className={cn(buttonVariants({ variant: 'ghost', size: 'icon' }), readAloud.stop)}>
            <svg {...svg}><path d={STOP_PATH} /></svg>
            <span className="sr-only">Stop reading</span>
          </button>
        </div>
      );
    }
    read-aloud.variants.ts
    /** Read aloud ("Listen to this article"): the browser's own speech reads an element's text. One button plays, pauses
     *  and resumes; Stop appears while it reads. Nothing is sent anywhere: speech synthesis runs in the browser. */
    export const readAloud = {
      // data-state on the root: idle | speaking | paused (set by read-aloud.client.ts).
      root: 'group/read inline-flex items-center gap-1',
      play: 'group-data-[state=speaking]/read:hidden',
      pause: 'hidden group-data-[state=speaking]/read:block',
      // The estimated listening time after the label, quieter than it.
      meta: 'font-normal text-muted-foreground tabular-nums group-data-[state=speaking]/read:hidden group-data-[state=paused]/read:hidden',
      stop: 'size-8',
    };
    
    export const PLAY_PATH = 'M5 3.5v9l7.5-4.5z';
    export const PAUSE_PATH = 'M5.5 3.5v9M10.5 3.5v9';
    export const STOP_PATH = 'M4.5 4.5h7v7h-7z';
    
    export type ReadAloudSource = {
      /** A CSS selector: the text of that element is read (headings, paragraphs and list items, in order). */
      from: string;
      /** BCP 47 language of the text, e.g. "nl-NL". Defaults to the nearest lang attribute. */
      lang?: string;
      /** Speaking rate, 0.5 to 2 (1 is normal). */
      rate?: number;
    };