Accessibility of canvas and WebGL content
A canvas holds pixels, and the browser cannot read pixels back to a screen reader. MDN states it plainly: the element on its own is a bitmap that gives no information about drawn objects, its content is not exposed to accessibility tools as semantic HTML is, and in general canvas is best avoided in an accessible site or app. The HTML standard adds the obligation: when authors use canvas, they must also provide content that conveys essentially the same function or purpose as the bitmap. Everything below is about meeting that obligation for particle backgrounds, charts, product viewers and WebGL scenes alike.
Decide what the canvas is
The right treatment depends on the job the canvas does. Four jobs cover almost every case.
| The canvas is | WCAG 1.1.1 treatment | In practice |
|---|---|---|
| Decoration, such as an ambient particle field | Implemented so assistive technology can ignore it | aria-hidden="true", nothing focusable inside |
| Information, such as a chart or diagram | A text alternative that serves the equivalent purpose | A short name plus the data or description in real page text |
| A control or tool, such as a viewer or editor | A name that describes its purpose, and keyboard operation under 2.1.1 | Native HTML controls next to the canvas |
| A sensory experience, such as generative art | Text alternative at least identifying it | A name and a sentence on what it is |
The first row matters most on sites with animated backgrounds. If the canvas carries nothing a visitor needs, hide it and put the effort elsewhere. The HTML standard also advises against the canvas when a more suitable element exists. Its example is a page heading: if the look is graphically intense, mark it up as h1 and style it, instead of drawing it into a bitmap.
What a browser exposes
The nine small cases below were run in Chromium 151.0.7922.34, driven by Playwright 1.62.1 on macOS. The accessibility tree was read through the DevTools protocol, and one press of Tab shows where keyboard focus lands. This is one engine and one tool. Screen readers combine the browser's tree with their own rules, so a pass here is a starting point and does not replace testing with a screen reader.
| Markup | Exposed in the accessibility tree | Tab focuses |
|---|---|---|
1. <canvas> alone | Nothing | Nothing |
| 2. Canvas with text between the tags | A Canvas node and the text | Nothing |
3. role="img" and aria-label | An image with that name | Nothing |
4. Canvas containing a <button> | Canvas, and the named button | The button |
5. role="img" and a contained button | Image, and the named button | The button |
6. aria-hidden="true" with text inside | Nothing | Nothing |
7. aria-hidden="true" with a button inside | Nothing | The button |
| 8. WebGL2 context and a contained button | Canvas, and the named button | The button |
9. role="img" canvas and a sibling button | Image, and the named button | The button |
Three readings follow. Row 7 is the failure MDN warns about: a keyboard user can reach a control that assistive technology has been told does not exist. Row 8 was checked to make sure the call getContext('webgl2') returned a real WebGL 2.0 context; with it in place the tree and the focus behaviour matched row 4, so the WebGL context itself changed nothing in this engine. And row 5 deserves care. MDN says browsers treat the children of role="img" as presentational, yet Chromium's raw tree still listed the contained button and let it take focus. Playwright's own accessibility snapshot is no help in settling this, because it left the fallback buttons out of every canvas in the same test, including plain canvases with no role at all. How other engines and screen readers treat a button under role="img" is therefore unverified here. Keep controls outside the canvas, as row 9 does, and the question never arises.
Writing the name and the description
A name is a short label that says what the thing is. A description is the longer text that carries the information. Keep them apart. The W3C tutorial's own bar chart example pairs a short description, "Bar chart showing monthly and total visitors for the first quarter 2025 for sites 1 to 3", with a long description that gives scales, values and trends. The same split works for a canvas, with a fictional name such as "Line chart of weekly visits for the last eight weeks" and the numbers in a table directly after it.
Do not begin a name with "image of" or "canvas showing". The role already tells assistive technology what kind of thing it is, and repeating it only adds noise. A decorative canvas gets no name at all, because it is hidden from the tree. If the picture changes meaningfully while the page is open, as when a chart updates, the name or the status line must reflect the change, or the accessible version will describe a picture that is no longer there.
What changes with WebGL
Nothing about the contract changes, but a 3D scene usually has more going on than a chart, so more of it needs a text route. MDN says fallback content shows in browsers without canvas support and when JavaScript is disabled, and the HTML standard says the element represents its fallback content in non-visual media. So the fallback should say something useful and not hold a placeholder.
Labels, hotspots and callouts that belong to a scene are better built as HTML elements positioned over the canvas than as pixels inside it. HTML text can be read, selected, translated and focused, and a hotspot built as a real button comes with its name, its keyboard handling and its focus ring. The scene then renders the picture, and the page carries the meaning. Overlays add elements and layout work of their own, and a performance budget for a site with WebGL shows how to measure that on your own pages.
A keyboard-operable viewer
The pattern below puts a labelled canvas and ordinary controls in a figure. The canvas gets a name, the buttons are real buttons, and a status line reports what changed. The demo draws a square with the 2D context so it can run anywhere, and a WebGL renderer would sit in the same place.
<figure role="group" aria-labelledby="viewer-title">
<figcaption id="viewer-title">Rotating model of a blue square (fictional demo)</figcaption>
<canvas id="viewer" width="320" height="200" role="img"
aria-label="Blue square, shown at the current rotation"></canvas>
<div role="group" aria-label="Viewer controls">
<button type="button" data-turn="-15">Rotate left</button>
<button type="button" data-turn="15">Rotate right</button>
<button type="button" id="pause" aria-pressed="false">Pause rotation</button>
</div>
<p id="viewer-status" role="status"></p>
</figure>
const canvas = document.getElementById('viewer');
const ctx = canvas.getContext('2d');
const status = document.getElementById('viewer-status');
const pause = document.getElementById('pause');
const reduce = matchMedia('(prefers-reduced-motion: reduce)');
let angle = 0;
let playing = false;
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.save();
ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.rotate((angle * Math.PI) / 180);
ctx.fillStyle = '#2f6bff';
ctx.fillRect(-50, -50, 100, 100);
ctx.restore();
}
function announce() {
status.textContent = 'Rotation ' + Math.round(((angle % 360) + 360) % 360) + ' degrees';
}
function loop() {
if (!playing) return; // no frames are scheduled while paused
angle += 0.5;
draw();
requestAnimationFrame(loop);
}
function setPlaying(on) {
const wasPlaying = playing;
playing = on;
pause.setAttribute('aria-pressed', String(!on)); // the label never changes
if (on && !wasPlaying) requestAnimationFrame(loop);
}
document.querySelectorAll('[data-turn]').forEach((b) => {
b.addEventListener('click', () => { angle += Number(b.dataset.turn); draw(); announce(); });
});
pause.addEventListener('click', () => setPlaying(!playing));
reduce.addEventListener('change', () => setPlaying(!reduce.matches));
setPlaying(!reduce.matches); // start paused for people who asked for less motion
draw();
Each decision has a reason:
- Controls are native buttons. They are focusable, operable with Enter and Space, and named by their text. This satisfies WCAG 2.1.1, which requires all functionality to work from a keyboard except where the function depends on the path of the movement. Dragging an object to a location, where the path does not matter, is not exempt, so a drag-to-rotate gesture needs a button equivalent like these.
- The status line is
role="status". MDN says that role carries an implicitaria-livevalue of polite, so updates are announced when the user is idle. The script writes to it only when someone acts, never on each animation frame, which would flood the announcement queue. - The pause control is a toggle with a fixed label. The W3C's ARIA Authoring Practices button pattern says it is critical that a toggle's label does not change when its state changes, and
aria-pressedcarries the state. A moving canvas that starts automatically also needs a way to stop it; prefers-reduced-motion, done properly covers the criteria and the setting. - Frames are scheduled only while playing. The loop ends when paused, which is the on-demand idea from a performance budget for a site with WebGL.
The page was run in Chromium under Playwright, first with no motion preference and then with reduce. With no preference, the canvas changed by itself within 400 ms, and Tab visited Rotate left, Rotate right and Pause in order before leaving the widget. Enter on Rotate right wrote a whole number of degrees to the status line (44 on that run, since the square was already turning). Space on Pause set aria-pressed to true, kept the label as it was, and stopped the animation. With reduce, the canvas stayed still from the start, aria-pressed began as true, Enter on Rotate right wrote "Rotation 15 degrees", and Space on Pause restarted the animation. In both runs the accessibility tree listed the figure group, the image, the controls group, the status and the three buttons.
Charts and data
A chart drawn on canvas gets two things: a short name on the canvas and the data in the page. The W3C complex images tutorial recommends placing the full information elsewhere on the page and associating it, and its figure and figcaption approach can hold headings, prose and a table. It also cautions that text referenced with aria-describedby is read as one continuous paragraph, with no headings or table navigation, so that attribute suits text-only descriptions. For a chart with values, use a real table inside the figcaption and let readers who can see the chart use it too.
Interaction inside the bitmap
If regions inside the canvas must respond to a pointer, the HTML standard asks for a one-to-one mapping of interactive regions to focusable elements in the fallback content, and says focus on those elements still receives keyboard events when the canvas renders. For the visible ring, drawFocusIfNeeded() draws one that follows platform conventions when the given element is focused. The content model is narrower than it looks: MDN lists only a, button and a few input types as interactive descendants. The standard also advises authors against building text-editing controls on canvas, because caret movement, selection, spell-checking and similar features all have to be reimplemented.
Test routine
- Put the mouse aside. Tab through the page and confirm every control is reachable, visible when focused and operable.
- Open the browser's accessibility tree and check that the canvas has the role and name you intended, and that no focusable element sits under
aria-hidden. - Run the page with a screen reader on the platforms you support, since the tree alone does not show what is announced.
- Turn on the operating system's reduced motion setting and reload.
- Turn off scripts or canvas support and confirm the fallback content still says something useful.
Checklist
- The canvas is classified as decoration, information, control or experience.
- Decorative canvases are hidden from assistive technology and contain nothing focusable.
- Informative canvases have a short name and their data in real text or a table.
- Controls are native HTML elements outside the canvas, reachable and operable by keyboard.
- Status changes go to a polite live region, once per user action.
- Motion starts paused when the user prefers reduced motion, and a labelled control stops it.
Questions and answers
Can a screen reader read what is drawn on a canvas?
Not from the pixels. MDN says a canvas on its own is just a bitmap that gives no information about drawn objects, and its content is not exposed to accessibility tools as semantic HTML is. Provide a name, a description or fallback content, and real controls.
Does WebGL change any of this?
No. A WebGL canvas is the same canvas element with a different rendering context. In the Chromium test described here, fallback content inside a canvas behaved the same after a WebGL 2.0 context had been created on it, and one engine is all that was tested.
Should I put aria-hidden on a decorative canvas?
Yes, if it carries no information and has no focusable content inside. WCAG 1.1.1 asks that purely decorative content can be ignored by assistive technology. MDN warns against aria-hidden on anything focusable, and the test below shows a focusable fallback button that stays reachable by keyboard while hidden from the accessibility tree.
Where should the controls for a 3D viewer live?
In ordinary HTML next to the canvas. The HTML standard allows buttons inside canvas fallback content, but separate controls avoid the content model limits and any ambiguity about role=img, and they work the same in every browser.
Sources
- The Canvas element (MDN) developer.mozilla.org
- The canvas element (HTML Standard, WHATWG) html.spec.whatwg.org
- Understanding SC 1.1.1: Non-text Content (W3C) w3.org
- Understanding SC 2.1.1: Keyboard (W3C) w3.org
- Complex images (W3C WAI Images Tutorial) w3.org