Building a Browser Extension That Improves Reddit Accessibility

Browser extensions remain one of the most powerful tools for tailoring the web experience. From ad blockers to password managers, they demonstrate how far a few lines of JavaScript can go when paired with the right browser APIs.

This tutorial walks through building a Chromium-based extension called “Transcribers of Reddit.” Its purpose is to flag comments containing image transcriptions — often marked with specific keywords and IDs — and pull them to the top of Reddit’s comment section. The extension also adds aria- attributes for screen readers and offers user-controlled visual settings for borders and backgrounds to improve text contrast.

Project Structure and Manifest Setup

Start with a top-level folder for the project, containing a src directory where all extension source files live. The first file to create inside src is manifest.json, which serves as the extension’s entry point and configures its capabilities. Minimal required properties include manifest_version, name, and version.

{
  "manifest_version": 3,
  "name": "Transcribers of Reddit",
  "version": "1.0"
}

manifest_version behaves much like a package manager version — as of writing, version 3 (or mv3) is current and determines which APIs are exposed. The name property appears everywhere users see the extension, including the Chrome Web Store and the browser’s extensions page. The version property, ironically, must be formatted **without** hyphens, accepting only numbers and dots (e.g., 1.3.5).

Use these additional manifest properties to present a polished package to users:

{
  "description": "Reddit made accessible for disabled users.",
  "icons": {
    "16": "images/logo/16.png",
    "48": "images/logo/48.png",
    "128": "images/logo/128.png"
  },
  "homepage_url": "https://lars.koelker.dev/extensions/tor/"
}
  • description should be shorter than 132 characters and appears on the extension management page.
  • icons should be supplied in three sizes; PNG format is best.
  • homepage_url adds a link observable when expanding extension details.
Our opened extension card inside the extension management page.

Declaring Permissions and Default Language

Extensions opt into browser features with an explicit list in manifest.json:


{
  "manifest_version": 3,
  "name": "Transcribers of Reddit",
  "version": "1.0",
  "description": "Reddit made accessible for disabled users.",
  "icons": {
    "16": "images/logo/16.png",
    "48": "images/logo/48.png",
    "128": "images/logo/128.png"
  },
  "homepage_url": "https://lars.koelker.dev/extensions/tor/",

  "permissions": [
    "storage",
    "webNavigation"
  ]
}

Two permissions matter for this project. storage allows the extension to persist user choices across browsing sessions via either chrome.storage.local or chrome.storage.sync. Since Reddit is a single-page application (SPA), the page URL changes without a true reload — webNavigation permission lets the extension observe navigation events and react in a timely manner.

Setting Up Localization

Extensions use the i18n API to handle multilingual support gracefully. Declare the fallback language in manifest.json and then create the folders containing per-language JSON files.

"default_locale": "en"

Set the language to English for this project. In the _locales folder, each supported language has a subdirectory (e.g., en) containing its own messages.json file.

src 
 └─ _locales
     └─ en
        └─ messages.json
     └─ fr
        └─ messages.json

Translation files include places to fix the placeholder fields, the translated text itself, plus another required set, the placeholders. Add dynamic content with the $ wrapper syntax:

{
  "userGreeting": { // Translation key ("id")
    "message": "Good $daytime$, $user$!" // Translation
    "description": "User Greeting", // Optional description for translators
    "placeholders": { // Optional placeholders
      "daytime": { // As referenced inside the message
        "content": "$1",
        "example": "morning" // Example value for our content
      },
      "user": { 
        "content": "$1",
        "example": "Lars"
      }
    }
  }
}

Chrome automatically resolves placeholder references like $1 inside the placeholder definition's content field. While other keys can be added for translator hints, making the translation keys themselves sufficiently descriptive often eliminates this need entirely.

Copy these translations into messages.json:

{
  "name": {
    "message": "Transcribers of Reddit"
  },
  "description": {
    "message": "Accessible image descriptions for subreddits."
  },
  "popupManageSettings": {
    "message": "Manage settings"
  },
  "optionsPageTitle": {
    "message": "Settings"
  },
  "sectionGeneral": {
    "message": "General settings"
  },
  "settingBorder": {
    "message": "Show comment border"
  },
  "settingBackground": {
    "message": "Show comment background"
  }
}

Worth noting: the i18n API doesn’t require a manifest entry for every function. Chrome separates the ecosystem into “no permission required,” “permission but no warning shown to users,” and “hybrid” APIs (like chrome.runtime, where some methods require an entry and some do not).

Using Translations in the Manifest and HTML

Manifest files support placeholders during rendering:

{
  // Update these entries
  "name": "__MSG_name__",
  "description": "__MSG_description__"
}

Similar syntax applies to the _MSG_ pattern elsewhere. Use getMessage in JavaScript to fetch translations in HTML:

chrome.i18n.getMessage('name');

Placeholders work the same way:

chrome.i18n.getMessage('userGreeting', {
  daytime: 'morning',
  user: 'Lars'
});

Writing all this for every element is repetitive. Use a small function that binds the document's entire language-based UI through custom attributes in a new util.js file:

src 
 └─ js
     └─ util.js

Called once after page load:

const i18n = document.querySelectorAll("[data-intl]");
i18n.forEach(msg => {
  msg.innerHTML = chrome.i18n.getMessage(msg.dataset.intl);
});

chrome.i18n.getAcceptLanguages(languages => {
  document.documentElement.lang = languages[0];
});
<!-- Before JS execution -->
<html>
  <body>
    <button></button>
  </body>
</html>
<!-- After JS execution -->
<html lang="en">
  <body>
    <button>Manage settings</button>
  </body>
</html>

Picking up from the placeholder content above, this function loops over elements tagged with data-intl and updates their textContent or placeholder attributes accordingly.

Adding UI: Options and Popup

Extensions typically expose two types of user interface: a larger options page accessible via the extensions menu and a lightweight popup clicked directly off the toolbar icon.

The options page containg our settings.
The pop-up containg a link to the options page.

The project needs this file structure before building either view:

src 
 ├─ css
 |    └─ paintBucket.css
 ├─ popup
 |    ├─ popup.html
 |    ├─ popup.css
 |    └─ popup.js
 └─ options
      ├─ options.html
      ├─ options.css
      └─ options.js

The features themselves are visual toggles. Both views share common scaffolding and pull design from CSS.

Setting Up the Popup

Here's the popup skeleton:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title></title>

    <link rel="stylesheet" href="../css/paintBucket.css">
    <link rel="stylesheet" href="popup.css">

    <!-- Our "translation" script -->
    <script src="../js/util.js" defer></script>
    <script src="popup.js" defer></script>
  </head>
  <body>
    <h1 id="title"></h1>
    <button></button>
  </body>
</html>

The popup consistently displays the extension name and version pulled from the manifest. Hook up settings via popup.js:

const title = document.getElementById('title');
const settingsBtn = document.querySelector('button');
const manifest = chrome.runtime.getManifest();

title.textContent = `${manifest.name} (${manifest.version})`;

settingsBtn.addEventListener('click', () => {
  chrome.runtime.openOptionsPage();
});

It does two things: first, queries the manifest JSON via the chrome.runtime API (which needs no prior permission), turning what might be raw strings into meaningful text for the page's h1; second, adds an event listener that opens the full options interface using chrome.runtime.openOptionsPage() when the button is pressed.

The extensions menu won't show the popup until registered in the manifest:

"action": {
  "default_popup": "popup/popup.html",
  "default_icon": {
    "16": "images/logo/16.png",
    "48": "images/logo/48.png",
    "128": "images/logo/128.png"
  }
},

Working Through the Options Page

The options page allows the user to define formatting preferences instead. Its HTML layout follows a parallax logic similarly present in the popup:

<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title></title>

  <link rel="stylesheet" href="../css/paintBucket.css">
  <link rel="stylesheet" href="options.css">

  <!-- Our "translation" script -->
  <script src="../js/util.js" defer></script>
  <script src="options.js" defer></script>
</head>
<body>
  <header>
    <h1>
      <!-- Icon provided by feathericons.com -->
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" role="presentation">
        <circle cx="12" cy="12" r="3"></circle>
        <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
      </svg>
      <span></span>
    </h1>
  </header>

  <main>
    <section id="generalOptions">
      <h2></h2>

      <div id="generalOptionsWrapper"></div>
    </section>
  </main>

  <footer>
    <p>Transcribers of Reddit extension by <a href="https://lars.koelker.dev" target="_blank">lars.koelker.dev</a>.</p>
    <p>Reddit is a registered trademark of Reddit, Inc. This extension is not endorsed or affiliated with Reddit, Inc. in any way.</p>
  </footer>
</body>
</html>

The relevant code in options.js:

const defaultSettings = Object.freeze({
  border: false,
  background: false,
});
const generalSection = document.getElementById('generalOptionsWrapper');

Freeze the default settings object so later assignments don't accidentally alter base values. To restore saved settings, pull from chrome.storage.local with the get method, passing in the key string directly:

chrome.storage.local.get('settings', ({ settings }) => {
  const options = settings ?? defaultSettings; // Fall back to default if settings are not defined
  if (!settings) {
    chrome.storage.local.set({
     settings: defaultSettings,
    });
 }

  // Create and display options
  const generalOptions = Object.keys(options).filter(x => !x.startsWith('advanced'));
  
  generalOptions.forEach(option => createOption(option, options, generalSection));
});

This makes rendering a dynamic list much easier:

function createOption(setting, settingsObject, wrapper) {
  const settingWrapper = document.createElement("div");
  settingWrapper.classList.add("setting-item");
  settingWrapper.innerHTML = `
  <div class="label-wrapper">
    <label for="${setting}" id="${setting}Desc">
      ${chrome.i18n.getMessage(`setting${setting}`)}
    </label>
  </div>

  <input type="checkbox" ${settingsObject[setting] ? 'checked' : ''} id="${setting}" />
  <label for="${setting}"
    tabindex="0"
    role="switch"
    aria-checked="${settingsObject[setting]}"
    aria-describedby="${setting}-desc"
    class="is-switch"
  ></label>
  `;

  const toggleSwitch = settingWrapper.querySelector("label.is-switch");
  const input = settingWrapper.querySelector("input");

  input.onchange = () => {
    toggleSwitch.setAttribute('aria-checked', input.checked);
    updateSetting(setting, input.checked);
  };

  toggleSwitch.onkeydown = e => {
    if(e.key === " " || e.key === "Enter") {
      e.preventDefault();
      toggleSwitch.click();
    }
  }

  wrapper.appendChild(settingWrapper);
}

When each radio button flips state, calling set with only the changed component avoids field loss via the spread operator:

function updateSetting(key, value) {
  chrome.storage.local.get('settings', ({ settings }) => {
    chrome.storage.local.set({
      settings: {
        ...settings,
        [key]: value
      }
    })
  });
}

Tell the manifest where to find options UI:

"options_ui": {
  "open_in_tab": true,
  "page": "options/options.html"
},

Setting open_in_tab to false would instead display the page as a toolbar popup.

Load and Test in Chrome

Leave the plain editor for the browser to see if the first pass works. In chrome://extensions, enable the developer mode toggle.

The tile appears giving instant access to settings if you toggle any control and reload the page.

Local storage ensures these values don't vanish mid-session.

Powering Actions: Content Script and Service Worker

To impact actual page content, define a content script in comment.js:

"content_scripts": [
  {
    "matches": [ "*://www.reddit.com/*" ],
    "js": [ "js/comment.js" ]
  }
],

Set matches to constrain where it activates and supply the script file in js. Be aware isolated worlds mean the script won't clash with page's main JS context.

// script_on_website.js
const username = 'Lars';

// content_script.js
console.log(username); // Error: username is not defined

The script's logic unfolds in stages. Initially, identify transcriptions present on a direct link versus a within-page navigation event:

const messageTypes = Object.freeze({
  COMMENT_PAGE: 'comment_page',
  SUBREDDIT_PAGE: 'subreddit_page',
  MAIN_PAGE: 'main_page',
  OTHER_PAGE: 'other_page',
});

const Selectors = Object.freeze({
  commentWrapper: 'div[style*="--commentswrapper-gradient-color"] > div, div[style*="max-height: unset"] > div',
  torComment: 'div[data-tor-comment]',
  postContent: 'div[data-test-id="post-content"]'
});

const UrlRegex = Object.freeze({
  commentPage: /\/r\/.*\/comments\/.*/,
  subredditPage: /\/r\/.*\//
});

const CommentUtils = Object.freeze({
  isTorComment: (comment) => comment.querySelector('[data-test-id="comment"]') ? comment.querySelector('[data-test-id="comment"]').textContent.includes('m a human volunteer content transcriber for Reddit') : false,
  torCommentsExist: () => !!document.querySelector(Selectors.torComment),
  commentWrapperExists: () => !!document.querySelector('[data-reddit-comment-wrapper="true"]')
});
let directPage = false;
if (UrlRegex.commentPage.test(window.location.href)) {
  directPage = true;
  moveComments();
}

Distinguish direct page loads in the background via runtime listeners:

chrome.runtime.onMessage.addListener(msg => {
  if (msg.type === messageTypes.COMMENT_PAGE) {
    waitForComment(moveComments);
  }
});

The core function reads options and relocates comments:

function moveComments() {
  if (CommentUtils.commentWrapperExists()) {
    return;
  }

  const wrapper = document.querySelector(Selectors.commentWrapper);
  let comments = wrapper.querySelectorAll(`${Selectors.commentWrapper} > div`);
  const postContent = document.querySelector(Selectors.postContent);

  wrapper.dataset.redditCommentWrapper = 'true';
  wrapper.style.flexDirection = 'column';
  wrapper.style.display = 'flex';

  if (directPage) {
    comments = document.querySelectorAll("[data-reddit-comment-wrapper='true'] > div");
  }

  chrome.storage.local.get('settings', ({ settings }) => { // HIGHLIGHT 18
    comments.forEach(comment => {
      if (CommentUtils.isTorComment(comment)) {
        comment.dataset.torComment = 'true';
        if (settings.background) {
          comment.style.backgroundColor = 'var(--newCommunityTheme-buttonAlpha05)';
        }
        if (settings.border) {
          comment.style.outline = '2px solid red';
        }
        comment.style.order = "-1";
        applyWaiAria(postContent, comment);
      }
    });
  })
}

The applyWaiAria() function corrects screen-reader order and labels:

function applyWaiAria(postContent, comment) {
  const postMedia = postContent.querySelector('img[class*="ImageBox-image"], video');
  const commentId = uuidv4();

  if (!postMedia) {
    return;
  }

  comment.setAttribute('id', commentId);
  postMedia.setAttribute('aria-describedby', commentId);
}

function uuidv4() {
  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
    var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
    return v.toString(16);
  });
}

Behavior triggers after initial comments load:

function waitForComment(callback) {
  const config = { childList: true, subtree: true };
  const observer = new MutationObserver(mutations => {
    for (const mutation of mutations) {
      if (document.querySelector(Selectors.commentWrapper)) {
        callback();
        observer.disconnect();
        clearTimeout(timeout);
        break;
      }
    }
  });

  observer.observe(document.documentElement, config);
  const timeout = startObservingTimeout(observer, 10);
}

function startObservingTimeout(observer, seconds) {
  return setTimeout(() => {
    observer.disconnect();
  }, 1000 * seconds);
}

Service workers are the missing link. They handle navigation events and communicate with the content script in the absence of a direct user gesture. Add it here:

"background": {
  "service_worker": "sw.js"
}

The new worker file lives in root. Prepare for rapid history state updates:

const messageTypes = Object.freeze({
  COMMENT_PAGE: 'comment_page',
  SUBREDDIT_PAGE: 'subreddit_page',
  MAIN_PAGE: 'main_page',
  OTHER_PAGE: 'other_page',
});

const UrlRegex = Object.freeze({
  commentPage: /\/r\/.*\/comments\/.*/,
  subredditPage: /\/r\/.*\//
});

const Utils = Object.freeze({
  getPageType: (url) => {
    if (new URL(url).pathname === '/') {
      return messageTypes.MAIN_PAGE;
    } else if (UrlRegex.commentPage.test(url)) {
      return messageTypes.COMMENT_PAGE;
    } else if (UrlRegex.subredditPage.test(url)) {
      return messageTypes.SUBREDDIT_PAGE;
    }

    return messageTypes.OTHER_PAGE;
  }
});
chrome.webNavigation.onHistoryStateUpdated.addListener(async ({ url }) => {
  const [{ id: tabId }] = await chrome.tabs.query({ active: true, currentWindow: true });

  chrome.tabs.sendMessage(tabId, {
    type: Utils.getPageType(url),
    url
  });
});

Once all elements are in place — list of files, permissions, and handlers merged — confirm there's isn't an unused sw location. Reload the unpacked extension, and the logic works at URL changes when listing an existing post with transcriptions.

The “Transcribers of Reddit” extension highlights a particular comment by moving it to the top of the Reddit thread’s comment list and giving it a bright red border

Extension Development Recap

The workflow feels remarkably approachable once the manifest shell is built. Hooking pages into the browser’s event loop is just clicking through HTML, CSS, and JavaScript at that point. See the original GitHub repository for the complete code and instructions for porting to other engines.