Text to speech in JavaScript with speechSynthesis

Browsers have a speech engine built in. Two lines of JavaScript make a page read text aloud, with no server, no library and no API key.

To make a page read text aloud in JavaScript, wrap the text in a SpeechSynthesisUtterance and pass it to speechSynthesis.speak(). That is the whole API for a first test:

const u = new SpeechSynthesisUtterance('Hello, world');
speechSynthesis.speak(u);

It is part of the Web Speech API, built into the browser. There is no server call and no key. Try it below: type something, pick a voice, and press Speak.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Text to speech</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .box { max-width: 560px; margin: 0 auto; background: #fff; border-radius: 12px; padding: 14px; box-shadow: 0 4px 16px rgba(0, 0, 0, .08); }
  textarea { width: 100%; box-sizing: border-box; height: 84px; font: inherit; font-size: 15px; padding: 8px; border: 1px solid #cfd4dc; border-radius: 8px; resize: vertical; }
  label { display: block; font-size: 13px; margin-top: 10px; color: #4b5261; }
  select { width: 100%; font: inherit; font-size: 14px; padding: 6px; margin-top: 4px; }
  .sliders { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
  input[type=range] { width: 100%; }
  .buttons { display: flex; gap: 8px; margin-top: 14px; flex-wrap: wrap; }
  button { font: inherit; font-size: 14px; padding: 8px 14px; border-radius: 8px; border: 1px solid #cfd4dc; background: #fff; cursor: pointer; }
  button.main { background: #1d4ed8; border-color: #1d4ed8; color: #fff; }
  button:disabled { opacity: .45; cursor: default; }
  #status { margin-top: 12px; font-size: 13px; color: #4b5261; min-height: 18px; }
</style>
</head>
<body>
<div class="box">
  <textarea id="text">Hello! This sentence is being read aloud by your browser.</textarea>
  <label>Voice <select id="voice"><option>Loading voices...</option></select></label>
  <div class="sliders">
    <label>Rate <b id="rateOut">1</b><input id="rate" type="range" min="0.5" max="2" step="0.1" value="1"></label>
    <label>Pitch <b id="pitchOut">1</b><input id="pitch" type="range" min="0" max="2" step="0.1" value="1"></label>
  </div>
  <div class="buttons">
    <button id="speak" class="main">Speak</button>
    <button id="pause">Pause</button>
    <button id="stop">Stop</button>
  </div>
  <div id="status">Ready.</div>
</div>

<script>
  const synth = window.speechSynthesis;
  const $ = (id) => document.getElementById(id);
  let voices = [];

  function loadVoices() {
    voices = synth.getVoices();  // often [] until voiceschanged fires
    if (!voices.length) return;
    $('voice').innerHTML = voices
      .map((v, i) => `<option value="${i}" ${v.default ? 'selected' : ''}>${v.name} (${v.lang})</option>`)
      .join('');
  }

  if (!synth) {
    $('status').textContent = 'This browser has no speechSynthesis.';
    $('speak').disabled = true;
  } else {
    loadVoices();
    synth.addEventListener('voiceschanged', loadVoices);
    // if no list ever arrives, say so instead of waiting forever
    setTimeout(() => {
      if (!voices.length) $('voice').innerHTML = '<option>No voices available here</option>';
    }, 2000);
  }

  ['rate', 'pitch'].forEach((id) => {
    $(id).addEventListener('input', () => { $(id + 'Out').textContent = $(id).value; });
  });

  $('speak').addEventListener('click', () => {
    synth.cancel();  // clear anything still queued
    synth.resume();  // cancel() does not clear the paused state
    const u = new SpeechSynthesisUtterance($('text').value);
    if (voices.length) {
      u.voice = voices[$('voice').value];
      u.lang = u.voice.lang;
    }
    u.rate = Number($('rate').value);
    u.pitch = Number($('pitch').value);
    u.addEventListener('start', () => { $('status').textContent = 'Speaking...'; });
    u.addEventListener('end', () => { $('status').textContent = 'Finished.'; $('pause').textContent = 'Pause'; });
    u.addEventListener('error', (e) => {
      if (e.error === 'interrupted' || e.error === 'canceled') return;  // our own Stop
      $('status').textContent = 'Error: ' + e.error;
    });
    synth.speak(u);
  });

  $('pause').addEventListener('click', () => {
    if (!synth.speaking) return;
    if (synth.paused) { synth.resume(); $('pause').textContent = 'Pause'; $('status').textContent = 'Speaking...'; }
    else { synth.pause(); $('pause').textContent = 'Resume'; $('status').textContent = 'Paused.'; }
  });

  $('stop').addEventListener('click', () => {
    synth.cancel();
    synth.resume();
    $('pause').textContent = 'Pause';
    $('status').textContent = 'Stopped.';
  });
</script>
</body>
</html>
Voice menu, rate and pitch sliders, and speak, pause and stop buttons. Edit the code and the example reruns.

If the voice menu says "No voices available here", this browser or device has no speech voices installed. The code is still correct.

The four steps

  1. Create an utterance. new SpeechSynthesisUtterance(text) holds the words.
  2. Set how it sounds. voice, lang, rate, pitch and volume are optional.
  3. Speak it from a click. Call speechSynthesis.speak(u) inside a click listener.
  4. Control playback. pause(), resume() and cancel() act on everything queued.
An utterance describes what to say. speak() queues it, and the utterance reports back through events.
An utterance describes what to say. speak() queues it, and the utterance reports back through events.

There is one speechSynthesis object per page. Every speak() call adds an utterance to its queue, and they play one after another. That is why the demo calls cancel() before speaking: it clears anything still waiting.

Events such as start and end fire on the utterance, not on speechSynthesis. Attach them with addEventListener before you call speak().

Rate, pitch, volume and lang

Property Range Default What it changes
rate 0.1 to 10 1 Speed. 2 is twice as fast
pitch 0 to 2 1 Higher or lower voice
volume 0 to 1 1 Loudness of this utterance
lang a language tag such as en-US the page language Which language the text is read as
voice one item from getVoices() the default voice The exact voice

These are the ranges in the specification. A voice engine may not use the full range, so the extremes can sound no different from values closer to 1. The demo keeps its range sliders between 0.5 and 2 for that reason.

Why getVoices() is empty

A common problem with this API is speechSynthesis.getVoices() returning []. The browser loads its voices in the background. If your script asks too early, the list is empty, and a menu built from it stays empty.

Read the list once and it may be empty forever. Listen for voiceschanged and it fills in.
Read the list once and it may be empty forever. Listen for voiceschanged and it fills in.

Call it at start, and again whenever voiceschanged fires:

function loadVoices() {
  const voices = speechSynthesis.getVoices();
  if (!voices.length) return;  // not ready yet
  // build the <select> from voices here
}
loadVoices();
speechSynthesis.addEventListener('voiceschanged', loadVoices);

In our test in Chromium on Windows, the first call returned an empty array and the event delivered the list a moment later. If neither call ever returns voices, show a message instead of an empty menu, as the first demo does after two seconds.

Voices depend on the device

The list comes from the operating system and the browser. A laptop and a phone can offer different voices, and the same code sounds different on each. Never hard-code a voice name.

Each voice has a name, a lang and a default flag. localService is true when the voice runs on the device, and false when a remote service produces the speech. Pick by language instead of by name:

const voice = voices.find((v) => v.lang.startsWith('en')) || null;
u.voice = voice;
u.lang = voice ? voice.lang : 'en-US';

A voice for one language will still try to read text in another, often with the wrong pronunciation. Setting lang to match the text avoids most of that.

Highlight words with the boundary event

While it speaks, an utterance can fire boundary events. Each one has a name (word or sentence) and a charIndex, the position in the text where that word starts. Match the index to a <span> around each word and you get karaoke-style highlighting.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Highlight words as they are spoken</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .box { max-width: 560px; margin: 0 auto; background: #fff; border-radius: 12px; padding: 16px; box-shadow: 0 4px 16px rgba(0, 0, 0, .08); }
  #text { font-size: 18px; line-height: 1.6; margin: 0 0 14px; }
  #text span { border-radius: 4px; transition: background .1s; }
  #text span.on { background: #fde68a; }
  button { font: inherit; font-size: 14px; padding: 8px 14px; border-radius: 8px; border: 1px solid #1d4ed8; background: #1d4ed8; color: #fff; cursor: pointer; }
  #note { margin-top: 12px; font-size: 13px; color: #4b5261; min-height: 36px; }
</style>
</head>
<body>
<div class="box">
  <p id="text">The boundary event tells you where the voice is. Each word lights up as it is read.</p>
  <button id="read">Read with highlight</button>
  <div id="note">Press the button. Words are highlighted if the voice sends boundary events.</div>
</div>

<script>
  const synth = window.speechSynthesis;
  const p = document.getElementById('text');
  const note = document.getElementById('note');
  const text = p.textContent;

  // wrap each word in a span and remember where it starts in the text
  const words = [...text.matchAll(/\S+/g)];
  p.innerHTML = words.map((m) => `<span data-i="${m.index}">${m[0]}</span>`).join(' ');
  const spans = [...p.querySelectorAll('span')];

  function mark(charIndex) {
    spans.forEach((s) => s.classList.toggle('on', Number(s.dataset.i) === charIndex));
  }

  document.getElementById('read').addEventListener('click', () => {
    if (!synth) { note.textContent = 'This browser has no speechSynthesis.'; return; }
    synth.cancel();
    let boundaries = 0;
    const u = new SpeechSynthesisUtterance(text);
    u.lang = 'en-US';

    u.addEventListener('boundary', (e) => {
      if (e.name !== 'word') return;  // there are also 'sentence' boundaries
      boundaries++;
      mark(e.charIndex);
      note.textContent = `Word boundary at character ${e.charIndex}.`;
    });
    u.addEventListener('start', () => { note.textContent = 'Speaking...'; });
    u.addEventListener('end', () => {
      mark(-1);
      note.textContent = boundaries
        ? `Finished. ${boundaries} word boundary events arrived.`
        : 'Finished, but this voice sent no boundary events, so there was nothing to highlight. Try another voice.';
    });
    u.addEventListener('error', (e) => { note.textContent = 'Error: ' + e.error; });
    synth.speak(u);
  });
</script>
</body>
</html>
Each word is wrapped in a span that records where it starts. The boundary event says which one to light up.
u.addEventListener('boundary', (e) => {
  if (e.name !== 'word') return;
  mark(e.charIndex);  // highlight the span that starts here
});

Not every voice sends boundary events. The demo counts them, and if none arrived by the end event, it says so instead of silently showing nothing. Treat highlighting as an extra: the speech still works without it.

Long text: one sentence at a time

You can pass a whole article to one utterance. It is hard to control, though: skipping ahead means cancelling and starting over, and with some voices a very long utterance can stop before the end.

Split long text into sentences. Each end event becomes a place to highlight, skip or stop.
Split long text into sentences. Each end event becomes a place to highlight, skip or stop.

Split the text into sentences and queue one utterance for each. The finished example reads a short article paragraph by paragraph, highlights the current one, and has skip buttons.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Read this article aloud</title>
<style>
  body { margin: 0; padding: 12px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .wrap { max-width: 600px; margin: 0 auto; background: #fff; border-radius: 12px; box-shadow: 0 4px 16px rgba(0, 0, 0, .08); overflow: hidden; }
  .bar { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; padding: 10px 12px; background: #1d2330; color: #fff; }
  .bar button { font: inherit; font-size: 14px; padding: 7px 11px; border-radius: 8px; border: 0; background: #374151; color: #fff; cursor: pointer; }
  .bar button#play { background: #16a34a; min-width: 84px; }
  .bar select { font: inherit; font-size: 13px; padding: 5px; border-radius: 6px; }
  #pos { font-size: 13px; color: #cbd5e1; margin-left: auto; }
  article { position: relative; height: 330px; overflow-y: auto; padding: 4px 16px 12px; }
  article h2 { font-size: 19px; margin: 12px 0 6px; }
  article p { font-size: 15px; line-height: 1.55; margin: 0 0 8px; padding: 6px 8px; border-radius: 8px; border-left: 4px solid transparent; }
  article p.now { background: #ecfdf3; border-left-color: #16a34a; }
</style>
</head>
<body>
<div class="wrap">
  <div class="bar">
    <button id="play">&#9654; Listen</button>
    <button id="prev" aria-label="Previous paragraph">&#9198;</button>
    <button id="next" aria-label="Next paragraph">&#9197;</button>
    <button id="stop" aria-label="Stop">&#9632;</button>
    <select id="speed" aria-label="Speed">
      <option value="0.8">0.8x</option><option value="1" selected>1x</option>
      <option value="1.25">1.25x</option><option value="1.5">1.5x</option>
    </select>
    <span id="pos">Not playing</span>
  </div>
  <article id="article">
    <h2>Why a page can read itself</h2>
    <p>Every modern browser ships a speech engine. The Web Speech API lets a page hand it text and get spoken audio back.</p>
    <p>No server is involved. The voices come from the device, so a phone and a laptop may sound different.</p>
    <p>Long text is easier to control in small pieces. This reader speaks one sentence at a time and moves on when a paragraph ends.</p>
    <p>The green bar shows the paragraph being read. Use the skip buttons to jump back or forward.</p>
    <p>Press stop at any time. The next press of Listen starts from the paragraph you stopped on.</p>
  </article>
</div>

<script>
  const synth = window.speechSynthesis;
  const paras = [...document.querySelectorAll('#article p')];
  const $ = (id) => document.getElementById(id);
  let index = 0;   // paragraph being read
  let run = 0;     // bumps on every jump, so old utterances are ignored

  function show(i) {
    paras.forEach((p, n) => p.classList.toggle('now', n === i));
    const box = $('article');
    if (i >= 0) box.scrollTop = paras[i].offsetTop - 12;
    $('pos').textContent = i >= 0 ? `Paragraph ${i + 1} of ${paras.length}` : 'Not playing';
  }

  function readFrom(i) {
    synth.cancel();
    synth.resume();  // cancel() does not clear the paused state
    const id = ++run;
    if (i >= paras.length) { index = 0; stop(); return; }  // finished: next Listen starts over
    index = i;
    show(i);
    $('play').textContent = '⏸ Pause';
    // split into sentences: short utterances are easier to pause and skip
    const sentences = paras[i].textContent.match(/[^.!?]+[.!?]*/g) || [];
    sentences.forEach((s, n) => {
      const u = new SpeechSynthesisUtterance(s.trim());
      u.rate = Number($('speed').value);
      if (n === sentences.length - 1) {
        u.addEventListener('end', () => { if (id === run) readFrom(index + 1); });
      }
      synth.speak(u);
    });
  }

  function stop() {
    run++;
    synth.cancel();
    synth.resume();
    show(-1);
    $('play').textContent = '▶ Listen';
  }

  $('play').addEventListener('click', () => {
    if (!synth) { $('pos').textContent = 'No speechSynthesis here'; return; }
    if (synth.speaking && synth.paused) { synth.resume(); $('play').textContent = '⏸ Pause'; }
    else if (synth.speaking) { synth.pause(); $('play').textContent = '▶ Resume'; }
    else readFrom(index);
  });
  $('next').addEventListener('click', () => readFrom(index + 1));
  $('prev').addEventListener('click', () => readFrom(Math.max(index - 1, 0)));
  $('stop').addEventListener('click', stop);
</script>
</body>
</html>
A Listen bar for an article: pause, previous, next, stop and speed. The current paragraph is highlighted.
  • Chunking: text.match(/[^.!?]+[.!?]*/g) splits text into sentences.
  • Next: the last sentence's end starts the next one.
  • Skipping: cancel(), then queue the new paragraph. A counter makes stale end events do nothing.
  • Pause: in Chromium, paused can stay true after cancel(). Check speaking && paused rather than paused alone before deciding what a button should do.

For recorded audio files rather than generated speech, the HTML audio tag is the tool.

When it does not work

What you see Cause Fix
The voice menu is empty getVoices() ran before voices loaded Call it again on voiceschanged
Nothing happens on page load The browser wants a user gesture first Start speech from a click or key press
Different voices on another device Voices come from the system and browser Choose by lang, not by name
Wrong accent or odd pronunciation Voice language does not match the text Set lang and a matching voice
Long text stops partway One very long utterance Split into sentences and queue them
No word highlighting This voice sends no boundary events Show a message; try another voice
Old speech keeps playing after Speak Earlier utterances are still queued Call cancel() before speak()
An error event says interrupted Your own cancel() stopped it Ignore interrupted and canceled

Speech is something people need to hear on their own device, with their own voices. A screen recording shows one voice only, and an .html attachment may open as plain code on a phone.

To send the working version, paste the page into a NOS document and choose Create share link. HTML to link walks through it. The page renders as written and its scripts run, so the people you send it to can press Listen themselves.

If you change the code later, the same link shows the new version.

Questions people ask

Is the Web Speech API free to use?

Yes. speechSynthesis is part of the browser, so there is no key, account or request limit to set up. The voices come from the operating system or the browser, which is also why they differ from one device to the next.

Why does speechSynthesis.getVoices() return an empty array?

The browser may still be loading its voices when your script runs. Listen for the voiceschanged event on speechSynthesis and call getVoices() again inside it. Call it once at start as well, because some browsers have the list ready immediately.

Why does nothing happen when the page loads and calls speak()?

Some browsers ignore speech that starts without any interaction, the same way they block audio with sound from playing on its own. Start speaking from a click or key press, such as a Listen button.

Can I save the spoken audio as an MP3 file?

Not with speechSynthesis. It plays through the device speakers and gives the page no access to the audio data. Saving speech to a file needs a text-to-speech service that returns audio.

Can JavaScript also turn speech into text?

That is a separate part of the Web Speech API called SpeechRecognition. Fewer browsers support it, some only under a prefixed name, and it needs permission to use the microphone. Test it in each browser you care about before relying on it.

Keep reading