A script tag for any site, a small JavaScript API when you want control, and working examples for React, Next.js, Vue and Node/Express. No SDK to install, no build step, and the free plan works on localhost.
About five minutes from signup to a working listen button.
1
Step 1: Create an account
Sign up free. No credit card. The free plan includes 100,000 new characters of audio a month; replays of cached audio don't count.
2
Step 2: Add your domain and copy your key
In the dashboard, create an API key and add the domain you'll embed on (for example example.com). The key only works on the domains you add. localhost and 127.0.0.1 always work, so you can build locally with the same key.
3
Step 3: Add the script tag
Paste it anywhere in your template (before </body> is typical). Keep async: it never holds up your page, even if ReadingVox were slow or unreachable.
Reload the page. A ReadingVox button appears at the bottom right; press play and each word is highlighted as it's read. Add data-debug="true" while you test to see what the widget found in the console.
How it behaves on your site
Loads once, stays quiet. The script is about 50 KB compressed. Tools load the first time the toolbar opens. Nothing is sent to our API until a visitor opens the toolbar, selects text or presses play.
Sends only what's read. When a visitor listens, the widget sends the text being read and your page address, plus which tools were used (with a per-page-view session id). No cookies; a visitor's settings stay in their browser.
Audio is cached by sentence. Each sentence is generated once and replayed from cache, for every visitor. Only new sentences count toward your plan, so editing a page only regenerates what changed.
Keys are public but locked. Your key is visible in your HTML, which is expected. It only works on the domains you add in the dashboard, so it can't be used on someone else's site.
It never breaks your page. Past your monthly allowance, or if our API is unreachable, new sentences are read with the visitor's built-in browser voice instead.
Script tag options
Add any of these to the script tag. The same settings exist as JavaScript options (below).
Attribute
Values
Default
What it does
data-key
your API key
(required)
The API key from the dashboard.
data-voice
heart | michael | lily | leo | mia | theo
heart
Default voice. Visitors can still pick another in Settings.
data-speed
0.5 – 2
1
Starting reading speed.
data-lang
auto | es | fr | it | pt | hi | ja | zh | any language code
auto
Language of your content. auto reads the page's lang attribute; natural voices for 7 languages, the browser voice for others.
data-ui-lang
auto | es | ar | zh | vi | … (29 languages)
auto
Language of the toolbar itself. auto uses the visitor's browser language; visitors can change it in Settings.
data-documents
true | false
false
Opt-in: add a Listen button after links to PDFs on your site; it opens the PDF in a reader view and reads it aloud.
data-tools
comma-separated tool ids
all tools
Which toolbar tools to show, e.g. data-tools="dictionary,translate,fontSize,settings".
data-mode
highlight | page | block
highlight
highlight: select text to hear it, or play the page. page: play reads the whole page. block: a play button on each content block.
data-selectors
comma-separated CSS selectors
article, main, [role=main], .content
Where the readable content lives.
data-position
top | bottom
top
Toolbar position.
data-toggle-position
bottom-right | bottom-left | top-right | top-left
bottom-right
Where the floating listen button sits.
data-show-toggle
true | false
true
Show the floating listen button.
data-pulse
true | false
true
Pulse the listen button for first-time visitors.
data-always-show
true | false
false
Show the toolbar as soon as the page loads.
data-theme
auto | light | dark
auto
Player light/dark theme.
data-color
hex color, e.g. #2563eb
ReadingVox blue
Brand color for the player.
data-preset
canvas | moodle | none
auto-detect
LMS preset (Institution plan). none turns off auto-detection.
data-auto-init
true | false
true
Set false to create the widget yourself with new ReadingVox({...}).
data-debug
true
off
Log setup details (content found, errors) to the browser console.
data-frames
true | false
true
Read iframes as part of the page (see Iframes). Set false to ignore them.
data-frame-origins
origins, comma-separated
none
Extra sites allowed to exchange content with this page in an iframe, beyond your own root domain, e.g. https://partner.example.org.
Besides the floating corner button, you can place a "Listen to this page" button anywhere in your layout, such as under the title or in the footer. Add an empty data-readingvox-button element and the widget fills it in.
index.html
<!-- Put a placeholder wherever you want a "Listen to this page" button -->
<article>
<h1>Lesson 4: Flight</h1>
<div data-readingvox-button data-toolbar="true"></div>
…
</article>
<footer>
<!-- Opens the toolbar instead of playing straight away -->
<div data-readingvox-button data-size="small" data-label="Listen" data-action="toolbar"></div>
</footer>
<!-- Only the placed buttons, no floating corner button: -->
<script src="https://readingvox.com/widget.js" data-key="YOUR_API_KEY" data-show-toggle="false" async></script>
Attribute (on the placeholder)
What it does
data-readingvox-button
Marks the placeholder. Use a div or span, not a button.
data-label="Listen"
Your own wording for the button. Default: "Listen to this page", in the visitor's toolbar language.
data-size="small"
A smaller button, for footers and tight spaces.
data-toolbar="true"
Adds a ⋯ beside it that opens the full reading toolbar.
data-action="toolbar"
Clicking opens the reading toolbar (with its play button) instead of reading the page right away.
By default it reads the whole page from the top, and shows its state: Loading, then Pause with a progress bar along the bottom, Resume when paused, and back to Listen when finished.
Every placed button controls the same playback, and the toolbar's player stays in step with them. Buttons your app renders later (React, Vue, single-page apps) are picked up automatically.
It follows data-color and data-theme and renders in its own shadow root, so your site's CSS doesn't restyle it. Style the placeholder element itself to position it (margins, alignment).
Keep the floating button too (the default) so visitors can still reach the tools, or hide it with data-show-toggle="false" and add data-toolbar="true" to a placed button.
Inside an iframe that joins the page around it (see Iframes), placeholders stay empty: the outer page's buttons read the frame.
Choosing what's read
By default the widget reads article, main, [role="main"] or .content, and skips navigation, footers, asides, alerts and .no-read. Point it at your content with data-selectors (or the selectors option), then fine-tune in your markup:
article.html
<article lang="en">
<h1>Field trip to the science museum</h1>
<!-- Skipped: anything with class="no-read" -->
<div class="share-buttons no-read">Share · Print · Email</div>
<!-- Say it differently: the page shows "NASA", the voice says the words -->
<p>
We'll visit the <span data-rv-say="National Aeronautics and Space Administration">NASA</span> exhibit.
</p>
<!-- Read in Spanish with a Spanish voice -->
<p lang="es">Los estudiantes deben traer almuerzo.</p>
</article>
class="no-read" skips an element and everything inside it.
data-rv-say changes what's spoken without changing the page. For words you use everywhere (product names, acronyms), use the pronunciation list in the dashboard instead.
lang on the page or an element picks the reading voice: natural voices for Spanish, French, Italian, Portuguese, Hindi, Japanese and Chinese, the browser voice for other languages.
Math written in MathML (including MathJax and KaTeX output) is read as spoken math.
Hidden content is skipped: hidden, display:none, visibility:hidden, aria-hidden="true" and inert, so inactive tabs, steps and slides aren't read.
Never read: scripts and styles, and the fallback text inside <video>, <audio>, <canvas>, <object> and <iframe> tags ("Your browser does not support the video tag"). Iframe content is read in place; see Iframes.
Text that only exists inside an image is read only if the image has alt text.
Changing and step-by-step content
Quizzes, assessments, wizards, tabs, slide decks and single-page apps change what's on screen without loading a new page. ReadingVox reads what the visitor is looking at when they press Play.
Play reads the current step. Each time Play starts, the widget looks at the page again. If the content changed since the last read (a new question, another tab, the next slide), it reads the new content from the start. Pausing and resuming on the same step picks up where it left off.
Hidden steps are skipped, whether your app removes them or hides them (hidden, display:none, aria-hidden). Hide inactive steps one of those ways rather than moving them off-screen, which screen readers would read as well.
Nothing to wire up for the reading itself. Re-creating the widget per route (React / Vue below) still works and also resets the toolbar between pages.
Content that arrives later (API data, infinite scroll) is picked up with observeContent: true.
quiz.html
<!-- A quiz, wizard, tabs or slide deck: one step showing, the rest hidden -->
<main>
<section class="step">Question 1: How do you like to solve problems?</section>
<section class="step" hidden>Question 2: …</section> <!-- skipped -->
<section class="step" style="display:none">Question 3: …</section> <!-- skipped -->
<section class="step" aria-hidden="true">Question 4: …</section> <!-- skipped -->
</main>
<!-- Show the next step (or render it in place, as React does) and Play reads that step -->
Iframes
Content inside iframes is read in its place on the page, with word highlighting, the selection menu and click-and-listen working inside the frame. What's needed depends on where the framed page lives.
Framed page
Example
What to do
Same origin as the page
/quizzes/flight-check
Nothing. The page's widget reads it directly.
Another subdomain of your root domain
media.example.com
Add the same script tag to the framed page. It detects the page around it and joins it in frame mode.
Another domain you control
partner.example.org
Add the script to the framed page and list each site in the other's data-frame-origins.
Someone else's site
YouTube, Google Forms, an LMS you don't host
Not readable: browsers don't let one site read another's page.
lesson.html
<!-- https://www.example.com/lesson: the page visitors open -->
<script src="https://readingvox.com/widget.js" data-key="YOUR_API_KEY" async></script>
<main>
<h1>Lesson 4: Flight</h1>
<!-- Same origin: read automatically, nothing to add inside -->
<iframe src="/quizzes/flight-check"></iframe>
<!-- Another subdomain: add the script inside that page too (below) -->
<iframe src="https://media.example.com/notes/42"></iframe>
</main>
notes.html (framed)
<!-- https://media.example.com/notes/42: the framed page -->
<!-- Same snippet. Framed by a page that runs ReadingVox, it shows no button and
reads through that page; opened on its own, it works as a normal page. -->
<script src="https://readingvox.com/widget.js" data-key="YOUR_API_KEY" async></script>
<!-- Framed by a different domain you trust? List that origin on both pages: -->
<script src="https://readingvox.com/widget.js" data-key="YOUR_API_KEY"
data-frame-origins="https://partner.example.org" async></script>
Frame mode
One player. The framed copy shows no button or toolbar. The page around it plays everything, so visitors never hear two voices, and all audio counts against the page's key.
No extra domains to add. The framed copy makes no API calls of its own, so its domain doesn't need adding to your key.
Works on its own too. Opened directly, or framed by a page without ReadingVox, the framed page shows its own button as usual, after a short check (about 1.5 seconds) for a page around it. If the outer page starts ReadingVox later (for example with data-auto-init="false"), it invites the frame when it starts, and the frame's button goes away.
Locked to your sites. Pages only exchange content with frames on the same root domain (over https) or listed in data-frame-origins, through the browser's postMessage with an exact origin. Skip one iframe with class="no-read" on it, or turn iframe reading off with data-frames="false".
Root domains
go.example.com, media.example.com and example.com share a root domain. Country domains count one level deeper (www.school.co.uk and media.school.co.uk). For anything unusual, list the origins in data-frame-origins.
Plain JavaScript
Turn off auto-start with data-auto-init="false" and create the widget yourself. Useful for your own play button or for passing callbacks.
index.html
<!-- Define the init function first, then load the widget async; it calls initReadingVox when ready -->
<script>
function initReadingVox() {
// async scripts can arrive before the page has finished loading
if (document.readyState === "loading") {
return document.addEventListener("DOMContentLoaded", initReadingVox);
}
var rv = new ReadingVox({
apiKey: "YOUR_API_KEY",
voice: "heart",
mode: "page",
selectors: ["article"],
onReady: function () { console.log("ReadingVox ready"); },
onError: function (err) { console.warn("ReadingVox:", err.message); },
});
rv.initialize();
// Optional: your own button
document.querySelector("#listen").addEventListener("click", function () {
rv.isPlaying() ? rv.pause() : rv.play();
});
}
</script>
<script src="https://readingvox.com/widget.js" data-auto-init="false" async onload="initReadingVox()"></script>
One instance per page
Create one ReadingVox per page view. To start over (new content, new options), call destroy() first; it removes the button, toolbar and highlights it added.
React
Single-page apps change content without a page load, so create the widget after each route renders and destroy it when the route changes. This small loader and hook do that, and work with React 18 Strict Mode.
1. Add the loader (shared by React, Next.js and Vue)
It adds the script tag once and includes TypeScript types for the options and methods. Drop the types if you use plain JavaScript.
src/readingvox.ts
// readingvox.ts — load the widget script once, then create instances yourself
export type ReadingVoxVoice = "heart" | "michael" | "lily" | "leo" | "mia" | "theo"
export interface ReadingVoxOptions {
apiKey: string
voice?: ReadingVoxVoice
speed?: number // 0.5 – 2
mode?: "highlight" | "page" | "block"
selectors?: string[] // what to read (default: article, main, [role=main], .content)
ignoreSelectors?: string[] // what to skip inside it
observeContent?: boolean // pick up content that renders after load
readFrames?: boolean // read iframes in place (default true)
frameOrigins?: string[] // extra origins allowed for frame mode
contentLanguage?: string // "auto" (default) or e.g. "es"
uiLanguage?: string // toolbar language, e.g. "es"
enabledTools?: string[] // same ids as data-tools
theme?: "auto" | "light" | "dark" | { mode?: "auto" | "light" | "dark"; primaryColor?: string }
toolbarPosition?: "top" | "bottom"
togglePosition?: "bottom-right" | "bottom-left" | "top-right" | "top-left"
showToggle?: boolean
documentLinks?: boolean // Listen buttons on PDF links
onReady?: () => void
onPlay?: () => void
onPause?: () => void
onEnd?: () => void
onError?: (error: Error) => void
}
export interface ReadingVoxInstance {
initialize(): Promise<void>
play(): Promise<void>
pause(): void
stop(): void
isPlaying(): boolean
playText(text: string): Promise<void>
setSpeed(speed: number): void
setVoice(voice: ReadingVoxVoice): void
destroy(): void
}
declare global {
interface Window {
ReadingVox?: new (options: ReadingVoxOptions) => ReadingVoxInstance
}
}
const SRC = "https://readingvox.com/widget.js"
let loading: Promise<void> | null = null
export function loadReadingVox(): Promise<void> {
if (typeof window === "undefined") return Promise.resolve() // server render
if (window.ReadingVox) return Promise.resolve()
if (!loading) {
loading = new Promise<void>((resolve, reject) => {
const script = document.createElement("script")
script.src = SRC
script.async = true
script.dataset.autoInit = "false" // we create the instance ourselves
script.onload = () => resolve()
script.onerror = () => {
loading = null
reject(new Error("ReadingVox failed to load"))
}
document.head.appendChild(script)
})
}
return loading
}
2. Add the hook
src/useReadingVox.ts
// useReadingVox.ts
import { useEffect } from "react"
import { loadReadingVox, type ReadingVoxInstance, type ReadingVoxOptions } from "./readingvox"
/**
* Creates the widget after your content renders and removes it on unmount.
* Pass the current route as resetKey so each page gets a fresh instance.
*/
export function useReadingVox(options: ReadingVoxOptions, resetKey?: unknown) {
useEffect(() => {
let instance: ReadingVoxInstance | null = null
let cancelled = false
loadReadingVox()
.then(() => {
if (cancelled || !window.ReadingVox) return
instance = new window.ReadingVox(options)
instance.initialize().catch(() => {}) // e.g. a page with nothing to read
})
.catch((err) => console.warn(err))
return () => {
cancelled = true
instance?.destroy()
}
// Options are read once per page; change resetKey to re-create
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [resetKey])
}
3. Use it once, near the top of your app
src/App.tsx
// App.tsx (Vite + React Router)
import { Outlet, useLocation } from "react-router-dom"
import { useReadingVox } from "./useReadingVox"
export default function App() {
const { pathname } = useLocation()
useReadingVox(
{
apiKey: import.meta.env.VITE_READINGVOX_KEY,
voice: "heart",
selectors: ["main article"],
observeContent: true, // articles that load from an API after the route renders
},
pathname,
)
return (
<main>
<Outlet />
</main>
)
}
.env
# .env (Vite exposes VITE_* variables to the browser)
VITE_READINGVOX_KEY=rv_live_...
Content that loads later
observeContent: true watches for content added after the route renders (API data, infinite scroll), so you don't need to wait for it before creating the widget.
Next.js
App Router. Uses the loader and hook from the React example (put them in lib/).
app/readingvox.tsx
// app/readingvox.tsx
"use client"
import { usePathname } from "next/navigation"
import { useReadingVox } from "@/lib/useReadingVox" // the hook from the React example
export function ReadingVox() {
const pathname = usePathname()
useReadingVox(
{
apiKey: process.env.NEXT_PUBLIC_READINGVOX_KEY!,
voice: "heart",
selectors: ["article"],
observeContent: true,
},
pathname, // client-side navigation re-creates the widget for the new page
)
return null
}
Pages Router: render the same component in pages/_app.tsx and use useRouter().asPath as the reset key. A plain <Script> tag with data-key also works: Play reads whatever page is showing after client-side navigation (see changing content).
Vue
Vue 3 with Vue Router. Uses the loader from the React example.
src/composables/useReadingVox.ts
// composables/useReadingVox.ts
import { onBeforeUnmount, onMounted, watch } from "vue"
import { useRoute } from "vue-router"
import { loadReadingVox, type ReadingVoxInstance, type ReadingVoxOptions } from "../readingvox"
export function useReadingVox(options: ReadingVoxOptions) {
const route = useRoute()
let instance: ReadingVoxInstance | null = null
let generation = 0
async function start() {
const mine = ++generation
instance?.destroy()
instance = null
await loadReadingVox()
if (mine !== generation || !window.ReadingVox) return // a newer route won
instance = new window.ReadingVox(options)
instance.initialize().catch(() => {})
}
onMounted(start)
// flush: "post" waits until the new page's DOM is rendered
watch(() => route.path, start, { flush: "post" })
onBeforeUnmount(() => {
generation++
instance?.destroy()
instance = null
})
}
Nuxt: call the composable from app.vue inside a client-only block, or create a plugins/readingvox.client.ts that does the same.
Node.js / Express
Server-rendered pages only need the script tag in your template. This example reads the key from config, adds the tag only where you want it, and sets a Content Security Policy that allows ReadingVox.
server.js
// server.js — Express + EJS, with a Content Security Policy that allows ReadingVox
import express from "express"
import helmet from "helmet"
const app = express()
app.set("view engine", "ejs")
app.use(
helmet({
contentSecurityPolicy: {
directives: {
"script-src": ["'self'", "https://readingvox.com"],
"connect-src": ["'self'", "https://readingvox.com"],
"media-src": ["'self'", "https://readingvox.com", "blob:"],
"font-src": ["'self'", "https://readingvox.com"],
"style-src": ["'self'", "'unsafe-inline'"],
},
},
}),
)
// The key is public (it ends up in your HTML) and locked to your domains,
// but keeping it in config makes per-environment keys easy.
app.use((req, res, next) => {
res.locals.readingvoxKey = process.env.READINGVOX_KEY
next()
})
app.get("/articles/:slug", async (req, res) => {
const article = await loadArticle(req.params.slug)
res.render("article", { article, readAloud: true })
})
app.listen(3000)
The ReadingVox API serves the browser widget and checks requests against your domains, so generating audio from your server isn't supported. The same pattern works with Pug, Handlebars, Nunjucks, Django, Rails or Laravel templates: render the script tag with your key.
Other platforms
WordPress: use the ReadingVox plugin; paste your key in its settings.
Webflow, Squarespace, Shopify, Ghost, Wix: paste the script tag into the site-wide custom code / footer code setting. See the website install guide.
Angular, Svelte, Solid, Astro islands: use the loader above and follow the same pattern: create the instance after the route's content renders, destroy() before the next one.
Canvas and Moodle: use the LMS installs, which read only course content and stay off grades and messages.
JavaScript API
Creating an instance
new ReadingVox(options) then initialize(). apiKey is required; everything else is optional and matches the script tag options above (data-tools → enabledTools, data-position → toolbarPosition, data-lang → contentLanguage, data-ui-lang → uiLanguage, data-documents → documentLinks, data-frames → readFrames, data-frame-origins → frameOrigins). The full option list with types is in the loader.
Methods
Method
What it does
initialize(): Promise<void>
Finds the content and builds the button and toolbar. Call once after creating the instance.
play(): Promise<void>
Reads the selection if there is one, otherwise the page. Resumes if paused.
playPage(): Promise<void>
Reads the whole page, ignoring any selection, or resumes it if paused. What placed Listen buttons do.
pause() / stop()
Pause where you are, or stop and reset to the start.
isPlaying(): boolean
Whether audio is playing right now.
playText(text: string): Promise<void>
Read any string aloud, e.g. text you render outside the content area.
readFrom(element: Element): Promise<boolean>
Read from this element to the end of the content. Returns false if it isn't inside the content.
skipSentence(delta: number)
Jump forward (1) or back (-1) a sentence.
seek(percent: number)
Jump to a position, 0–100.
getProgress(): number
Current position, 0–100.
setSpeed(speed: number)
0.5 to 2, applied right away.
setVoice(voice: string)
One of heart, michael, lily, leo, mia, theo.
setVolume(volume: number)
0 to 1.
onWordHighlight(callback)
Called for every word as it's spoken: { word, sentence, wordIndex, timestamp }. Useful for your own captions or analytics.
destroy()
Stops playback and removes everything the widget added to the page. Call it before creating another instance.
example.js
// Read any string aloud (e.g. a quiz question rendered in canvas or an image)
await rv.playText("Which planet is closest to the Sun?")
// Read from a specific element to the end of the content
await rv.readFrom(document.querySelector("#chapter-3"))
rv.setSpeed(1.25)
rv.setVoice("michael")
rv.stop()
Events (options)
Option
Called when
onReady()
The button and toolbar are in place.
onPlay()
Audio started (also fires at each new sentence).
onPause()
Paused by the visitor or your code.
onEnd()
Reached the end, or stopped.
onError(error)
Setup failed (e.g. no readable content, invalid key) or audio couldn't play. Reading falls back to the browser voice when it can.
Content Security Policy
If your site sends a CSP header, allow these (tested with the widget's full toolbar, playback and MP3 download):
Directive
Add
Why
script-src
https://readingvox.com
The widget and its tools
connect-src
https://readingvox.com
Text to read, settings, word timings
media-src
https://readingvox.com blob:
Audio (played from blob: URLs)
font-src
https://readingvox.com
The OpenDyslexic font, if a visitor picks it
style-src
'unsafe-inline'
The toolbar's styles
No img-src, frame-src or third-party domains are needed. A strict Referrer-Policy is fine: the widget identifies your site with the Origin header.
Troubleshooting
The button doesn't appear
Add data-debug="true" and check the browser console.
No readable content found: the default selectors didn't match. Set data-selectors to your content's container.
Single-page app: the content wasn't rendered yet. Create the widget after render (see React / Vue) or set observeContent: true.
"Domain … not authorized" (403)
Add the exact host you're on in the dashboard (www.example.com and example.com are different; *.example.com covers subdomains). Staging and preview hosts need adding too. Requests with no Origin header (server-side calls, some privacy extensions) are rejected.
It reads the previous page or the first step
Press Play again: each Play reads what's on screen now (changing content). If it still reads old content, the old step is probably still in the page and only moved off-screen. Hide it with hidden, display:none or aria-hidden="true", or point data-selectors at the visible container.
Iframe content isn't read
Another subdomain: the framed page needs the script tag too (see Iframes).
Another domain: list the sites in each other's data-frame-origins; both pages need https.
Someone else's site (YouTube, Google): browsers don't allow reading it.
Very small frames (under about 120×40 px) are skipped.
A second button appears inside an iframe
The framed page couldn't join the page around it: that page has no ReadingVox, has data-frames="false", or is on a domain that isn't allowed. Check both pages with data-debug="true".
Two buttons or toolbars
Two instances: either the script tag auto-started and you also called new ReadingVox() (add data-auto-init="false"), or an instance wasn't destroyed before the next one.
Audio blocked by Content Security Policy
Add the directives in the CSP table; blob: in media-src is the one most often missed.