react-three-a11y: making a WebGL scene reachable by keyboard and screen reader
♿️ Accessibility tools for React Three Fiber
At a glance
- What is it?
- A canvas is a black box to a screen reader and a dead end for a keyboard, so this library adds a layer on top of React Three Fiber that emulates the parts of HTML the browser cannot see. You wrap scene objects in a component, give them a role and a description, and the library turns them into focusable, describable, activatable things that a screen reader announces and a keyboard can reach.
- Who is it for?
- react-three-a11y fits a team shipping a three dimensional scene to the public web who has discovered that keyboard users and screen reader users cannot use it at all. It does not fit a project that renders to a canvas for its own sake with no interactive content, and it does not fit a team that needs a full audit trail of conformance.
- Can I use it commercially?
- Check first. The repository uses a licence we do not classify automatically, so read its LICENSE file before any commercial use.
- Is it still maintained?
- Yes. The repository last received commits 64 days ago.
- What is it written in?
- Mainly TypeScript, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.
Editorial analysis
The problem with a canvas
The library's pitch is one line: it brings accessibility to WebGL with easy-to-use React Three Fiber components. The list of what it provides is the list of things a canvas does not have.
There is focus and focus indication. There is a tab index and keyboard navigation. There is screen reader support and alt text. There are roles and cursor shapes. And there are descriptive links.
Every one of those is something the browser gives you for free with ordinary HTML and takes away entirely when you draw into a canvas element. A canvas is a rectangle of pixels to the browser; the accessibility tree is not built from what you draw, it is built from the document structure, and an object inside a canvas does not exist as far as a screen reader is concerned. Tab does not reach it, a screen reader has nothing to announce, and a link inside the scene is not a link.
So the approach here is emulation. The library keeps a scene graph of its own, mirrored from the three dimensional one, and it builds the missing accessibility behaviour on top: objects that can receive focus, a tab order, an announcer that speaks to a screen reader, and cursor changes on hover so that a mouse user gets the same hint of interactivity that a link would give in a document.
The upstream framework matters here. React Three Fiber is a renderer that maps React components to three dimensional objects, and this library is built to sit alongside it rather than inside it. The package name is scoped to the same organisation, and the peer dependencies require a recent version of React, React DOM, Three, and the framework itself, so the two are used together as a set.
The project is MIT licensed, the documentation lives on the pmndrs site under an a11y section, and there is a live demo. The last push was in August 2026.
Two components, and the order they go in
The quick overview is short, and it consists of two components that do two different jobs.
The first is the announcer, and it goes next to the canvas rather than inside it.
import { Canvas } from '@react-three/fiber';
import { A11yAnnouncer } from '@react-three/a11y';
function App() {
return (
<>
<Canvas />
<A11yAnnouncer />
</>
);
}The readme calls this component critical, because it manages some screen reader features. That word is doing real work: a screen reader needs a live region in the document to announce changes in, and a canvas cannot contain one. So the announcer is a piece of ordinary DOM sitting outside the canvas, and it is the bridge through which the library talks to the assistive technology.
The second component is the wrapper, and it goes around the objects you want to be accessible.
import { A11y } from '@react-three/a11y'
[...]
<A11y>
<MyComponent />
</A11y>After that, the wrapped component can receive focus. The readme is precise about what that means, and the precision is worth repeating because the word focus is doing double duty here. The emulated focus is handled at the A11y component, which acts as a provider for its children to access its state. So the focus lives in the accessibility layer, not in the three dimensional object, and the children read it from the provider.
And the readme adds the sentence that stops people expecting too much: even if objects are focusable, nothing will be displayed or shown by default. Focus is a state, and the state has no visual representation until you draw one. Which is a deliberate division of labour, because the alternative would be a library that guesses at visual design.
Focus, action, and description
Three props carry the behaviour, and each of them maps onto something a person using assistive technology needs.
The first is the focus call, which is invoked each time the component receives focus, which is usually through tab navigation.
<A11y role="content" focusCall={()=> console.log("in focus")} ... />The second is the action call, invoked each time the component is clicked, focused, keyboard activated, and so on.
<A11y role="button" actionCall={()=> console.log("clicked")} ... />The third is the description, which is what a screen reader reads out. Used together with a role, it provides a description to screen reader users on focus or on hover, and with a flag set it can also be shown visually on hover.
// Reads "A rotating red square" to screen readers on focus / hover while also showing it on mouseover
<A11y role="content" description="A rotating red square" showAltText ... />
// Reads "Button, open menu + (description on how to activate depending on the screen reader)" to screen readers on focus / hover
<A11y role="button" description="open menu" actionCall={()=>{someFunction()}} ... />The comments in that second example are the most useful thing in the readme, because they show what a screen reader actually says rather than what the developer intends. For the button role, the announcement is the role, then the description, and then a note about how to activate it, and the readme notes that the last part depends on the screen reader. So the developer writes a description and a role, and the library composes them into a sentence, and the exact wording varies by the assistive technology on the other end.
That is the right division of labour for an accessibility layer over an opaque rendering surface. The developer knows what the object is; the library knows how to talk to the screen reader; the screen reader knows how the person wants to hear it.
The three props are also independent of each other, which is why a content object has a focus call but no action call, and a button has both.
The four roles, and what each one promises
There are four roles, and the readme's framing is that like in HTML, you can focus different kinds of elements and expect different things depending on what you are focusing. That is the right model to copy, because it means a screen reader user who has learned one set of expectations is not relearning them for your scene.
The first is content.
<A11y role="content" ... />It uses the default cursor, and the readme is careful about what it is for: to provide information to screen readers, or to serve as a step for a user navigating your site with the tab key. It is not meant to trigger anything on click and is not meant to be activable with the keyboard, and therefore it will not show a pointer cursor on hover. The last part is the point, because a pointer cursor on something that cannot be clicked is a lie to the mouse user.
The second is button.
<A11y
role="button"
description="Send email"
activationMsg="Sending email" ... />It uses the pointer cursor and has one special attribute, the activation message. The readme says it is meant to emulate the behaviour of a button or a togglable button: it shows a pointer cursor when the mouse is over the linked object, calls the function on click and also on any action that would trigger a focused button, such as Enter or a double tap, and it is actionnable by a user of a screen reader.
The third is the toggle button, which emulates a button with two states, and therefore carries the pressed attribute. It adds a deactivation message to the usual description and activation message properties.
<A11y
role="togglebutton"
description="Dark theme "
activationMsg="Switched to dark theme"
deactivationMsg="Switched to light theme" ... />The fourth is the link, which uses the pointer cursor and has one special attribute, the destination.
<A11y role="link" href="https://url.com" ... />It is meant to emulate the behaviour of a regular HTML link and should be used in combination with something that will trigger navigation on click. The readme carries a note about the destination attribute that is worth taking literally: provide it, because it is required for screen readers to read it correctly, and it will have no effect on the navigation, it is just used as information.
That is a small example of the whole philosophy. The library does not move the page for you. It tells the screen reader that this object is a link and what it points at, and your own code does the navigation.
Sections, preferences, and the tab order
Two features sit outside the wrapper, and both are about context.
The first is the section, which is for giving extra instructions about an unconventional interface. The readme says that for screen readers it might be useful to provide additional information on how to use some unconventional UI, and the way to do it is to wrap the concerned part of the code in the section component with a label and a description.
<A11ySection
label="Shape carousel"
description="This carousel contains 5 shapes. Use the Previous and Next buttons to cycle through all the shapes."
>
[...]
</A11ySection>A screen reader user hearing a list of focusable shapes has no way to know that the set is a carousel, or that there are previous and next buttons, or that it holds five items. A sighted user sees that. The section closes that gap, and it is the kind of thing that is invisible until you notice it is missing.
The second is user preferences, which is a component that gives access to preferences such as a reduced motion preference and a colour scheme preference. The readme points at a documentation page and at the demo, and says that the demo adapts to the system preferences.
That is the accessibility concern that most often goes unnoticed in a three dimensional scene. A spinning object is motion, and some people find motion physically unpleasant, and the browser already knows whether the person has asked for reduced motion at the system level. A library that exposes the preference lets the scene respect it, and the readme's phrasing about the demo adapting is the practical way to see it work.
And then there is the tab index, which is the most likely thing to be got wrong.
<A11y tabIndex={-1} ... />A custom tab index is supported by providing a number to the attribute. But the readme carries a caution: avoid using values greater than zero, because doing so makes it difficult for people who rely on assistive technology to navigate and operate the page content. Instead, write the document with the elements in a logical sequence, with a link to the developer.mozilla.org explanation.
That caution is the single most useful piece of advice in the readme, and it is worth understanding rather than just following. A positive tab index tells the browser to put this element at a fixed point in the global tab order, and it overrides the document order for everything after it. The result is a page whose tab sequence no longer matches its visual and logical structure, which breaks the expectation that tabbing follows the content. A value of negative one is different: it removes the element from the tab sequence entirely while leaving it focusable programmatically, which is the legitimate use.
The package, its size budget, and the test setup
The manifest describes a package that takes its accessibility obligations seriously enough to put a number on them.
The size limit configuration sets a limit of ten kilobytes for two of the built outputs, the common bundle and the module build, and there are two scripts for it, one that reports and one that explains why the bundle grew, using the why flag. A size limit with a stated ceiling is a design constraint rather than an observation, and for a library that every React Three Fiber application may end up pulling in, ten kilobytes is a reasonable promise.
The rest of the manifest is small in a way that says something about the approach. The runtime dependencies are two: a set of utility types, and a small state container. The state container is the interesting one, because the library needs to know which object has focus, which is shared state that the wrapper components read and the announcer writes, and a dedicated store is the natural way to hold it without threading it through the React tree by hand. Everything else is a peer dependency, so the package does not force a version of React, of Three, or of the framework onto the application.
The build tooling is the older generation. The build and the test commands both go through a single tool that handles the bundling, the test runner, and the linter, and the release is prepared by running the build from the prepare script. The engine requirement is a fairly old node version, and the test script is written to pass when there are no tests, which tells you the test suite was not in place at the time of the last release.
The repository layout matches. There is a source directory, a documentation directory, a stories directory with a Storybook configuration, a test directory, an example application with its own lock file and its own public directory, a codesandbox configuration, and a yarn lock file at the root. The stories directory is the interesting one for a library like this: a Storybook story per role is the natural way to demonstrate and review the behaviour of four different focus models, and it is how the demo the readme links to is likely put together.
Editorial conclusion
react-three-a11y fits a team shipping a three dimensional scene to the public web who has discovered that keyboard users and screen reader users cannot use it at all. It does not fit a project that renders to a canvas for its own sake with no interactive content, and it does not fit a team that needs a full audit trail of conformance. Verify first that every interactive object in your scene has a role and a description, because the library only announces what you give it, that the announcer component sits alongside the canvas and not inside it, that the tab order in your scene follows the logical order of the content rather than the order the objects happen to be created, and that the release you install matches the version of React Three Fiber you are on, since the peer dependency range is where the breakage will show up.
Frequently asked questions
What does the react-three-a11y library do?
It provides accessibility support for React Three Fiber scenes: focus and focus indication, a tab index and keyboard navigation, screen reader support and alt text, roles, and cursor shapes. It emulates the accessibility behaviour that a browser provides for HTML but not for a canvas, by keeping its own state for which object is focused and announcing it through a live region.
How do I use the react-three-a11y library?
Place the announcer component next to the canvas component, since it manages the screen reader features and cannot live inside the canvas. Then wrap the objects you want to be focusable in the A11y component, give each one a role and a description, and add a focus call or an action call as appropriate.
What roles does the react-three-a11y A11y component support?
Four. Content, for information that is not activatable and does not show a pointer cursor. Button, which emulates a clickable button and takes an activation message. Toggle button, which emulates a two state button with the pressed attribute and takes a deactivation message. And link, which emulates a regular HTML link and takes a destination attribute for the screen reader to read.
Why should I avoid a positive tabIndex in react-three-a11y?
Because a positive value puts the element at a fixed point in the global tab order and overrides the document order for everything after it, which makes it hard for people relying on assistive technology to move through the content. The readme advises writing the document with the elements in a logical sequence instead, and a negative value such as negative one is the way to remove an element from the tab sequence while keeping it focusable programmatically.
What are the A11yUserPreferences and A11ySection components for?
The preferences component gives access to user preferences such as reduced motion and colour scheme, so a scene can adapt to the system settings. The section component wraps an unconventional part of the interface with a label and a description, so that a screen reader user is told how to operate it rather than only hearing the list of focusable objects inside it.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/pmndrs-react-three-a11y)Was this useful?