@real-a11y-dev/core
TL;DR — Extracts a browser-accurate accessibility tree from any DOM element, as plain data (
Map<string, SemanticNode>). Zero runtime dependencies. Reach for this when you're building your own tooling on top of the engine; for apps, use one of the wrapper packages.
Want a ready-made tool, not a custom build? Audit from the shell with
@real-a11y-dev/cli, or give an AI agent accessibility audits via@real-a11y-dev/mcp— both sit on this layer already, so you don't have to build them.
The extraction engine. Every other package is built on top of this.
Install
# Most apps — dev-only (used in tests or behind a dev gate)
npm install -D @real-a11y-dev/core
# Library authors / tools that ship core at runtime — regular dep or peer
npm install @real-a11y-dev/core@real-a11y-dev/core has no runtime dependencies. It uses standard DOM APIs only.
Which type of dependency do I need?
-D(dev) is the right default for most apps — you're usingcoreinside tests, Storybook, or a dev-only audit panel gated byimport.meta.env.DEV/process.env.NODE_ENV. See Keep it out of production.- Regular
dependencyif you're publishing your own package that usescoreat runtime (e.g. a new audit tool on top of the engine). peerDependencyif you're publishing a library that composescorewith other Real A11y packages — lets consumers share a single copy.
Extraction
extractA11yTree(root)
Extracts the accessibility tree from a DOM element.
import { extractA11yTree } from "@real-a11y-dev/core";
const tree = extractA11yTree(document.getElementById("app"));
// tree: { nodes: Map<string, SemanticNode>; rootId: string;
// focusedId?: string; source: { producer: "dom" } }Resolves ARIA roles, computes accessible names via the full ANDC algorithm, detects hidden subtrees, and maps interaction capabilities.
Each node is accessibility-first: a11y is always present, while dom, interaction, and ui are optional facets. The DOM producer here fills all of them, so trees from this package have every facet — but the type is optional because a future native (CDP) producer yields nodes with no backing light-DOM element. If you only handle DOM-produced trees, narrow once to DomSemanticNode (all facets required) at your boundary rather than guarding each node.dom.* / node.interaction.* / node.ui.* read. The source on every ExtractionResult records which producer built it ("dom" here), so serializers and snapshots never silently compare a DOM tree against a native one.
Media elements mirror the browser's native accessibility tree: <video> and <audio> get the video / audio roles Chromium shows in DevTools (ARIA defines no media roles, so a strict HTML-AAM mapping would hide them as generic). Media nodes are leaves — unrendered fallback content and <track>/<source> metadata never become tree nodes — and each carries a properties.captions flag ("true" / "false") telling you whether the element ships a captions or subtitles track (the WCAG 1.2.2 signal). A media element with native controls is reported focusable; its play/seek/volume buttons live in a closed user-agent shadow root that no in-page extractor can reach.
focusedId is the id of the element that held focus at extraction time, when it's inside the extracted subtree — absent when focus rested on <body>/<html> (nothing meaningfully focused) or fell outside the tree. The serializers in @real-a11y-dev/testing render it as a [focused] marker.
extractDomTree(root)
Same shape as extractA11yTree, but uses raw tag names as roles instead of ARIA roles. Useful when you want to audit DOM structure rather than accessibility semantics.
import { extractDomTree } from "@real-a11y-dev/core";
const tree = extractDomTree(document.querySelector("main"));Query helpers
findByRole(tree, role, options?)
Returns the first SemanticNode matching the given role, or null.
import { extractA11yTree, findByRole } from "@real-a11y-dev/core";
const tree = extractA11yTree(root);
const submitBtn = findByRole(tree, "button", { name: /submit/i });Options:
| Option | Type | Description |
|---|---|---|
name | string | RegExp | Filter by accessible name. String: exact match (case/whitespace-normalized). RegExp: .test(). |
level | number | Filter by heading level (1–6). |
checked | boolean | Filter by aria-checked state. |
expanded | boolean | Filter by aria-expanded state. |
selected | boolean | Filter by aria-selected state. |
pressed | boolean | Filter by aria-pressed state. |
disabled | boolean | Filter by aria-disabled state. |
includeHidden | boolean | Include nodes in aria-hidden subtrees (default: false). |
findAllByRole(tree, role, options?)
Returns all matching nodes as SemanticNode[].
const headings = findAllByRole(tree, "heading", { level: 2 });Tree traversal
linearize(tree, options?)
Returns all nodes in pre-order (DOM order), skipping hidden nodes by default.
import { linearize } from "@real-a11y-dev/core";
const nodes = linearize(tree);
// SemanticNode[] in DOM orderOptions:
| Option | Type | Default | Description |
|---|---|---|---|
includeHidden | boolean | false | Include aria-hidden subtrees |
Structure queries
getOutline(tree)
Returns heading nodes as a flat outline list.
import { getOutline } from "@real-a11y-dev/core";
const outline = getOutline(tree);
// [{ id, level, name }, ...]getTabSequence(tree)
Returns focusable nodes in computed tab order.
import { getTabSequence } from "@real-a11y-dev/core";
const sequence = getTabSequence(tree);
// SemanticNode[] — positive tabindex ascending, then natural orderDisclosure-pair index
buildControlsIndex(nodes)
Resolves the aria-controls relationship across a tree and returns adjacency maps in both directions, so callers can render cross-links between disclosure triggers (button, tab, combobox) and the elements they open (menu, panel, listbox).
import { extractA11yTree, buildControlsIndex } from "@real-a11y-dev/core";
const tree = extractA11yTree(root);
const { forward, reverse, inferred } = buildControlsIndex(tree.nodes);
// forward.get(triggerNodeId) → tree-node ids the trigger controls
// reverse.get(controlledNodeId) → tree-node ids of triggers pointing at it
// inferred.has(triggerNodeId) → true when the link came from the heuristic
// fallback rather than an explicit aria-controlsThe lookup happens entirely in tree-node id space — DOM ids are resolved internally so callers never deal with them.
Two link sources, merged into the same maps:
- Explicit
aria-controls— the principled relationship. Always preferred. - Heuristic fallback — for triggers that expose
aria-haspopup+aria-expanded="true"but noaria-controls, paired with the next visible element of the matching role (aria-haspopup="menu"→role="menu",aria-haspopup="listbox"→role="listbox", etc.). Common in apps that skiparia-controls. Conservative by design: skipped when the trigger already hasaria-controls, won't poach an element that's already an explicit target, and excludes hidden candidates.
The inferred set lists trigger ids whose link came from the heuristic — render those cross-links with a "likely" affordance (different style, hedged tooltip) instead of presenting them as ground truth.
ControlsIndex
interface ControlsIndex {
/** trigger tree-node id → tree-node ids it controls */
forward: Map<string, string[]>;
/** controlled tree-node id → tree-node ids of triggers pointing at it */
reverse: Map<string, string[]>;
/** subset of `forward` keys whose link came from the heuristic */
inferred: Set<string>;
}Tree diffing
diffTrees(before, after)
Compares two tree snapshots and returns what changed.
import { diffTrees } from "@real-a11y-dev/core";
const before = extractA11yTree(root);
// ...user interaction...
const after = extractA11yTree(root);
const diff = diffTrees(before, after);
// { added: SemanticNode[], removed: SemanticNode[], changed: NodeChange[] }Node ids come from a per-DOM-node WeakMap, so the same element keeps its id across extractions — that's what makes this an identity-aware comparison rather than a text diff. It also means diffTrees only works within one live document: ids don't survive serialization, so two separately-captured snapshots can't be compared this way (for that, use the CLI's artifact diff).
NodeChange:
interface NodeChange {
id: string;
before: SemanticNode;
after: SemanticNode;
/** Dot-paths that differ, e.g. ["a11y.name", "a11y.states.expanded"] */
changes: string[];
}To render a diff as a committable change list (+ option "Spain", ~ combobox "Country": a11y.states.expanded false → true), pass it to serializeTreeDiff from @real-a11y-dev/serialize.
DOM observation
DomObserver
Watches a DOM subtree for mutations and calls a callback. Rapid mutations are debounced, and a max-wait ceiling ensures the callback still fires on a stream that never goes quiet (streaming responses, progress bars, animated content) instead of the debounce deferring it forever.
The callback receives a TreeChange object containing the accumulated MutationRecords and any synthetic dirty roots produced by input/change events (which MutationObserver cannot see natively).
import { DomObserver, type TreeChange } from "@real-a11y-dev/core";
const observer = new DomObserver(
root,
(change?: TreeChange) => {
console.log("DOM changed, re-extract", change);
},
200, // debounce: wait 200ms of quiet before firing
undefined, // internalIds: mutations to ignore (defaults to the overlay set)
1000, // maxWaitMs: flush at least this often during a continuous stream
);
observer.start();
// Later:
observer.stop();Live tree extraction
LiveTreeExtractor
Stateful extractor that keeps the previous tree in memory and re-extracts only the subtrees that changed, falling back to a full extraction when a mutation has non-local accessibility effects (portal/modal scope changes, id, aria-labelledby, aria-describedby, for, etc.).
Used by the Chrome extension, React hook, and Storybook addon to keep the tree fresh during typing or small DOM updates without paying the cost of a full page walk every time.
import { LiveTreeExtractor, DomObserver } from "@real-a11y-dev/core";
const extractor = new LiveTreeExtractor(document.body, { mode: "a11y" });
const observer = new DomObserver(document.body, (change) => {
const tree = extractor.refresh(change);
// tree is the same shape as extractA11yTree/ extractDomTree
});
observer.start();mode is "a11y" (default) or "dom". Call extractor.extract() for an unconditional full re-extract, or extractor.setMode(mode) to switch views.
Native AX vocabulary
The single shared normalization of Chromium's native accessibility tree (a CDP Accessibility.getFullAXTree payload) into the engine's vocabulary. Pure — no CDP session, no DOM globals — so the same module runs in Node, jsdom, a page, or an extension service worker; fetching the tree is the caller's job (Playwright CDPSession, chrome.debugger.sendCommand, …).
import {
normalizeNativeAX,
serializeNativeAX,
mapNativeAXRole,
NATIVE_AX_VOCABULARY_VERSION,
} from "@real-a11y-dev/core";
const full = await cdp.send("Accessibility.getFullAXTree");
const tree = normalizeNativeAX(full.nodes); // NativeAXNode[], document order
serializeNativeAX(tree); // indented `role "name"` lines, serializeTree-shapedWhat normalization does:
- Drops Blink noise (
NATIVE_AX_DROP_ROLES): text runs (StaticText/InlineTextBox), wrappers (generic/none/presentation), and internals (RootWebArea,ListMarker, …). Kept descendants re-parent to the nearest kept ancestor. - Maps Blink roles to engine roles (
mapNativeAXRole):Video→video,Audio→audio,image→img— the same computed roles the DOM extractor assigns media elements. - Promotes names a node left on a dropped
StaticText/LabelTextchild (listitem "Alpha"keeps its text), without ever overriding an authored name. - Preserves Chromium's document order — siblings follow each parent's
childIds, not the interleaved flat-list order of the raw payload. - Each
NativeAXNodecarriesbackendDOMNodeId(when Chromium exposes one) for CDP enrichment or action dispatch, plus a session-scopedax-dom-*/ax-*id.
NATIVE_AX_VOCABULARY_VERSION names the table revision; stamp it next to snapshots of native trees so output changes across Chromium milestones (or vocabulary updates) are attributable.
Types
import type {
SemanticNode,
DomSemanticNode,
TreeSource,
TreeProducerKind,
ExtractionResult,
SemanticNavigatorConfig,
FindByRoleOptions,
OutlineEntry,
NodeChange,
TreeDiff,
LinearizeOptions,
QueryInput,
TreeViewMode,
ActionType,
TreeChange,
LiveTreeExtractorOptions,
} from "@real-a11y-dev/core";