Tip: Accessibility for Interactive Simulations — ARIA, Keyboard, Reduced Motion

A physics simulation is inherently visual. Making it useful to everyone — keyboard-only users, screen-reader users, users with vestibular disorders, low-vision users — requires deliberate, layered design. Here is our full accessibility pattern library.

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.

HTML — live region adjacent to canvas
<!-- 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>
JavaScript — throttled state announcements
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.

JavaScript — canvas keyboard handler
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.

JavaScript — respect reduced motion preference
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.

GLSL fragment shader — high-contrast mode uniform
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:

Quick Compliance Checklist