Accessibility for any React component.
Build a parallel DOM accessibility tree for UIs that render outside the DOM: WebGL canvases, react-three-fiber scenes, Pixi stages, game UIs. Components declare accessible markup wherever they live in the scene graph; it tunnels into a screenreader-friendly DOM tree that mirrors the scene.
npm i @astralarium/a11y-tree its-fine
Requires React 19.2 or later.
<A11yTreeProvider>:
Root provider. Wrap your canvas (and <A11yTreeRenderer>) in it.
<A11yTreeRenderer>:
Renders the accessibility tree into the DOM. Default class "sr-only" (ie. visually hidden in Tailwind).
<A11yTreeElement>:
Tunnels accessible DOM markup from a scene component into the tree.
<A11yTreeContainer>:
Wraps a subtree, so nested a11y tree components land inside.
<A11yTreeProvider>
<Canvas fallback={<A11yTreeRenderer />}>
<A11yTreeContainer
render={(content) => (
<div role="listbox" aria-label="Hand">
{content}
</div>
)}
>
<Card3D>
<A11yTreeElement>
<div role="option">Ace of Spades</div>
</A11yTreeElement>
</Card3D>
</A11yTreeContainer>
</Canvas>
</A11yTreeProvider>
The a11y tree mirrors the scene hierarchy:
<div role="listbox" aria-label="Hand">
<div role="option">Ace of Spades</div>
</div>
Enables performance-sensitive scenes to use memoized item arrays to avoid re-renders.
<A11yTreeMultiplexer>:
Routes items into slots as data; memoized content moves without remounting or re-rendering:
<A11yTreeSlot>:
Defines a slot in the tree structure. Only renders while an item is inside.
<A11yTreeSlotGroup>:
Groups slots under a shared wrapper, ordered by position in the React tree.
const items = useMemo(
() =>
cards.map((card) => ({
key: card.id,
slotId: card.zone, // "hand" | "board"
render: (
<A11yTreeElement>
<div role="option">{card.name}</div>
</A11yTreeElement>
),
})),
[cards],
);
<A11yTreeMultiplexer items={items}>
<A11yTreeSlotGroup
render={(content) => (
<div role="region" aria-label="Battlefield">
{content}
</div>
)}
>
<A11yTreeSlot
id="hand"
render={(content) => (
<div role="listbox" aria-label="Hand">
{content}
</div>
)}
/>
<A11yTreeSlot
id="board"
render={(content) => (
<div role="listbox" aria-label="Board">
{content}
</div>
)}
/>
</A11yTreeSlotGroup>
</A11yTreeMultiplexer>;
Items keep React identity (by key) when slotId changes, so focus and component state survive zone changes.
Memoized render elements do not re-render when they change order.
Errors thrown by tunneled content are caught in A11yTreeRenderer; the default UI is a dismissible dialog.
Replace it with the fallback prop, wrapped in
<A11yTreeFallbackRenderer portal>
to portal it out of the visually hidden tree container:
<A11yTreeRenderer
fallback={({ error, reset }) => (
<A11yTreeFallbackRenderer portal>
<MyErrorToast message={error?.message} onDismiss={reset} />
</A11yTreeFallbackRenderer>
)}
/>
The tunnel primitive powering the tree is exported for standalone use:
import { fiberTunnel } from "@astralarium/a11y-tree";
const status = fiberTunnel();
// Anywhere in your app — even in a different React root:
<status.In>
<span>Saving…</span>
</status.In>;
// Content from every In renders here:
<status.Out />;
Unlike tunnel-rat, Out content is ordered by each In's position in the React tree, not registration order.
Tree ordering requires a FiberProvider from its-fine above the Ins (A11yTreeProvider provides one).
Also unlike tunnel-rat, content is not mirrored: only one Out is active at a time — the most recently mounted — and the others render nothing (dev warns).
Unmounting the active Out hands back to the previous one.
Hiding the active Out (Suspense/Activity) keeps its claim: React preserves its rendered content, and the tunnel renders nothing else until it is revealed or unmounted.
To show content in different places at different times, keep a single Out mounted and move or restyle its container rather than mounting several Outs.
A11yTreeElement updates whenever its parent re-renders.
Keep elements out of components that re-render every frame, or route them through the multiplexer as memoized items.A11yTreeElement are visible to its children.See examples on the documentation website
pnpm install
pnpm dev
This project uses React Compiler.