Developer docs

Integrate ReadingVox

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.

index.html
<script src="https://readingvox.com/widget.js" data-key="YOUR_API_KEY" data-voice="heart" async></script>

Quick start

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.

    index.html
    <script src="https://readingvox.com/widget.js" data-key="YOUR_API_KEY" data-voice="heart" async></script>

    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).
AttributeValuesDefaultWhat it does
data-keyyour API key(required)The API key from the dashboard.
data-voiceheart | michael | lily | leo | mia | theoheartDefault voice. Visitors can still pick another in Settings.
data-speed0.5 – 21Starting reading speed.
data-langauto | es | fr | it | pt | hi | ja | zh | any language codeautoLanguage of your content. auto reads the page's lang attribute; natural voices for 7 languages, the browser voice for others.
data-ui-langauto | es | ar | zh | vi | … (29 languages)autoLanguage of the toolbar itself. auto uses the visitor's browser language; visitors can change it in Settings.
data-documentstrue | falsefalseOpt-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-toolscomma-separated tool idsall toolsWhich toolbar tools to show, e.g. data-tools="dictionary,translate,fontSize,settings".
data-modehighlight | page | blockhighlighthighlight: select text to hear it, or play the page. page: play reads the whole page. block: a play button on each content block.
data-selectorscomma-separated CSS selectorsarticle, main, [role=main], .contentWhere the readable content lives.
data-positiontop | bottomtopToolbar position.
data-toggle-positionbottom-right | bottom-left | top-right | top-leftbottom-rightWhere the floating listen button sits.
data-show-toggletrue | falsetrueShow the floating listen button.
data-pulsetrue | falsetruePulse the listen button for first-time visitors.
data-always-showtrue | falsefalseShow the toolbar as soon as the page loads.
data-themeauto | light | darkautoPlayer light/dark theme.
data-colorhex color, e.g. #2563ebReadingVox blueBrand color for the player.
data-presetcanvas | moodle | noneauto-detectLMS preset (Institution plan). none turns off auto-detection.
data-auto-inittrue | falsetrueSet false to create the widget yourself with new ReadingVox({...}).
data-debugtrueoffLog setup details (content found, errors) to the browser console.
data-framestrue | falsetrueRead iframes as part of the page (see Iframes). Set false to ignore them.
data-frame-originsorigins, comma-separatednoneExtra sites allowed to exchange content with this page in an iframe, beyond your own root domain, e.g. https://partner.example.org.

Tool ids for data-tools: play, simplify, vocabSimplify, pageTheme, fontSize, fontSelector, screenMask, magnifier, screenshotReader, dictionary, translate, summary, download, clickListen, formReader, settings.

Listen button in your page

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-buttonMarks 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 pageExampleWhat to do
Same origin as the page/quizzes/flight-checkNothing. The page's widget reads it directly.
Another subdomain of your root domainmedia.example.comAdd the same script tag to the framed page. It detects the page around it and joins it in frame mode.
Another domain you controlpartner.example.orgAdd the script to the framed page and list each site in the other's data-frame-origins.
Someone else's siteYouTube, Google Forms, an LMS you don't hostNot 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
}
app/layout.tsx
// app/layout.tsx
import { ReadingVox } from "./readingvox"

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        {children}
        <ReadingVox />
      </body>
    </html>
  )
}
.env.local
# .env.local
NEXT_PUBLIC_READINGVOX_KEY=rv_live_...

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
  })
}
src/App.vue
<!-- App.vue -->
<script setup lang="ts">
import { useReadingVox } from "./composables/useReadingVox"

useReadingVox({
  apiKey: import.meta.env.VITE_READINGVOX_KEY,
  voice: "heart",
  selectors: ["main article"],
  observeContent: true,
})
</script>

<template>
  <main><RouterView /></main>
</template>

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)
views/article.ejs
<!-- views/article.ejs -->
<article>
  <h1><%= article.title %></h1>
  <%- article.html %>
</article>

<% if (readAloud && readingvoxKey) { %>
  <script
    src="https://readingvox.com/widget.js"
    data-key="<%= readingvoxKey %>"
    data-voice="heart"
    data-selectors="article"
    async
  ></script>
<% } %>

There's no server-side API (yet)

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

MethodWhat 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(): booleanWhether 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(): numberCurrent 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)

OptionCalled 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):
DirectiveAddWhy
script-srchttps://readingvox.comThe widget and its tools
connect-srchttps://readingvox.comText to read, settings, word timings
media-srchttps://readingvox.com blob:Audio (played from blob: URLs)
font-srchttps://readingvox.comThe 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.

Still stuck? Email [email protected] with your page address.