DEV Community

Cover image for How I Built Phantom: A Chrome Extension That Fixes Accessibility So Developers Don't Have To
Naima Kader
Naima Kader

Posted on

How I Built Phantom: A Chrome Extension That Fixes Accessibility So Developers Don't Have To

1.3 billion people worldwide live with some form of disability. When they visit a website, there is a 97% chance it will fail basic accessibility standards — broken form labels, missing image descriptions, unreadable color contrast, keyboard traps that make navigation impossible.

Every tool that exists to help — Lighthouse, axe DevTools, WAVE — does the same thing. It generates a report. It tells you what is broken. Then it leaves you alone.

A developer reads the report, tries to find the broken element somewhere in thousands of lines of HTML, researches what the WCAG rule actually means, attempts a fix, and re-runs the audit. This process takes hours. Most companies never get around to it.

I wanted to build something different. Not a report — a workspace.

That became Phantom.


What I Built

Phantom is a Chrome Extension that transforms how developers interact with accessibility issues. It finds every broken element. It draws a border around it on the real page. It shows you the broken HTML. It generates the corrected HTML. It exports a professional PDF report in one click.

GitHub: github.com/naimakader/phantom
Stack: React 18, TypeScript, Chrome Extension Manifest V3, axe-core, jsPDF, Vite


The Hardest Technical Problem: Scanning a Live Page From Inside a Popup

A Chrome Extension popup is an isolated HTML page. It cannot directly read or modify the tab the user is browsing. The only bridge is Chrome's scripting API.

The naive approach — injecting axe-core as a script tag — fails in Manifest V3 because of the new Content Security Policy restrictions. I discovered the solution after reading Chrome Extension source code and Manifest V3 migration guides: a two-step injection pattern.

Step 1: chrome.scripting.executeScript({ files: ['axe.min.js'] })
This injects axe-core as a web-accessible resource into the live page.

Step 2: chrome.scripting.executeScript({ func: runAxeScan })
Now that axe exists on the page, this runs axe.run() and returns the results.

This two-call pattern is not documented anywhere. It is the only reliable way to run a WebAssembly-powered accessibility engine inside a foreign page from an extension popup.


The Live Element Highlighter

This was the hardest feature to build. After scanning, every broken element on the page should glow red — without breaking the page, without affecting the site's own CSS, and without persisting after the user clears highlights.

Three problems had to be solved.

Z-index conflicts. Many sites use z-index: 9999 on overlays and modals. A naive outline injection would be hidden behind them. The solution was to use outline with outline-offset rather than border or box-shadow — outlines render outside the element's box model and are not affected by the stacking context of child elements.

Cleaning up. Every injected style was scoped to a unique id (phantom-styles) so a single element.remove() call cleans everything up. No leftover CSS, no data attributes.

Hover tooltips without a React tree. The tooltip had to work inside the live page, not inside the popup. This meant pure DOM manipulation — a CSS ::after pseudo-element reading from a data-phantom-label attribute set per element. No React, no event listeners, no memory leaks.


Extracting Live HTML for the Fix Engine

The fix engine needed the actual broken HTML from the page — not a generic example. axe-core returns a CSS selector for each violation. Using that selector to extract the outerHTML required another chrome.scripting.executeScript call with the selector passed as an argument.

The key constraint: chrome.scripting functions run in a completely isolated context. They cannot close over variables from the popup. Everything must be passed explicitly as args. This forced a clean separation between popup state and page-side execution that made the code more reliable overall.


Client-Side PDF Generation Without a Backend

The PDF report needed to look professional — dark cover page, score ring, severity badges, paginated violation list, page numbers. jsPDF provides a low-level drawing API similar to Canvas — every element is positioned with explicit coordinates.

The score ring required manual arc math:

circumference = 2π × radius
arc_length = circumference × (score / 100)
strokeDashoffset = circumference - arc_length

Page breaks required tracking the current Y position and triggering a new page when remaining space was insufficient for the next violation card.

Zero backend infrastructure. Zero user data leaving the machine. Instant downloads.


Why These Architecture Decisions

axe-core over Lighthouse: Lighthouse requires a full page reload and runs in a separate DevTools process. It cannot scan a page being actively browsed. axe-core runs as a JavaScript library directly inside the page's DOM — it sees exactly what the user sees, including dynamically rendered content and SPA state.

Manifest V3 over V2: Google deprecated Manifest V2 in 2024. V3's service worker model forced a cleaner architecture — instead of a persistent background page with direct DOM access, all page interaction is explicit and auditable through chrome.scripting.

chrome.storage over localStorage: chrome.storage.local is shared across all extension contexts — popup, background worker, content scripts. It persists across extension updates and browser restarts. localStorage is scoped to a single page origin and is destroyed when the popup closes.


What I Learned

The browser is a platform, not just a runtime. Building Phantom required understanding how Chrome manages isolated execution contexts, how the scripting API bridges them, and how CSS rendering works at the level of stacking contexts and box models. This is a different category of knowledge from building React applications.

Constraints produce better architecture. The Manifest V3 restriction on background page access forced every page interaction through a single, explicit API. The resulting code is easier to reason about than the V2 equivalent would have been.

The demo is the product. The most important engineering decision I made was choosing the live element highlighter as a feature. A popup showing a list of issues is forgettable. A tool that draws red borders on a real website while you watch — that is memorable. When I demo Phantom, people understand the problem and the solution in under 10 seconds.


Results

  • 57 WCAG 2.2 rules checked per scan
  • Scans BBC.com in under 3 seconds
  • PDF report generates and downloads in under 1 second
  • 13 unit tests — 100% passing
  • 7 features shipped
  • Zero backend infrastructure

What's Next

Multi-page site audit — crawl an entire domain and aggregate scores across pages.
Score trend chart — visualize accessibility improvement over time using D3.
Real AI integration — replace smart mock fixes with actual AI-generated fixes using the Anthropic API.


Final Thought

Building Phantom taught me that the browser is one of the most underestimated platforms in software development. Most developers use it as a display layer. It is actually a full operating system with security boundaries, execution contexts, storage APIs, and scripting capabilities that most people never touch.

I touched all of it. And the result is a tool that makes the web slightly more accessible for 1.3 billion people.

That felt worth building.


Built by Naima Kader
Portfolio: https://portfolio-seven-beryl-29.vercel.app/
GitHub: github.com/naimakader/phantom

Top comments (0)