Skip to main content

Implementing a Read Aloud Feature in Oracle APEX

ORACLE APEX · JAVASCRIPT · WEB SPEECH API

Implementing a Read Aloud Feature in Oracle APEX

With word-level highlighting, pause, resume & speed control - no plugins needed.

A Little Background..

I've been building Oracle APEX applications for a while now, and one thing I kept noticing was a common user behaviour - long content like Terms & Conditions, Privacy Policies, or Company Guidelines just gets skipped. People scroll past it, click "I Agree" without reading a single word, and move on. Honestly, I'm guilty of that too.

One day it just clicked - what if the page could read it out for them? They could listen while doing something else, and actually get through the content. I started digging into the browser's built-in Web Speech API and was honestly surprised by how capable it is, right out of the box - no external libraries, no paid plugins, nothing.

So in this blog, I'm going to show you exactly how I implemented a full Read Aloud feature in Oracle APEX with word-level highlighting, a pause/resume toggle, stop button, and a speed selector. I've used a Privacy Policy section as the example content. Let's get into it.

๐ŸŽฏ What you'll build: A static content region with a styled player bar - Play, Pause/Resume, and Stop buttons with speed control. As the text is read, each word gets highlighted in real-time. Everything is built with Oracle APEX's native tools and vanilla JavaScript using the browser's SpeechSynthesis API.

1

Create the Static Content Region

In your APEX page, create a Static Content region and give it a Static ID of policy-region. Paste the following HTML into the region source. This is the content that will be read aloud - you can replace it with your own policy text.

HTML
<div id="readAloudContent">
  <p><b>This Privacy Policy describes how Acme Corporation collects,
  uses, and shares information about you when you use our services.
  We collect information you provide directly to us, such as when
  you create an account, submit a form, make a purchase, or contact
  us for support.</b></p>

  <p><b>We may also collect information automatically when you use
  our services, including log data, device information, and location
  information. This data helps us improve our services and provide
  you with a better experience.</b></p>
</div>

๐Ÿ’ก Note: The id="readAloudContent" on the outer div is important - the JavaScript uses it to locate and wrap the text for word highlighting.

2

Create the Player Controls Sub-Region

Under the region you just created (policy-region), create a Sub Region. In its Template options, set the template to Blank with Attributes so no extra wrappers interfere. Then paste the player HTML below into its source.

HTML - Player Controls
<!-- Player controls -->
<div class="ra-player">

  <button type="button"
          class="ra-btn ra-btn-primary"
          id="raBtnPlay"
          onclick="raPlay()">
    ▶ Read Aloud
  </button>

  <button type="button"
          class="ra-btn"
          id="raBtnPause"
          onclick="raPause()"
          disabled>
    ⏸ Pause
  </button>

  <button type="button"
          class="ra-btn"
          id="raBtnStop"
          onclick="raStop()"
          disabled>
    ⏹ Stop
  </button>

  <label for="raSpeed" class="ra-speed-label">Speed:</label>

  <select id="raSpeed"
          class="ra-select"
          onchange="raChangeSpeed()">
    <option value="0.5">0.5×</option>
    <option value="0.75">0.75×</option>
    <option value="1" selected>1× (Normal)</option>
    <option value="1.25">1.25×</option>
    <option value="1.5">1.5×</option>
    <option value="2">2×</option>
  </select>

  <span id="raStatusText" class="ra-status-text">
    Ready
  </span>

</div>
3

Add the CSS - Page Level Inline CSS

Go to your APEX page's CSS → Inline section and paste the styles below. This gives the player bar its dark blue look, styles the buttons, dropdown, and the word-highlight effect. The .ra-highlight class is what flashes on each word as it's read.

CSS - Inline Stylesheet
.ra-player {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
  padding: 14px 18px;
  margin-top: 18px;
  background: #173247;
  border: 1px solid #24485f;
  border-radius: 10px;
  box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}

.ra-btn {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 8px 16px;
  background: #26485d;
  border: 1px solid #355d75;
  border-radius: 6px;
  color: #f1f5f9;
  font-size: 13px;
  font-weight: 500;
  cursor: pointer;
  transition: all 0.2s ease;
}

.ra-btn:hover {
  background: #315d76;
  border-color: #4b7891;
}

.ra-btn:active {
  transform: scale(0.97);
}

.ra-btn:disabled {
  opacity: 0.45;
  cursor: not-allowed;
}

.ra-btn-primary {
  background: #2f9ad6;
  border-color: #2f9ad6;
  color: #ffffff;
}

.ra-btn-primary:hover {
  background: #2588bf;
  border-color: #2588bf;
}

.ra-speed-label {
  font-size: 13px;
  color: #c7d5df;
  font-weight: 500;
}

.ra-select {
  padding: 7px 10px;
  background: #26485d;
  border: 1px solid #355d75;
  border-radius: 6px;
  color: #ffffff;
  font-size: 13px;
  cursor: pointer;
  outline: none;
}

.ra-select:focus {
  border-color: #4aa8dd;
  box-shadow: 0 0 0 2px rgba(74,168,221,0.25);
}

.ra-status-text {
  margin-left: auto;
  font-size: 12px;
  font-style: italic;
  color: #c7d5df;
}

.ra-highlight {
  background-color: #4aa8dd;
  color: #ffffff;
  border-radius: 4px;
  padding: 1px 3px;
  transition: all 0.15s ease;
}

.ra-highlight-sentence {
  background-color: rgba(74,168,221,0.18);
  border-radius: 4px;
  padding: 1px 2px;
}
4

Add the JavaScript

Navigate to the page's JavaScript → Function and Global Variable Declaration section. Paste the full script below. This is where all the logic lives - wrapping words in spans, tracking character position, highlighting, managing states, and handling pause/resume accurately.

One thing that tripped me up initially was the pause behaviour. The Web Speech API doesn't support true pause across all browsers, so the trick here is to cancel the utterance (which stops audio immediately), save the last character index, then restart from that position on resume. The raPausing flag prevents the onend event from firing incorrectly when we cancel for a pause.

JavaScript - Function and Global Variable Declaration
var raSynth            = window.speechSynthesis;
var raUtter            = null;
var raState            = 'stopped';
var raOriginalHTML     = '';
var raPlainText        = '';
var raWordSpans        = [];
var raCurrentHighlight = null;
var raCharIndex        = 0;
var raPausing          = false;

/* ── Get plain text ── */
function raGetText() {
  var el = document.getElementById('readAloudContent');
  return el ? (el.innerText || el.textContent) : '';
}

/* ── Wrap every word in a span ── */
function raWrapWords() {
  var el = document.getElementById('readAloudContent');
  if (!el) return;
  raOriginalHTML = el.innerHTML;
  raPlainText    = el.innerText || el.textContent;
  el.innerHTML   = raWrapTextNodes(el.innerHTML);
  raWordSpans    = Array.prototype.slice.call(
    el.querySelectorAll('span[data-ra-index]')
  );
}

function raWrapTextNodes(html) {
  var wordIndex = 0;
  return html.replace(/(<[^>]+>)|(\b\w+\b)/g, function(match, tag, word) {
    if (tag) return tag;
    var idx = wordIndex++;
    return '<span data-ra-index="' + idx + '">' + word + '</span>';
  });
}

/* ── Restore original HTML ── */
function raRestoreHTML() {
  var el = document.getElementById('readAloudContent');
  if (el && raOriginalHTML) {
    el.innerHTML = raOriginalHTML;
  }
  raCurrentHighlight = null;
  raWordSpans        = [];
}

/* ── Highlight word at absolute charIndex ── */
function raHighlightAt(charIndex) {
  var regex = /\b\w+\b/g;
  var wordMatch;
  var wordNum = 0;
  while ((wordMatch = regex.exec(raPlainText)) !== null) {
    if (wordMatch.index >= charIndex) {
      raHighlightSpan(wordNum);
      return;
    }
    wordNum++;
  }
}

function raHighlightSpan(index) {
  if (raCurrentHighlight) {
    raCurrentHighlight.classList.remove('ra-highlight');
  }
  var span = raWordSpans[index];
  if (span) {
    span.classList.add('ra-highlight');
    raCurrentHighlight = span;
    span.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
  }
}

/* ── Status manager ── */
function raSetStatus(state) {
  raState = state;
  var txt      = document.getElementById('raStatusText');
  var btnPlay  = document.getElementById('raBtnPlay');
  var btnPause = document.getElementById('raBtnPause');
  var btnStop  = document.getElementById('raBtnStop');

  if (state === 'speaking') {
    txt.textContent      = '🔊 Reading...';
    btnPlay.disabled     = true;
    btnPause.disabled    = false;
    btnStop.disabled     = false;
    btnPause.textContent = '⏸ Pause';
  } else if (state === 'paused') {
    txt.textContent      = '⏸ Paused';
    btnPlay.disabled     = true;
    btnPause.disabled    = false;
    btnStop.disabled     = false;
    btnPause.textContent = '▶ Resume';
  } else {
    txt.textContent      = 'Ready';
    btnPlay.disabled     = false;
    btnPause.disabled    = true;
    btnStop.disabled     = true;
    btnPause.textContent = '⏸ Pause';
  }
}

/* ── Core speak from a character offset ── */
function raSpeak(fromCharIndex) {
  if (raSynth.speaking) { raSynth.cancel(); }

  var textToSpeak = raPlainText.slice(fromCharIndex);
  raUtter         = new SpeechSynthesisUtterance(textToSpeak);
  raUtter.rate    = parseFloat(document.getElementById('raSpeed').value);

  raUtter.onstart = function() {
    raPausing = false;
    raSetStatus('speaking');
  };

  raUtter.onboundary = function(e) {
    if (raPausing) return;
    if (e.name === 'word') {
      var absoluteChar = fromCharIndex + e.charIndex;
      raCharIndex = absoluteChar;
      raHighlightAt(absoluteChar);
    }
  };

  raUtter.onend = function() {
    if (raPausing) return;
    raRestoreHTML();
    raCharIndex = 0;
    raSetStatus('stopped');
  };

  raUtter.onerror = function(e) {
    if (e.error === 'interrupted' || raPausing) return;
    raRestoreHTML();
    raCharIndex = 0;
    raSetStatus('stopped');
  };

  raSynth.speak(raUtter);
}

/* ── Controls ── */
function raPlay() {
  raPausing   = false;
  raCharIndex = 0;
  raWrapWords();
  raSpeak(0);
  raSetStatus('speaking');
}

function raPause() {
  if (raState === 'speaking') {
    raPausing = true;
    raSynth.cancel();
    raSetStatus('paused');
  } else if (raState === 'paused') {
    raPausing = false;
    raSpeak(raCharIndex);
    raSetStatus('speaking');
  }
}

function raStop() {
  raPausing = false;
  raSynth.cancel();
  raRestoreHTML();
  raCharIndex = 0;
  raSetStatus('stopped');
}

function raChangeSpeed() {
  if (raState === 'speaking') {
    raSynth.cancel();
    setTimeout(function() { raSpeak(raCharIndex); }, 100);
  }
}

// Expose to global scope (APEX fix)
window.raPlay        = raPlay;
window.raPause       = raPause;
window.raStop        = raStop;
window.raChangeSpeed = raChangeSpeed;

๐Ÿง  How It All Works - A Quick Breakdown

๐Ÿ”ค

Word Wrapping

raWrapWords() walks through the HTML and wraps each word in a <span> with a unique index. This lets us target individual words for highlighting.

๐ŸŽฏ

Boundary Events

The onboundary event fires on every word. We use the charIndex it gives us to match back to the correct span and apply the highlight class.

Pause Trick

Since true pause isn't reliable cross-browser, we cancel the utterance (stops audio), freeze the last charIndex, then restart from that position on resume.

๐ŸŒ

Global Scope Fix

APEX wraps JS in its own scope. That's why we explicitly assign functions to window.raPlay etc., so the inline onclick handlers in the HTML can find them.

Try It Yourself

See the live demo running on Oracle APEX, or grab the full source code on GitHub.

๐Ÿ’ฌ Final Thoughts

What I love most about this feature is that it's entirely browser-native - no external API calls, no billing, no plugins to maintain. The Web Speech API has been around for a while and is supported in all major modern browsers, which makes it a surprisingly reliable tool for APEX applications.

From a user experience perspective, this small addition can make a real difference - especially for users who are multitasking, have reading difficulties, or just prefer consuming content through audio. I've had users genuinely thank me for this in one of my projects, which was a nice moment.

If you found this useful, drop a comment below or share it with your APEX community. Happy building! ๐Ÿš€

#OracleAPEX #JavaScript #WebSpeechAPI #Accessibility #ReadAloud #LowCode

Comments

Popular posts from this blog

Generate Custom PDFs in Oracle APEX with jsPDF

Generate Custom PDFs in Oracle APEX with jsPDF A complete step-by-step guide to building a client-side PDF generator with profile image embedding and PL/SQL database persistence. &#128196; Step-by-step guide Oracle APEX jsPDF Dynamic Actions PL/SQL JavaScript "Picture this: your Oracle APEX application is polished, your users love it — but when they ask for a downloadable report with a profile picture and custom styling, you realize vanilla APEX isn't built for that out of the box. What if a single JavaScript library and one Dynamic Action could change everything?" Welcome to the world of jsPDF inside Oracle APEX . In this tutorial, we wire up a PDF generator that captures a user's name, email, phone number, and profile picture — renders them into a beautiful PDF layout — and saves everything to Oracle, all in one button click. ⓘ What you'll build: A dynamic PDF generat...

APEX Custom Auth Settings, Decoded!

Oracle APEX Security PL/SQL APEX Custom Auth Settings, Decoded Why I Build this I was building an internal APEX app — strictly for people on our corporate network. The default authentication worked, but it had a few things that kept bothering me. Anyone with valid credentials could log in from anywhere. Sessions expired with a useless error page. There was no audit trail — no way to know who logged in, when, or from where. And passwords were stored in plain text, which I just couldn't leave alone. So I did what any developer does — I built it myself. Custom authentication, from scratch, in PL/SQL. Turned out to be one of the best learning experiences I've had with APEX. Here's exactly how I did it. Img 1 : Head to Shared Components → Authentication Schemes → Create, choose Custom as the Scheme Type, and plug in your function and procedure names. Quick note before we get into the code — the s...

APEX 26.1 is here - and there's a lot to talk about

Oracle APEX APEX 26.1 is here - and there's a lot to talk about ๐Ÿš€ A quick look at everything that came in this release - big and small. May 2026  ·  90+ community ideas shipped When APEX 26.1 dropped, I honestly wasn't expecting this much. I opened the release notes thinking I'd be done in 10 minutes. An hour later, I was still reading - going "wait, they added this too?" This post is not a deep dive into any single feature. It's just me walking you through what's new in 26.1, so you know what to look forward to. We'll cover each feature properly in separate posts. But first, let's get the full picture. I also personally tried some of the community-submitted features in this release, and I'll share my honest experience with each one. Some of them are small things - but small things are what make daily dev life better. You'll see what I mean. ๐Ÿ˜„ New 1. APEXlang - Your App Des...