Skip to content
Caseroom

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,062 sites in The UI Index have a button (1,054 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/caseroom/r/read-aloud.json
Astro
npx shadcn@latest add https://www.themes.dev/caseroom/r/astro/read-aloud.json

Or copy the files below into your project. They need clsx, tailwind-merge, and the Caseroom 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;
};