The browser ships with a speech engine you can drive straight from JavaScript. Combine it with sentence-level highlighting and you get the read-along effect that audiobook players and accessibility tools rely on: the text stays on screen, and the part being spoken is marked as it plays.

The build below is deliberately small. It takes a block of text, reads it one sentence at a time, keeps the active sentence highlighted and in view, and exposes the usual transport controls plus a voice picker.

Illustration of a text reader built with speech synthesis and sentence highlighting

The two objects behind the speech

Almost everything goes through two things:

  • window.speechSynthesis — the controller. It owns speak(), pause(), resume() and cancel().
  • SpeechSynthesisUtterance — one chunk of text plus the settings used to deliver it: which voice, how fast, at what pitch.

The minimal version is two lines:

const msg = new SpeechSynthesisUtterance("Hello, world!");
window.speechSynthesis.speak(msg);

Voice selection is slightly awkward because voice data arrives asynchronously. getVoices() is frequently empty on the first call, and the list only becomes trustworthy once voiceschanged fires — so the picker is best filled from an event handler rather than at load time. Once you do have a voice object, the rest is configuration:

const utter = new SpeechSynthesisUtterance("This is a test");
const voices = window.speechSynthesis.getVoices();
utter.voice = voices.find(v => v.lang === 'en-US');
utter.rate = 1.2;
utter.pitch = 1;
window.speechSynthesis.speak(utter);

Both the start and the end of an utterance are observable, which is what makes chaining possible later on:

utter.onstart = () => console.log('Started speaking');
utter.onend = () => console.log('Finished speaking');

Marking the sentence being read

Highlighting is the easy half, provided the markup cooperates. Wrap each sentence in its own element and the styling collapses into a single state class:

<p>
  <span class="sentence">First sentence.</span>
  <span class="sentence">Second sentence.</span>
</p>
.sentence.active {
  background-color: yellow;
  font-weight: bold;
}
function highlight(index) {
  document.querySelectorAll('.sentence').forEach((el, i) => {
    el.classList.toggle('active', i === index);
  });
}

That toggle(..., i === index) pattern is worth remembering: it strips the class from every element that isn't the target in the same pass, so you never have to keep track of which one was active before.

The reader

The demo treats a block of text as a list of sentences, reads them in order, highlights the current one, and offers play, pause, resume and stop along with a voice selector. Progress is reported both as a fraction and as a bar.

Markup

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <meta name="description" content="Build an interactive sentence-level text-to-speech reader with highlight, playback controls, and local progress tracking using HTML and JavaScript." />
  <title>Interactive TTS Article Reader</title>
</head>
<body>
  <h1>Read Along TTS Demo</h1>
  <div class="toolbar">
    <button id="start">Play</button>
    <button id="pause" disabled>Pause</button>
    <button id="resume" disabled>Resume</button>
    <button id="stop" disabled>Stop</button>
    <button id="reset">Reset</button>
    <select id="voices"></select>
  </div>
  <div class="text-block" id="reader">
    <span class="line">Learning to code is a never-ending journey.</span>
    <span class="line">Technologies evolve rapidly, requiring constant adaptation.</span>
    <span class="line">JavaScript, HTML, and CSS are essential tools for web development.</span>
    <span class="line">Frameworks like React and Vue enhance front-end capabilities.</span>
    <span class="line">Back-end skills with Node.js extend JavaScript to the server.</span>
  </div>
  <div class="progress">
    <span id="progressText">0/0</span>
    <div class="bar"><div class="bar-fill" id="bar"></div></div>
  </div>
</body>
</html>

Each sentence is a <span class="line"> — the same idea as the .sentence spans above, just under a different class name. The transport buttons begin disabled because nothing is playing yet, and the counter sits at 0/0 until the script fills it in.

Styling

body {
  font-family: sans-serif;
  margin: 0;
  padding: 2rem;
  background: #f0f4f8;
  color: #333;
}
h1 {
  text-align: center;
  margin-bottom: 2rem;
}
.toolbar {
  display: flex;
  justify-content: center;
  flex-wrap: wrap;
  gap: 1rem;
  margin-bottom: 2rem;
}
.toolbar button,
.toolbar select {
  padding: 0.6rem 1.2rem;
  border-radius: 4px;
  border: none;
  font-size: 1rem;
}
.toolbar button {
  background: #0077cc;
  color: white;
  cursor: pointer;
}
.toolbar button:disabled {
  background: #ccc;
  cursor: not-allowed;
}
.text-block {
  background: white;
  padding: 1.5rem;
  border-radius: 6px;
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
}
.line {
  display: block;
  margin-bottom: 1rem;
  cursor: pointer;
  transition: background 0.3s;
}
.line:hover {
  background: #f9f9f9;
}
.line.active {
  background: #fff3cd;
  font-weight: bold;
}
.progress {
  text-align: center;
  margin-top: 1rem;
}
.bar {
  height: 8px;
  width: 100%;
  background: #eee;
  border-radius: 4px;
  overflow: hidden;
}
.bar-fill {
  height: 100%;
  width: 0;
  background: linear-gradient(to right, #0077cc, #005fa3);
  transition: width 0.3s;
}

The only rule carrying real meaning is .line.active. The rest — the greyed-out disabled state, the hover tint, the animated bar width — exists so the interface communicates what is happening without any accompanying text.

Logic

const lines = document.querySelectorAll('.line');
const playBtn = document.getElementById('start');
const pauseBtn = document.getElementById('pause');
const resumeBtn = document.getElementById('resume');
const stopBtn = document.getElementById('stop');
const resetBtn = document.getElementById('reset');
const voiceSelect = document.getElementById('voices');
const progressText = document.getElementById('progressText');
const progressBar = document.getElementById('bar');

const synth = window.speechSynthesis;
let voices = [];
let currentIndex = 0;
let currentUtterance = null;
let isPaused = false;

function populateVoices() {
  voices = synth.getVoices();
  voiceSelect.innerHTML = '';
  voices.forEach((voice, index) => {
    const opt = document.createElement('option');
    opt.value = index;
    opt.textContent = `${voice.name} (${voice.lang})`;
    voiceSelect.appendChild(opt);
  });
}

synth.onvoiceschanged = populateVoices;
populateVoices();

function updateUI() {
  progressText.textContent = `${currentIndex + 1}/${lines.length}`;
  progressBar.style.width = `${((currentIndex + 1) / lines.length) * 100}%`;
}

function highlightLine(index) {
  lines.forEach((line, i) => {
    line.classList.toggle('active', i === index);
  });
  lines[index]?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}

function speakLine(index) {
  if (index >= lines.length) return;
  const text = lines[index].textContent;
  const utter = new SpeechSynthesisUtterance(text);
  const selected = voiceSelect.value;
  if (voices[selected]) utter.voice = voices[selected];
  utter.rate = 1;

  utter.onstart = () => {
    highlightLine(index);
    playBtn.disabled = true;
    pauseBtn.disabled = false;
    resumeBtn.disabled = true;
    stopBtn.disabled = false;
  };

  utter.onend = () => {
    if (!isPaused) {
      currentIndex++;
      if (currentIndex < lines.length) {
        speakLine(currentIndex);
      } else {
        resetControls();
      }
    }
  };

  currentUtterance = utter;
  synth.speak(utter);
  updateUI();
}

function resetControls() {
  playBtn.disabled = false;
  pauseBtn.disabled = true;
  resumeBtn.disabled = true;
  stopBtn.disabled = true;
}

playBtn.onclick = () => {
  currentIndex = 0;
  speakLine(currentIndex);
};
pauseBtn.onclick = () => {
  synth.pause();
  isPaused = true;
  pauseBtn.disabled = true;
  resumeBtn.disabled = false;
};
resumeBtn.onclick = () => {
  synth.resume();
  isPaused = false;
  pauseBtn.disabled = false;
  resumeBtn.disabled = true;
};
stopBtn.onclick = () => {
  synth.cancel();
  resetControls();
};
resetBtn.onclick = () => {
  synth.cancel();
  currentIndex = 0;
  highlightLine(currentIndex);
  updateUI();
};

lines.forEach((line, index) => {
  line.addEventListener('click', () => {
    synth.cancel();
    currentIndex = index;
    speakLine(currentIndex);
  });
});

updateUI();

How the pieces fit together

Since every sentence becomes its own utterance, reading the whole block isn't a loop — it's a chain. speakLine() speaks one sentence, and its onend handler increments currentIndex and calls itself again, halting once the index runs past the last line. That single number is also why progress can be derived cheaply instead of tracking elapsed time.

A few points in the control flow are worth spelling out:

  • synth.pause() halts the audio without finishing the utterance, so onend never fires while paused. The isPaused flag is what stops the chain from advancing mid-pause; resume() clears it and playback carries on from where it stopped.
  • cancel() ends the current utterance outright, which is exactly what stop and reset need. Both also restore the buttons so the reader isn't left staring at a dead toolbar.
  • Clicking a line cancels anything in flight, sets currentIndex to that line and starts again from there — a cheap way to jump around without building a separate seek control.
  • scrollIntoView with behavior: 'smooth' keeps the highlighted sentence on screen in longer texts, where an off-screen highlight would be useless.

Where to take it

The core is small enough to extend without reworking it. Storing currentIndex in localStorage turns it into a resume-where-you-left-off reader. Swapping the hard-coded spans for text loaded at runtime — from a file, an API, or a textarea — mostly means splitting the text into sentences and rebuilding the DOM before playback starts, and the rest of the logic stays as it is.