Accessibility · Component reference
Read aloud
An optional local voice player with paragraph navigation, rate and volume controls.
Try the live exampleResearch and our take
Our recorded research compared reader preferences and accessibility tools across several projects. For this component, these observations informed Starlit’s approach:
- ATbar: A modular reading toolbar, text size, colored reading surfaces and speech tools. Our decision: Provide a focused toolbar and an optional local voice player.
Starlit wrote the shared controller, scoped content adapters and component modules presented here. The referenced widgets are research sources, not browser dependencies. The component also draws on interface work in EvansWiki and PayCal.
Design reasoning
Provide an explicit optional reading aid while leaving the user in control of speech.
- Start with the person’s task and the barrier they may encounter.
- Use for prose with meaningful paragraph order. Offer only voices the browser reports as local.
- Keep the ordinary content and action usable before adding enhancement.
- Start, pause, move passages and stop. Try an environment without a local voice, and confirm controls explain the limitation.
Usage pattern
Use for prose with meaningful paragraph order. Offer only voices the browser reports as local.
Impact and tradeoffs
Speech can support reading and attention. Voice availability and browser behaviour vary, so reading must work when no local voice exists.
Accessibility solutions
Never start speech on page load or on focus. This is a supplementary player, not a replacement for a screen reader.
Build accessible source content first. Personal preferences supplement semantic HTML, useful names, keyboard access and visible focus.
HTML
A concrete usage example. Host these assets on your own origin if you adapt the pattern. Read the source and credits before reuse.
<link rel="stylesheet" href="/open-ui/accessibility/shared/content.css">
<script type="module"
src="/open-ui/accessibility/components/read-aloud/index.js"></script>
<article id="reading" data-sdou-a11y-content data-default-theme="dark">
<div data-sdou-a11y-prose>
<h2 id="begin">Begin with a clear purpose</h2>
<p>Readable text and useful controls belong together.</p>
<h2 id="details">Consider the details</h2>
<p>The page should remain usable before any enhancement loads.</p>
</div>
</article>
<sdou-read-aloud target="#reading"
storage-key="example.read-aloud.v1"></sdou-read-aloud>CSS
This is the component stylesheet used inside its Shadow DOM. The shared ui.css supplies its base controls and content.css adapts the reading region. An empty component stylesheet deliberately inherits the shared UI.
.player {
padding: 18px;
background: var(--sdou-ax-bg);
border: 1px solid var(--sdou-ax-line);
border-radius: 12px;
}
.player .actions {
gap: 8px;
}
.player .button {
flex: 1 1 40%;
font-size: 0.875rem;
}
progress {
width: 100%;
height: 16px;
margin-top: 20px;
accent-color: var(--sdou-ax-gold);
}
JavaScript
This is the actual component module used by the live example. Imports, the base class and shared preferences are dependencies. Keep the component folder and shared folder in their relative positions.
Read the javascript implementation (184 lines)
import { A11yComponent, range } from "../../shared/base.js?v=sdou20261029";
class ReadAloud extends A11yComponent {
static styles = new URL("./component.css?v=sdou20261029", import.meta.url).href;
render() {
this.mount(
"Listen at your own pace.",
"An optional reading voice, with clear playback controls. Your device screen reader remains available.",
'<label class="field-label" for="local-voice">Local device voice</label><select id="local-voice" aria-describedby="voice-note"></select><p class="hint" id="voice-note">Only voices marked local by your browser are offered. This component does not send text to a speech service.</p><p class="hint" data-voice-support></p><div class="player"><div class="actions"><button type="button" class="button secondary" data-previous aria-label="Previous paragraph">Previous</button><button type="button" class="button" data-play>Read aloud</button><button type="button" class="button secondary" data-next aria-label="Next paragraph">Next</button><button type="button" class="button secondary" data-stop>Stop</button></div><progress max="1" value="0" aria-label="Reading progress"></progress><p class="hint" data-position></p></div>' +
range("rate", "Reading speed", 0.7, 1.4, 0.1, ["Slower", "Faster"]) +
range("volume", "Voice volume", 0, 1, 0.05, ["Quiet", "Loud"]) +
'<p class="hint">Rate and volume changes apply to the next paragraph. Audio starts only when you choose Read aloud.</p>',
);
}
initialize() {
this.synth = window.speechSynthesis;
this.index = 0;
this.generation = 0;
this.active = false;
this.paused = false;
this.chunks = [];
const source =
this.target.querySelector("[data-sdou-a11y-prose]") || this.target;
for (const el of source.querySelectorAll("h2,h3,p,li")) {
if (
el.closest("[hidden],[aria-hidden=true]") ||
!el.getClientRects().length
)
continue;
let text = el.textContent.trim();
while (text) {
let cut = Math.min(500, text.length);
if (cut < text.length) {
const space = text.lastIndexOf(" ", cut);
if (space > 100) cut = space;
}
this.chunks.push({ element: el, text: text.slice(0, cut) });
text = text.slice(cut).trim();
}
}
this.refreshVoices();
this.on(this.synth, "voiceschanged", () => this.refreshVoices());
this.on(this.q("#local-voice"), "change", () => this.stop());
this.on(this.q("[data-play]"), "click", () => {
if (this.active && !this.paused) {
this.synth.pause();
this.paused = true;
this.say("Reading paused.");
this.updatePlayer();
} else if (this.active && this.paused) {
this.synth.resume();
this.paused = false;
this.say("Reading resumed.");
this.updatePlayer();
} else this.play();
});
this.on(this.q("[data-stop]"), "click", () => {
this.stop();
this.say("Reading stopped.");
});
this.on(this.q("[data-next]"), "click", () => this.jump(1));
this.on(this.q("[data-previous]"), "click", () => this.jump(-1));
this.updatePlayer();
}
refreshVoices() {
const selected = this.q("#local-voice").value;
this.voices =
this.synth?.getVoices().filter((voice) => voice.localService === true) ||
[];
const select = this.q("#local-voice");
select.replaceChildren();
if (!this.voices.length) {
const option = document.createElement("option");
option.textContent = "No local voice available";
select.append(option);
} else
for (const [index, voice] of this.voices.entries()) {
const option = document.createElement("option");
option.value = String(index);
option.textContent = voice.name + " · " + voice.lang;
select.append(option);
}
if ([...select.options].some((o) => o.value === selected))
select.value = selected;
select.disabled = !this.voices.length;
this.q("[data-voice-support]").textContent = this.voices.length
? "Local voice available. Choose a voice, then start reading."
: "This browser has no local speech voice available. Use your device reading tools, or enable a local voice in your system settings.";
this.updatePlayer();
}
play() {
if (!this.voices?.length || !this.chunks.length) return;
const chunk = this.chunks[this.index],
token = ++this.generation;
this.synth.cancel();
this.active = true;
this.paused = false;
const utterance = new SpeechSynthesisUtterance(chunk.text);
utterance.voice = this.voices[Number(this.q("#local-voice").value) || 0];
utterance.rate = this.preferences.state.rate;
utterance.volume = this.preferences.state.volume;
this.clearHighlight();
chunk.element.setAttribute("data-sdou-a11y-speaking", "");
utterance.onend = () => {
if (token !== this.generation) return;
this.clearHighlight();
if (this.index < this.chunks.length - 1) {
this.index++;
this.play();
} else {
this.active = false;
this.say("Reading complete.");
this.updatePlayer();
}
};
utterance.onerror = (event) => {
if (
token !== this.generation ||
event.error === "canceled" ||
event.error === "interrupted"
)
return;
this.active = false;
this.clearHighlight();
this.fail(
"The local voice could not read this passage. Your other reading controls still work.",
);
this.updatePlayer();
};
this.synth.speak(utterance);
this.say(
"Reading passage " + (this.index + 1) + " of " + this.chunks.length + ".",
);
this.updatePlayer();
}
jump(delta) {
const wasActive = this.active;
this.stop();
this.index = Math.max(
0,
Math.min(this.chunks.length - 1, this.index + delta),
);
if (wasActive) this.play();
else {
this.say("Passage " + (this.index + 1) + " selected.");
this.updatePlayer();
}
}
stop() {
this.generation++;
if (this.active) this.synth?.cancel();
this.active = false;
this.paused = false;
this.clearHighlight();
this.updatePlayer();
}
clearHighlight() {
this.target
?.querySelectorAll("[data-sdou-a11y-speaking]")
.forEach((el) => el.removeAttribute("data-sdou-a11y-speaking"));
}
updatePlayer() {
if (!this.q("[data-play]")) return;
const usable = !!this.voices?.length && !!this.chunks?.length;
this.q("[data-play]").disabled = !usable;
this.q("[data-play]").textContent = this.active
? this.paused
? "Continue"
: "Pause"
: "Read aloud";
this.q("[data-stop]").disabled = !this.active;
this.q("[data-previous]").disabled = !usable || this.index <= 0;
this.q("[data-next]").disabled =
!usable || this.index >= this.chunks.length - 1;
this.q("progress").max = Math.max(1, this.chunks?.length || 1);
this.q("progress").value = this.active ? this.index + 1 : this.index || 0;
this.q("[data-position]").textContent = this.chunks?.length
? "Passage " + ((this.index || 0) + 1) + " of " + this.chunks.length
: "No readable passages found.";
}
cleanup() {
this.stop();
}
}
customElements.define("sdou-read-aloud", ReadAloud);
PHP integration
In your own application, PHP can render this HTML on the server. Here, the example is escaped text only. The library has no PHP runner or execution endpoint. The browser owns the interaction.
<?php
// Server render trusted structure and escape authored text.
// PHP does not implement or save these browser preferences.
declare(strict_types=1);
$title = 'A readable article';
$paragraph = 'Your authored content goes here.';
$e = static fn (string $value): string =>
htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
?>
<link rel="stylesheet" href="/open-ui/accessibility/shared/content.css">
<script type="module"
src="/open-ui/accessibility/components/read-aloud/index.js"></script>
<article id="reading" data-sdou-a11y-content data-default-theme="dark">
<div data-sdou-a11y-prose>
<h2 id="begin"><?= $e($title) ?></h2>
<p><?= $e($paragraph) ?></p>
</div>
</article>
<sdou-read-aloud target="#reading"
storage-key="example.read-aloud.v1"></sdou-read-aloud>
API and dependencies
- Element
sdou-read-aloud- target
- A selector for the content region. Use a unique ID. Components sharing a target share its controller.
- storage-key
- A stable key for this project. Use the same key for controls targeting the same region. Preferences stay in browser storage when available.
- Content
data-sdou-a11y-contentmarks the target;data-sdou-a11y-proseidentifies reading copy. Usedata-default-theme="dark"for a dark initial and reset preference.- Event
sdou:a11y-changeis dispatched on the target withpreferences,paletteandreducedMotionin its detail.- Dependencies
- Modern JavaScript modules, custom elements, Shadow DOM and native form controls. Dialog, speech, clipboard and fullscreen support depend on the feature and browser.
Read shared/base.js · Read the preference controller · Read the content adapter · Read the shared control styles
Testing and limitations
Start, pause, move passages and stop. Try an environment without a local voice, and confirm controls explain the limitation.
The original library validation records automated rule checks, 320px reflow, enlarged text, text spacing, state persistence and relevant functional checks. Unresolved automated Shadow DOM reference checks are recorded separately from browser dialog checks. These historical results are not a new audit of every integration.
Production review still needs assistive technology, touch, zoom, custom styles and representative content. See our testing scope and known gaps. Report a barrier to Starlit.