Skip to content

@real-a11y-dev/storybook-addon

TL;DR — One line in .storybook/main.ts and every story gets a Semantic Navigator panel next to Controls / A11y — showing the tree, heading outline, and tab sequence, updating live while the panel is open (idle when another tab is active). Reach for this when you develop components in Storybook and want per-story a11y insight without writing tests.

A Storybook panel that shows the semantic tree, heading outline, and tab sequence for every story — updated live while the panel is open.

Install

sh
npm install -D @real-a11y-dev/storybook-addon

Peer dependencies: storybook (9.x, 10.x or 11.x), react ^18.0.0 || ^19.0.0, react-dom ^18.0.0 || ^19.0.0.

Storybook 8 is not supported

Storybook 9 folded @storybook/manager-api, @storybook/preview-api and @storybook/theming into the storybook package as the subpath exports storybook/manager-api, storybook/preview-api and storybook/theming. The standalone packages stopped publishing at 8.6, and storybook@8 has no equivalent subpath — so no single build can serve both. On Storybook 8, stay on @real-a11y-dev/storybook-addon@0.1.0-beta.16.

Storybook 11 is in the peer range, as ^11.0.0-0. The -0 is load-bearing: 11.0.0-alpha.0 is the only 11.x published so far, and a plain ^11.0.0 does not match a prerelease — the range would have excluded every Storybook 11 that actually exists. The addon type-checks, builds and passes its tests against that alpha, and an advisory CI job re-runs that check against storybook@next so a breaking change in 11 surfaces here rather than in your install.

Be precise about what that verifies: tsc resolves Storybook 11's own manager-api and preview-api declarations and checks every API the addon uses against them, and the build resolves those same specifiers as externals. The unit tests mock storybook/preview-api, so their passing is not evidence about 11 at runtime. Treat 11 support as provisional while 11 is in alpha.


Setup

Add the addon to .storybook/main.ts:

ts
// .storybook/main.ts
import type { StorybookConfig } from "@storybook/react-vite";

const config: StorybookConfig = {
  addons: [
    "@real-a11y-dev/storybook-addon",    // ← add this
  ],
  // ...
};

export default config;

That's it. No decorator, no parameters required. A Semantic Navigator panel appears next to Controls, Actions, and A11y for every story.


The panel

The panel embeds the full interactive Semantic Navigator tree — the same TreePanel used by the Chrome extension and inspector — rendered inside a CSS-isolated shadow root. A mode switcher in the panel header offers three views:

ModeShows
A11yThe accessibility tree — roles, accessible names, and ARIA states for every node in the story.
DOMThe raw DOM tree — tag names and attributes, useful to confirm whether a semantic element is actually being rendered.
TabFocusable elements in tab order, derived from the current a11y tree.

Switching between A11y and DOM re-extracts the story via the channel; Tab is a client-side transform of the current tree, so it switches instantly.


How it works

The addon follows Storybook's manager/preview split:

  • Preview (@real-a11y-dev/storybook-addon/preview) runs inside the story iframe. Extraction is lazy: it only stands up a DomObserver (200ms debounce) after the manager emits REQUEST_TREE (panel open), and tears it down on STOP_TREE (panel hidden). While active it re-extracts on every debounced DOM mutation, but emits TREE_UPDATED over the Storybook channel only when that extraction differs from the last one it published — so a mutation that extracts to an identical tree (an animation frame rewriting inline style, a change inside an aria-hidden subtree) costs no postMessage and no panel re-render at all. And because the observer only exists while the panel is open, animating or Controls-driven stories pay no extract and no postMessage cost at all while you're on another addon tab.

  • Manager (@real-a11y-dev/storybook-addon/manager) runs in the Storybook UI shell (React). It asks for a tree only while its tab is selected and the addon panel is showing — read off its own box, since from Storybook 10.1 every addon panel stays mounted — subscribes to TREE_UPDATED events, deserializes the tree (the [id, node][] array back into a Map), re-applies the user's expand/collapse via preserveExpandedState (so a Controls tweak or animation does not snap open every row), and renders the interactive TreePanel from @real-a11y-dev/semantic-navigator-ui inside a shadow root.

Preview iframe                              Manager (Storybook UI)
────────────────                            ──────────────────────
(idle until panel opens)                    Panel shown → REQUEST_TREE (mode)
←──────────────────────────────────────────
start DomObserver + extract
→ TREE_UPDATED ───────── channel ─────────▶ deserialize tree (array → Map)
   { tree, mode, extractedAt }              → render <TreePanel/> in a shadow root
canvas reload → PREVIEW_READY ────────────▶ Panel still open → REQUEST_TREE (mode)
←──────────────────────────────────────────
Panel hidden → STOP_TREE
←──────────────────────────────────────────
stop DomObserver (no further extracts)

Using the panel effectively

Open any story and click the Semantic Navigator tab. Then:

  1. Tree — inspect the role, accessible name, and ARIA states of every element in the story canvas. Compare what you see here to what the design intent was.
  2. Outline — verify the heading hierarchy at a glance. Stories that embed full page templates should still produce a coherent outline.
  3. Tab sequence — press the keyboard-navigation icon and confirm the focus order matches user expectation. Disabled controls correctly drop out of the sequence; positive tabindex values show a warning badge.
  4. Mode toggle — flip between A11y and DOM modes. In DOM mode you see raw tag names (e.g. div instead of the implicit role); useful to confirm whether a semantic element is actually being rendered.
  5. Combine with Controls — change a prop (e.g. disabled, aria-label) in the Controls panel and watch the Semantic Navigator panel re-extract within 200 ms. Great for regression-checking state-dependent a11y.

Story parameters

The addon reads no story parameters today — it runs on every story with no per-story configuration required.

TIP

Per-story controls (disabling the panel for a specific story, expected-outline / expected-tab-sequence assertions) are planned for v0.2. The channel architecture is already in place to support them.


Channel events

If you need to integrate with the addon from your own tooling:

ts
import { EVENTS, type TreeUpdatePayload } from "@real-a11y-dev/storybook-addon";
// From Storybook 9 on, the channel APIs live on the `storybook` package:
// `storybook/manager-api` in the UI shell, `storybook/preview-api` in the iframe.
import { addons } from "storybook/manager-api";

// Preview → manager:
//   EVENTS.TREE_UPDATED    — a fresh extraction (on every debounced DOM change that
//                            altered the tree, while the panel is open)
//   EVENTS.PREVIEW_READY   — preview iframe (re)booted; manager re-sends REQUEST_TREE if still open
// Manager → preview:
//   EVENTS.REQUEST_TREE    — start observing, in the mode it carries, and send the current tree
//                            (panel shown); a no-op while already observing in that mode
//   EVENTS.STOP_TREE       — tear down the observer (panel hidden or unmounted)
//   EVENTS.SET_MODE        — re-extract with a new view mode
//   EVENTS.HIGHLIGHT_NODE  — highlight a node in the story by id
//   EVENTS.CLEAR_HIGHLIGHT — clear the highlight overlay
//   EVENTS.ACTIVATE_NODE   — dispatch a node's primary action in the story

const channel = addons.getChannel();
channel.on(EVENTS.TREE_UPDATED, (payload: TreeUpdatePayload) => {
  // payload.tree is a SerializableTree: { nodes: [id, node][], rootId }
  const { nodes, rootId } = payload.tree;
  console.log(rootId, `${nodes.length} nodes`);
  console.log(payload.mode);         // "a11y" | "dom" | "tab"
  console.log(payload.extractedAt);  // Date.now() timestamp
});

TREE_UPDATED is de-duplicated: the preview compares each extraction against the last one it published (mode included, extractedAt excluded) and stays quiet when they are byte-identical. So it fires on changes to the extracted tree, not on every mutation — do not use it as a DOM-mutation heartbeat, and do not expect a fresh extractedAt while a story is animating without semantic effect. The first publish after the panel opens and after a story renders is always sent, as is one whose mode differs from the last published — re-selecting the mode the panel is already in changes nothing and stays quiet like any other no-op.

It is not a general mutation filter, though. class is a key attribute and lands in dom.attributes, so a class toggle always publishes; and node ids are minted per DOM element, so a re-render that replaces elements publishes even when the resulting markup is identical. Only a framework that patches nodes in place de-duplicates.


Troubleshooting

Panel shows "Waiting for story to render…"

This means the preview hasn't emitted a TREE_UPDATED event yet. Check:

  • The story is in story view mode (not docs)
  • #storybook-root exists in the iframe DOM
  • No errors in the Storybook preview console

Panel content is out of date after a story change

The addon listens to storyRendered and storyChanged events while the panel is open. If your framework delays rendering past the storyRendered event, the first snapshot may capture an empty state. A second extraction fires automatically on the next DOM mutation, so the panel catches up within 200ms. With the panel closed, the preview stays idle — open the Semantic Navigator tab to resume.


See it running

  • Storybook 10 + Vite + React — examples/storybook/: Button / Form / Dialog / Navigation stories showing how the panel reacts to Controls changes.
  • Storybook + React 19 — the Storybook + React 19 recipe covers the viteFinal JSX override some React 19 projects need.

Panel features

Inside every story's panel: search, role filters, focus tracking, scoping, keyboard navigation. Same UI as the Chrome extension and inspector — see the shared reference for what each control does and when to reach for it. The addon has no live region log; only the Chrome extension does.

→ Panel features reference

Released under the MIT License. · Privacy · Accessibility