cd /news/ai-tools/build-a-text-to-speech-chrome-extens… · home › topics › ai-tools › article
[ARTICLE · art-152401] src=dev.to ↗ pub= topic=ai-tools verified=true sentiment=↑ positive

Build a Text-to-Speech Chrome Extension

A developer published a tutorial for building a Chrome extension, dubbed Voice Reader, that converts selected web text into speech using the ElevenLabs neural text-to-speech API. The guide walks through a Manifest V3 extension with a popup UI for voice selection and API key storage, a background service worker that calls the ElevenLabs TTS endpoint, and host permissions scoped to the API domain.

by read6 min views1 publishedOct 18, 2026

If you spend any time on the web, you’ve probably wished you could listen to articles, docs, or chat messages instead of reading them. A lightweight Chrome extension that turns selected text into natural‑sounding speech can boost productivity, aid accessibility, and just be plain fun.

In this guide we’ll build a Text‑to‑Speech (TTS) Chrome extension that leverages the ElevenLabs voice AI platform. ElevenLabs offers high‑quality, neural TTS with voice cloning capabilities, and their API is straightforward to call from JavaScript. By the end of the tutorial you’ll have a working extension you can load locally, customize with your favorite voice, and ship to the Chrome Web Store.

👉 Quick link: Want to try ElevenLabs now? Grab a free trial here: https://try.elevenlabs.io/kr07zfuqn1bp

Item Reason
Node.js (>=14) To run the build script and install dependencies.
Chrome browser To load and test the extension.
ElevenLabs API key Required to call the TTS endpoint. Sign up at the link above.
Basic HTML/CSS/JS The extension’s UI is built with vanilla web tech.

Tip: Keep your API key secret! We’ll store it in the extension’s background.js using Chrome’s storage.sync API so it never ends up in the public repo.

Create a new folder called tts-extension and add the following files:

tts-extension/
│�� manifest.json
│─ background.js
│─ popup/
│   ├─ popup.html
│   ├─ popup.css
│   └─ popup.js
└─ icons/
    └─ icon48.png

The manifest.json tells Chrome what the extension can do, while the popup folder holds the UI that appears when you click the extension icon.

{
  "manifest_version": 3,
  "name": "Voice Reader",
  "description": "Select text and have ElevenLabs read it aloud.",
  "version": "1.0.0",
  "permissions": ["storage", "activeTab", "scripting"],
  "action": {
    "default_popup": "popup/popup.html",
    "default_icon": {
      "48": "icons/icon48.png"
    }
  },
  "background": {
    "service_worker": "background.js"
  },
  "host_permissions": ["https://api.elevenlabs.io/*"]
}

Key points

manifest_version: 3 is the current standard. scripting permission lets us inject a content script on the active tab to grab selected text. host_permissions whitelist the ElevenLabs API domain. popup.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Voice Reader</title>
  <link rel="stylesheet" href="popup.css">
</head>
<body>
  <h2>Read Selected Text</h2>
  <select id="voiceSelect"></select>
  <button id="speakBtn">Speak</button>

  <hr>
  <label for="apiKey">ElevenLabs API Key</label>
  <input type="password" id="apiKey" placeholder="Enter API key">
  <button id="saveKeyBtn">Save</button>

  <script src="popup.js"></script>
</body>
</html>

popup.css (keep it minimal)

body { font-family: sans-serif; width: 250px; padding: 10px; }
h2 { font-size: 1.1rem; margin-bottom: 0.5rem; }
button, select, input { width: 100%; margin-top: 5px; }

The UI lets the user (a) pick a voice, (b) trigger speech, and (c) store their ElevenLabs API key securely.

background.js

// Listen for messages from popup.js
chrome.runtime.onMessage.addListener(async (msg, sender, respond) => {
  if (msg.action === 'speak') {
    const { text, voiceId, apiKey } = msg.payload;
    try {
      const audioUrl = await generateAudio(text, voiceId, apiKey);
      // Play audio in a new tab (or you could use Audio() directly)
      chrome.tabs.create({ url: audioUrl });
      respond({ success: true });
    } catch (e) {
      console.error(e);
      respond({ success: false, error: e.message });
    }
    return true; // keep the channel open for async response
  }
});

/**
 * Calls ElevenLabs TTS endpoint and returns a data: URL with the MP3.
 */
async function generateAudio(text, voiceId, apiKey) {
  const response = await fetch(
    `https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`,
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'xi-api-key': apiKey
      },
      body: JSON.stringify({
        text,
        model_id: 'eleven_monolingual_v1',
        voice_settings: { stability: 0.75, similarity_boost: 0.85 }
      })
    }
  );

  if (!response.ok) {
    const err = await response.text();
    throw new Error(`ElevenLabs error: ${err}`);
  }

  const blob = await response.blob();
  return URL.createObjectURL(blob); // returns a temporary URL to the MP3
}

Chrome MV3 forces us to use a service worker instead of a persistent background page. The worker stays alive only while we’re processing a request, keeping the extension lightweight.

popup.js

const voiceSelect = document.getElementById('voiceSelect');
const speakBtn = document.getElementById('speakBtn');
const apiKeyInput = document.getElementById('apiKey');
const saveKeyBtn = document.getElementById('saveKeyBtn');

const ELEVENLABS_API = 'https://api.elevenlabs.io/v1/voices';

// Load saved API key
chrome.storage.sync.get(['apiKey'], (data) => {
  if (data.apiKey) apiKeyInput.value = data.apiKey;
});

// Save API key
saveKeyBtn.onclick = () => {
  const key = apiKeyInput.value.trim();
  if (key) {
    chrome.storage.sync.set({ apiKey: key }, () => alert('API key saved!'));
  }
};

// Populate voice dropdown
async function loadVoices() {
  const key = apiKeyInput.value.trim();
  if (!key) return;
  const res = await fetch(ELEVENLABS_API, {
    headers: { 'xi-api-key': key }
  });
  const { voices } = await res.json();
  voiceSelect.innerHTML = voices
    .map(v => `<option value="${v.voice_id}">${v.name}</option>`)
    .join('');
}
apiKeyInput.addEventListener('blur', loadVoices);

// When user clicks "Speak"
speakBtn.onclick = async () => {
  const [{ result: { selectionText } }] = await chrome.scripting.executeScript({
    target: { tabId: (await chrome.tabs.query({ active: true, currentWindow: true }))[0].id },
    func: () => ({ selectionText: window.getSelection().toString() })
  });

  if (!selectionText) {
    alert('Please select some text on the page first.');
    return;
  }

  const voiceId = voiceSelect.value;
  const apiKey = apiKeyInput.value.trim();

  chrome.runtime.sendMessage(
    {
      action: 'speak',
      payload: { text: selectionText, voiceId, apiKey }
    },
    (response) => {
      if (!response.success) alert(`Error: ${response.error}`);
    }
  );
};

The script does three things:

storage.sync. chrome://extensions/. tts-extension folder. If everything is wired correctly you’ll see a new tab open with the generated audio playing. You can also replace the chrome.tabs.create call with an Audio object if you prefer in‑page playback.

ElevenLabs shines when you upload a short sample (≈30 seconds) and let the service create a cloned voice. The API for cloning is identical; you just use the voice_id that the cloning endpoint returns. Once you have a custom voice_id, drop it into the dropdown or hard‑code it for a personal assistant feel.

You can also experiment with the stability and similarity_boost parameters to make the speech sound more expressive or more like the source voice. Play with values between 0 and 1 until you find the sweet spot for your use case.

When you’re ready to share your extension:

npm run build (if you set up a bundler) or simply zip the folder. Remember to remove any hard‑coded API keys before publishing! Use the storage flow we built above, and let users provide their own keys.

Symptom Likely Cause Fix
No audio plays chrome.tabs.create blocked by popup blocker Use Audio object or request thetabs permission
fetch fails with 401 Wrong or missing API key Verify the key is saved correctly; check for extra spaces
Voices list stays empty API key not set before fetching Click out of the API key field (blur) or add a manual “Load Voices” button
Extension disappears after idle Service worker stopped This is normal; the worker restarts when a message arrives

Open Chrome’s Extensions → Details → Inspect views to view console logs from the background worker and popup.

You now have a functional Text‑to‑Speech Chrome extension powered by ElevenLabs. The core ideas—injecting a content script, calling a cloud TTS API, and handling audio playback—are reusable for many other projects, such as:

The real magic lies in the quality of the voice model, and ElevenLabs provides some of the most natural‑sounding options available today.

Ready to give your extension a premium voice? Try ElevenLabs with the affiliate link and unlock the full suite of voice cloning tools: https://try.elevenlabs.io/kr07zfuqn1bp

Happy coding, and enjoy the sound of your own words!

── more in #ai-tools 4 stories · sorted by recency
── more on @elevenlabs 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/build-a-text-to-spee…] indexed:0 read:6min 2026-10-18 · —