# Build a Text-to-Speech Chrome Extension

> Source: <https://dev.to/voice_developer/build-a-text-to-speech-chrome-extension-5d6a>
> Published: 2026-10-18 16:13:58+00:00

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](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**

``` 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 the`tabs` 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](https://try.elevenlabs.io/kr07zfuqn1bp)

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