Accessibility · React Three Fiber · WCAG 2.2
Accessible 3D: keyboard hotspots over a WebGL canvas
A canvas is a black box to assistive technology. The fix is not to make WebGL accessible, it's to put real HTML exactly where the interaction is.
On kids.lego.com I build 3D scenes that children explore by clicking on things: an island with points of interest, a hidden-object game, product models you can turn around. The platform targets WCAG 2.2 AA, and since June 2025 the European Accessibility Act makes that a legal requirement for a lot of consumer products in the EU, not a nice-to-have.
The problem is that a <canvas> is a black box. A screen reader sees one element with no content. The keyboard can't reach anything inside it. Pixels have no focus, no role and no name.
The fix isn't to make WebGL accessible. It's to put real HTML exactly where the interaction is, and let the canvas do what it's good at: drawing.
The pattern: one real button per hotspot
Every interactive object in the scene gets a real <button>, positioned over the object and moved with it every frame. The button is what receives focus, clicks and screen-reader commands. The 3D object is just its visual.
In React Three Fiber, drei's <Html> component does the projection for you: it takes a 3D position and keeps a DOM node on top of it.
import { Html } from "@react-three/drei";
type HotspotProps = {
id: string;
label: string;
position: [number, number, number];
selected: boolean;
onSelect: (id: string) => void;
onActive: (id: string | null) => void;
overlay: React.RefObject<HTMLElement>;
};
export function Hotspot({ id, label, position, selected, onSelect, onActive, overlay }: HotspotProps) {
return (
// portal: render into a container you own (see "Pitfalls" below)
<Html position={position} center zIndexRange={[10, 0]} portal={overlay}>
<button
type="button"
className="hotspot"
aria-pressed={selected}
onClick={() => onSelect(id)}
onFocus={() => onActive(id)}
onBlur={() => onActive(null)}
onPointerEnter={() => onActive(id)}
onPointerLeave={() => onActive(null)}
>
{label}
</button>
</Html>
);
}The button can be a visible label, as in my 3D lab, or a transparent target sitting exactly over the object, which is what a game usually wants. If it's invisible, two things still have to be true: it must be at least 24×24 CSS pixels (WCAG 2.5.8, Target Size) and it must show a clear focus ring when it's focused (2.4.7, Focus Visible).
.hotspot--invisible {
width: 48px;
height: 48px;
border: 0;
border-radius: 50%;
background: transparent;
cursor: pointer;
}
/* Two rings, so it's visible on light and dark parts of the scene */
.hotspot--invisible:focus-visible {
outline: 3px solid #fff;
box-shadow: 0 0 0 6px #000;
}Focus and hover are the same thing
The trick that makes this feel native: keyboard focus, mouse hover and pointer-over on the mesh all write the same piece of state. Whatever highlight a mouse user sees when they point at an object, a keyboard user sees when they tab to it.
const [active, setActive] = useState<string | null>(null);
<group
onPointerOver={(e) => { e.stopPropagation(); setActive(id); }}
onPointerOut={() => setActive(null)}
onClick={() => onSelect(id)}
>
<Model highlighted={active === id || selected === id} />
</group>
<Hotspot id={id} onActive={setActive} /* … */ />Say what happened
When a selection moves the camera, a sighted user sees the change. A screen-reader user hears nothing unless you tell them. A visually hidden aria-live region fixes that, and it costs one line.
<p className="visually-hidden" aria-live="polite">
{selected ? `Selected ${selected.label}. Press Escape to go back.` : ""}
</p>A keyboard model that makes sense
- Tab order follows the DOM order, so render hotspots in the order a person would read the scene, not the order your models happen to load in.
- Escape goes back to the overview, and focus returns to the hotspot you came from. Losing focus to
<body>after closing a view is one of the most common keyboard bugs. - Arrow keys are a nice extra for stepping between objects when you're zoomed in.
- Never let focus land on something off screen (2.4.11, Focus Not Obscured). In my lab, focusing another hotspot while zoomed in flies the camera to it.
const buttons = useRef<Record<string, HTMLButtonElement | null>>({});
function back() {
if (selected) buttons.current[selected]?.focus();
setSelected(null);
}
<div onKeyDown={(e) => { if (e.key === "Escape" && selected) back(); }}>…</div>Respect reduced motion
Camera fly-throughs and idle animations are exactly what prefers-reduced-motion is for. With it on, I jump the camera instead of animating it and render only when something changes, which also saves battery.
const reducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
<Canvas frameloop={reducedMotion ? "demand" : "always"}>Pitfalls I ran into
- z-index wars. drei's
<Html>uses a huge z-index range by default, so the labels can end up above a sticky site header. SetzIndexRangeexplicitly. - The portal target. By default
<Html>picks its container lazily, and that container can change after the first render. With React 19 that showed up as aremoveChilderror and a missing first label. Rendering into a container you own (portal={overlay}) fixes it for good. - Don't hide the wrong thing.
aria-hiddenon the canvas wrapper also hides the buttons inside it. Hide only the canvas, if anything. - Touch scrolling. Orbit controls capture touch drags, which can trap people on a page they want to scroll. I enable orbiting for fine pointers only and let touch users tap.
Testing it
Automated checkers like axe still help: they see the buttons and will flag missing names and poor contrast. They can't see inside the canvas, though, so the core behaviour needs its own test. A keyboard-only walkthrough with a screen reader on (VoiceOver, NVDA) catches the rest.
import { expect, test } from "@playwright/test";
test("hotspots work with the keyboard", async ({ page }) => {
await page.goto("/lab/3d/");
const hotspot = page.getByRole("button", { name: "SITA" });
await hotspot.focus();
await page.keyboard.press("Enter");
await expect(page.locator("[aria-live=polite]")).toContainText("Selected SITA");
await page.keyboard.press("Escape");
await expect(hotspot).toBeFocused();
});Try it
Everything above runs in my 3D lab: open it, put the mouse away and use Tab, Enter, the arrow keys and Escape. It's a small scene, but it's the same approach I use on production campaigns that reach millions of visitors.