# Canvas and WebGL Need an Accessible Fallback

**MUST** · **ID:** `content-canvas-accessible-fallback` · **Category:** content
**Source:** [Web Platform](https://web.dev/)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/content-canvas-accessible-fallback

> MUST: A <canvas> (2D or WebGL) is an opaque bitmap to assistive tech. Name it (role="img" + aria-label stating the data, or descriptive fallback children between the tags), mirror any interactive 3D objects in real focusable DOM with an announcer, and provide the same information without the canvas (a data table, a static-image fallback where WebGL is absent).

A <canvas> renders pixels the accessibility tree cannot see — give it a name, fallback content, and a non-canvas path to the same information

Distilled from the Poimandres react-three-a11y docs and the MDN canvas-accessibility guidance, and it is the hardest surface content-accessible-content and content-icons-have-labels ever point at. A `<canvas>` — 2D or WebGL — is a single flat bitmap to a screen reader: every bar in the chart, every clickable region, every label you painted is invisible and unreachable, because none of it is in the DOM. Three fixes stack, in order of effort. First, name the surface: a static visualisation takes `role="img"` plus an `aria-label` that states what it shows ("Revenue by quarter: Q1 $12k, Q2 $18k…"), and any markup placed BETWEEN the `<canvas>` tags is both the legacy no-canvas fallback and what AT reads. Second, for anything interactive, mirror it in real focusable DOM — WebGL objects cannot take Tab focus or receive key events, which is exactly why react-three-a11y injects a parallel HTML layer and an announcer node beside the R3F canvas. Third, provide the information without the canvas at all: a data table behind the chart, and a static image where WebGL is unavailable (the progressive-enhancement half — see performance-webgl-gpu-budget for the runtime side). The test is the corpus's standard one: turn the visual off and check the accessibility tree still answers what the picture said.

## Rule snippet

```tsx
<canvas role="img" aria-label="Revenue by quarter: Q1 $12k, Q2 $18k, Q3 $15k, Q4 $24k" />
{/* plus a visually-hidden <table> with the same data */}
```

## Bad — do not do this

`content-canvas-accessible-fallback-bad`

```tsx
import { useEffect, useRef } from 'react';
import { ScreenReaderView } from '@/components/demo-kit/ScreenReaderView';

const DATA = [
  { label: 'Q1', value: 12 },
  { label: 'Q2', value: 18 },
  { label: 'Q3', value: 15 },
  { label: 'Q4', value: 24 },
];

/**
 * A bar chart painted straight onto a bare <canvas>. To a screen reader it is one
 * empty graphic: no name, no fallback, no data. The numbers are trapped in pixels.
 */
export function CanvasAccessibleFallbackBad() {
  const ref = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const canvas = ref.current;
    const ctx = canvas?.getContext('2d');
    if (!canvas || !ctx) return;
    const color = getComputedStyle(canvas).color;
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    const max = Math.max(...DATA.map((d) => d.value));
    const bw = canvas.width / DATA.length;
    DATA.forEach((d, i) => {
      const h = (d.value / max) * (canvas.height - 12);
      ctx.fillStyle = color;
      ctx.fillRect(i * bw + 8, canvas.height - h, bw - 16, h);
    });
  }, []);

  return (
    <div className="w-full max-w-sm">
      <ScreenReaderView>
        <div className="rounded-lg border border-border bg-card p-4 text-primary">
          {/* No role, no aria-label, no fallback children, no data alternative */}
          <canvas ref={ref} width={280} height={120} className="w-full" />
        </div>
      </ScreenReaderView>
      <p className="mt-4 text-xs text-destructive">
        Screen readers announce nothing — an unnamed graphic with the data locked inside the bitmap.
      </p>
    </div>
  );
}
```

## Good — do this

`content-canvas-accessible-fallback-good`

```tsx
import { useEffect, useRef } from 'react';
import { ScreenReaderView } from '@/components/demo-kit/ScreenReaderView';

const DATA = [
  { label: 'Q1', value: 12 },
  { label: 'Q2', value: 18 },
  { label: 'Q3', value: 15 },
  { label: 'Q4', value: 24 },
];

const SUMMARY = `Revenue by quarter: ${DATA.map((d) => `${d.label} $${d.value}k`).join(', ')}.`;

/**
 * The same chart, made reachable three ways: role="img" plus an aria-label that states
 * the data, a visually-hidden data table as the non-canvas alternative, and a visible
 * caption. Turn the pixels off and the information survives.
 */
export function CanvasAccessibleFallbackGood() {
  const ref = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const canvas = ref.current;
    const ctx = canvas?.getContext('2d');
    if (!canvas || !ctx) return;
    const color = getComputedStyle(canvas).color;
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    const max = Math.max(...DATA.map((d) => d.value));
    const bw = canvas.width / DATA.length;
    DATA.forEach((d, i) => {
      const h = (d.value / max) * (canvas.height - 12);
      ctx.fillStyle = color;
      ctx.fillRect(i * bw + 8, canvas.height - h, bw - 16, h);
    });
  }, []);

  return (
    <div className="w-full max-w-sm">
      <ScreenReaderView>
      <figure className="rounded-lg border border-border bg-card p-4 text-primary">
        <canvas ref={ref} role="img" aria-label={SUMMARY} width={280} height={120} className="w-full" />
        <figcaption className="mt-2 text-xs text-muted-foreground">Revenue by quarter (thousands)</figcaption>
        {/* Non-canvas alternative — read by AT, and the fallback if canvas is unsupported */}
        <table className="sr-only">
          <caption>{SUMMARY}</caption>
          <thead>
            <tr>
              <th scope="col">Quarter</th>
              <th scope="col">Revenue</th>
            </tr>
          </thead>
          <tbody>
            {DATA.map((d) => (
              <tr key={d.label}>
                <th scope="row">{d.label}</th>
                <td>${d.value}k</td>
              </tr>
            ))}
          </tbody>
        </table>
      </figure>
      </ScreenReaderView>
      <p className="mt-4 text-xs text-success">
        Named with role=&quot;img&quot; + aria-label, and backed by a hidden data table — the numbers survive without the pixels.
      </p>
    </div>
  );
}
```

## References

- [Poimandres — react-three-a11y](https://github.com/pmndrs/react-three-a11y)
- [MDN — Canvas accessibility concerns](https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Hit_regions_and_accessibility)
- [WCAG 1.1.1 — Non-text Content](https://www.w3.org/WAI/WCAG21/Understanding/non-text-content.html)
