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.
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 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 {};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 ("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;
};---
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';
interface Props 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'];
class?: string;
}
const { from, lang, rate, label = 'Listen', duration, variant = 'outline', class: className } = Astro.props;
const svg = { viewBox: '0 0 16 16', fill: 'none', stroke: 'currentColor', 'stroke-width': '1.5', 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'aria-hidden': 'true' };
---
<div data-read-aloud data-read-from={from} data-read-lang={lang} data-read-rate={rate} data-label-idle={label} data-state="idle" class={cn(readAloud.root, className)}>
<button type="button" data-read-toggle class={buttonVariants({ variant, size: 'sm' })}>
<svg {...svg} class={readAloud.play}><path d={PLAY_PATH} /></svg>
<svg {...svg} class={readAloud.pause}><path d={PAUSE_PATH} /></svg>
<span data-read-label>{label}</span>
{duration && <span class={readAloud.meta}><span class="sr-only">, </span>{duration}</span>}
</button>
<button type="button" data-read-stop hidden class={cn(buttonVariants({ variant: 'ghost', size: 'icon' }), readAloud.stop)}>
<svg {...svg}><path d={STOP_PATH} /></svg>
<span class="sr-only">Stop reading</span>
</button>
</div>
<script>
import './read-aloud.client';
</script>// 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 ("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;
};<article class="grid w-full max-w-md gap-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<p class="text-xs text-muted-foreground">Field notes · 6 November</p>
<div data-read-aloud data-read-from="#ex-read-text" data-label-idle="Listen" data-state="idle" class="group/read inline-flex items-center gap-1">
<button type="button" data-read-toggle class="inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,background-color,border-color,transform] select-none active:scale-[0.97] aria-busy:cursor-progress disabled:pointer-events-none disabled:opacity-50 aria-busy:disabled:opacity-80 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 border border-input bg-background hover:bg-accent hover:text-accent-foreground active:bg-pressed h-8 px-3 text-[13px]">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="group-data-[state=speaking]/read:hidden"><path d="M5 3.5v9l7.5-4.5z"></path></svg>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="hidden group-data-[state=speaking]/read:block"><path d="M5.5 3.5v9M10.5 3.5v9"></path></svg>
<span data-read-label>Listen</span>
<span class="font-normal text-muted-foreground tabular-nums group-data-[state=speaking]/read:hidden group-data-[state=paused]/read:hidden">
<span class="sr-only">, </span>
1 min
</span>
</button>
<button type="button" data-read-stop hidden class="inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,background-color,border-color,transform] select-none active:scale-[0.97] aria-busy:cursor-progress disabled:pointer-events-none disabled:opacity-50 aria-busy:disabled:opacity-80 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 hover:bg-accent hover:text-accent-foreground active:bg-pressed size-8">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4.5 4.5h7v7h-7z"></path></svg>
<span class="sr-only">Stop reading</span>
</button>
</div>
</div>
<div id="ex-read-text" class="grid gap-2">
<h3 class="text-lg font-semibold tracking-tight text-balance">Why the caseroom kept every letter in its own box</h3>
<p class="text-sm text-muted-foreground">
A compositor reached for type without looking. Each letter had a fixed place in the case, the common ones closest to the hand.
</p>
<p class="text-sm text-muted-foreground">
When a box ran empty, the job stopped. So the room was sorted again every evening, ready for the morning's first line.
</p>
</div>
</article><script type="module" src="/caseroom/r/html/read-aloud.js"></script>