跳转到主要内容
Skip to docs content

Runtime Accessibility Testing in the Browser

Runtime accessibility testing in a real browser lets you check what screen readers announce for any component, including web components built with Lit or custom elements, without spinning up Storybook. Speakable ships two transport-agnostic entry points for this: a browser bundle that analyzes live DOM in the current page, and an iframe harness that mounts a component in isolation and drives analysis over postMessage. Both run the same engine as the CLI and the Storybook addon, so output stays consistent across every surface.

When to Use the Browser Bundle or Iframe Harness

Speakable has several runtime surfaces. Pick the one that matches where your component lives:

SurfaceRuns inBest for
Storybook addonStorybook preview iframeComponent-driven development with Timeline and Diff tabs
Browser bundleThe current page (real browser)Analyzing live DOM in your own app, test runner, or e2e page
Iframe harnessAn isolated iframe you controlTesting a component in isolation, including web components and shadow DOM
CLI runtimeNode plus a headless browserWhole-library regression runs in CI

For the Storybook path, see the Storybook addon guide. For the CLI path, see Runtime Analysis.

How to Analyze Live DOM with the Browser Bundle

The browser bundle is exported at @reticular/speakable/browser. It runs against live DOM in the current page, so it captures JavaScript-set state and open shadow roots that a serialized HTML string would lose. Use it inside a test runner (Vitest, Playwright, Web Test Runner) or anywhere you already have a live element.

Static analysis with analyzeElement

analyzeElement returns per-reader output, audit findings, structural stats, and warnings. It never throws on empty or detached input:

analyze.ts
import { analyzeElement } from '@reticular/speakable/browser';

const result = analyzeElement(document.querySelector('#widget'));

console.log(result.nvda);    // ["Save changes, button", ...]
console.log(result.audit);   // [{ severity, message, selector }, ...]
console.log(result.stats);   // { totalElements, interactiveElements, ... }
console.log(result.warnings);

For web components that hydrate asynchronously, use analyzeElementWithUpgrade. It awaits custom-element upgrade first, then folds any upgrade warnings into the result:

analyze-upgrade.ts
import { analyzeElementWithUpgrade } from '@reticular/speakable/browser';

const result = await analyzeElementWithUpgrade(
  document.querySelector('my-widget')
);

Capture an interaction timeline with captureTimeline

captureTimeline attaches the runtime engine to a live document, runs an interaction sequence, and returns a serializable timeline of focus moves, state changes, and announcements:

capture.ts
import { captureTimeline } from '@reticular/speakable/browser';

const timeline = await captureTimeline(document, {
  componentName: 'Menu',
  sequence: {
    description: 'open and arrow down',
    actions: [
      { type: 'click', selector: '#menu-btn' },
      { type: 'arrowDown' },
    ],
  },
});

for (const event of timeline.events) {
  console.log(event.type, event.target.accessibleName);
}

See the API reference for the full AnalysisResult shape and CaptureOptions.

How to Test a Component in Isolation with the Iframe Harness

The iframe harness, exported at @reticular/speakable/harness, mounts a component into an iframe from a URL or an HTML string, injects the browser bundle, and drives analysis over postMessage. Because it runs in a real browser, open shadow roots and slot projection work correctly, and custom elements are awaited before analysis. This is the way to test any component in isolation without Storybook.

Mount, analyze, and capture

harness.ts
import { createHarness } from '@reticular/speakable/harness';

const harness = createHarness({
  target: { container: document.body }, // harness creates + owns the iframe
  bundleUrl: '/speakable-browser.global.js', // the injectable IIFE bundle
});

// Mount a component (HTML string via srcdoc, or a same-origin URL)
await harness.load({ html: '<my-widget>Content</my-widget>' });

// Static analysis, scoped to a selector
const result = await harness.analyze('my-widget');

// Run an interaction sequence and get the timeline
const timeline = await harness.captureTimeline({
  componentName: 'MyWidget',
  sequence: {
    description: 'toggle',
    actions: [{ type: 'click', selector: 'my-widget' }],
  },
});

harness.destroy();

Where to get the injectable bundle

The harness injects an IIFE build named speakable-browser.global.js, shipped in the package's dist/. Serve it from your app or copy it to a static path, then pass its URL as bundleUrl. If the bundle is already present in the iframe, you can omit bundleUrl.

Same-origin content only

The harness supports HTML strings (mounted via srcdoc) and same-origin URLs. Cross-origin URLs are rejected with a descriptive error, because a script cannot be injected across origins. To test a cross-origin component, embed the bundle in that page and omit bundleUrl.

Testing Web Components and Shadow DOM

Both the browser bundle and the harness run in a real browser, so they handle web component internals that a static Node analysis cannot:

  • Open shadow roots are traversed, so content rendered inside a custom element's shadow tree is analyzed.
  • Slot projection is resolved, so light-DOM children projected into a <slot> appear in the correct shadow-tree order.
  • Custom-element upgrade is awaited via customElements.whenDefined and, where present, a Lit-style updateComplete promise, so components are analyzed after hydration.
  • Closed shadow roots cannot be traversed by any tool. Speakable surfaces a warning so partial coverage is not mistaken for full coverage.

Upgrade waiting is bounded by a timeout, so analysis never hangs. If a component does not upgrade in time, a warning is added to result.warnings and analysis proceeds.

Related Pages