Why Simulation Accessibility Is Hard
Standard web accessibility guidance covers text, forms, and navigation. A simulation — a WebGL canvas that updates 60 times per second, responds to mouse drag gestures, and communicates state visually — breaks almost every assumption those guidelines are built on.
The key is accepting that a screen reader cannot render a gravity simulation and instead ensuring that: (1) every simulation has a meaningful text description, (2) every interactive control is reachable by keyboard, and (3) the canvas never causes vestibular harm.
1. ARIA Live Regions for Simulation State
Screen readers ignore canvas elements unless you explicitly mirror simulation state into an ARIA live region. Add a visually-hidden live region adjacent to the canvas and update it at human pace (every 1–2 seconds), not at frame rate.
<!-- The live region must exist in the DOM before the sim starts -->
<canvas id="sim" aria-label="Pendulum simulation"
aria-describedby="sim-desc"></canvas>
<p id="sim-desc" class="visually-hidden">
An interactive double pendulum simulation.
Use the controls below to change mass and length.
</p>
<!-- Updated by JS every ~1 s to announce current state -->
<div id="sim-status"
role="status"
aria-live="polite"
aria-atomic="true"
class="visually-hidden"></div>
<style>
.visually-hidden {
position: absolute; width: 1px; height: 1px;
padding: 0; margin: -1px; overflow: hidden;
clip: rect(0,0,0,0); white-space: nowrap; border: 0;
}
</style>
const statusEl = document.getElementById('sim-status');
let lastAnnounce = 0;
function announceState(theta1, theta2, energy) {
const now = performance.now();
if (now - lastAnnounce < 1500) return; // max once per 1.5 s
lastAnnounce = now;
const deg1 = (theta1 * 180 / Math.PI).toFixed(0);
const deg2 = (theta2 * 180 / Math.PI).toFixed(0);
statusEl.textContent =
`Pendulum 1: ${deg1}°, Pendulum 2: ${deg2}°, ` +
`System energy: ${energy.toFixed(2)} J`;
}
2. Full Keyboard Control
Every action available via mouse drag must be achievable with the
keyboard. Focus the canvas and handle keydown events.
Provide a visible focus indicator — never remove
outline from the canvas without providing a custom focus
ring.
const canvas = document.getElementById('sim');
canvas.setAttribute('tabindex', '0');
canvas.setAttribute('role', 'application');
canvas.addEventListener('keydown', (e) => {
switch (e.key) {
case ' ':
e.preventDefault();
togglePause();
break;
case 'ArrowLeft':
e.preventDefault();
applyImpulse(-0.1, 0);
break;
case 'ArrowRight':
e.preventDefault();
applyImpulse(+0.1, 0);
break;
case 'r':
case 'R':
resetSimulation();
break;
case '?':
showKeyboardHelpDialog();
break;
}
});
// Visible focus ring
canvas.addEventListener('focus', () => canvas.classList.add('focused'));
canvas.addEventListener('blur', () => canvas.classList.remove('focused'));
Add a keyboard shortcut legend below every
simulation — a small <details> element listing
all keys. This serves both as discoverability for sighted keyboard
users and as a hint to screen-reader users reading the page before
interacting.
3. Reduced Motion
Users with vestibular disorders can experience nausea or migraines
from animations with rapid movement or flashing. The
prefers-reduced-motion: reduce media query lets you
detect their preference and provide a static or slow-motion
alternative.
const prefersReducedMotion =
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function tick(t) {
requestAnimationFrame(tick);
if (prefersReducedMotion) {
// Skip rendering on alternating frames (30 fps cap)
if (frameCount++ % 2 !== 0) return;
// Reduce physics step size so motion appears slower
physicsStep(0.004); // half-speed
} else {
physicsStep(0.016); // normal 60 fps
}
render();
}
// Also listen for runtime changes (user changes OS setting mid-session)
window.matchMedia('(prefers-reduced-motion: reduce)')
.addEventListener('change', (e) => {
if (e.matches) pauseParticleTrails();
else resumeParticleTrails();
});
4. Colour Contrast in WebGL
WCAG 2.1 AA requires 3:1 contrast for large graphical objects and UI components against their background. In a dark-themed 3D simulation this is usually fine — except for subtle effects like faint particle trails, anti-aliased edges on a dark sphere against a near-black background, and colour-only encoding of data.
- Never use colour alone to convey information — add shape, label, or pattern. In our disease-spread simulation, infected agents are red circles with a cross icon, not just red.
- Test with a contrast analyser by screenshotting the canvas and running it through a tool like TPGi CCA.
- Provide a high-contrast mode toggle that switches WebGL uniforms to maximum-brightness palette values.
uniform bool u_highContrast;
uniform vec3 u_particleColor;
void main() {
vec3 col = u_highContrast
? vec3(1.0, 1.0, 0.0) // bright yellow — 18:1 on black
: u_particleColor;
gl_FragColor = vec4(col, 1.0);
}
5. Screen Reader Page Structure
Even if the canvas itself cannot be read, the page around it can and should be fully accessible. Our standard simulation page structure follows ARIA landmark best practice:
-
<header>— title and description (always present, indexable) -
<main>— contains the canvas, controls, and live region -
<section aria-label="Controls">— all parameter inputs with<label>/forpairs -
<section aria-label="About this simulation">— educational text, mathematical description <footer>— attribution and links
Quick Compliance Checklist
-
Canvas has
aria-labelandaria-describedby -
Canvas is keyboard focusable (
tabindex="0",role="application") - Visible focus ring on canvas (not suppressed)
- All interactive controls reachable by Tab
-
All sliders and buttons have associated
<label> - Keyboard shortcut legend present in DOM
- ARIA live region announces state at ≤ 1.5 s intervals
-
prefers-reduced-motionrespected — motion halved or paused - Colour is not the sole channel for any data
- High-contrast mode toggle available
- Graphical elements meet 3:1 contrast ratio against background
-
Error states announced to screen readers via
role="alert"