La accesibilidad del canvas: el contenido de reserva y sus límites
Poner el contenido de reserva, construir el subárbol de elementos focalizables que sí llega a las ayudas técnicas, y reconocer con honestidad dónde deja de haber solución.
El contenido de reserva
Todo lo que escribes dentro del elemento es contenido de reserva. No se pinta nunca en un navegador que soporte canvas, que a estas alturas son todos, así que su nombre engaña: no es un plan B para navegadores antiguos, es la única descripción de lo que hay dentro.
<canvas id="grafico" width="600" height="360"> Ventas trimestrales de 2026. El primer trimestre cerro en 1,2 millones, el segundo en 1,5 y el tercero en 1,1. La tendencia es al alza salvo en el tercer trimestre, afectado por la parada de la planta de Sevilla.</canvas>Escribir ahí “Tu navegador no soporta canvas” es tirar el único hueco que la plataforma reserva para explicar el gráfico. Es un error tan extendido que sigue apareciendo en documentación oficial de librerías.
Para un canvas estático —un gráfico que no cambia, una ilustración, un código de barras— hay una forma más fiable que el contenido de reserva, porque no depende de que la ayuda técnica decida recorrer el subárbol:
<canvas id="grafico" role="img" aria-label="Ventas trimestrales de 2026: 1,2 millones, 1,5 y 1,1."></canvas>Con role="img" el elemento se anuncia como una imagen con su texto alternativo y el subárbol se ignora, que es exactamente lo que quieres cuando el canvas es un dibujo. Y si la descripción es larga, va en un elemento visible de la página referenciado con aria-describedby, no comprimida en un atributo.
Cuando el contenido cambia, hay que avisar, porque redibujar píxeles no genera ningún evento de accesibilidad. Un texto en una región activa fuera del canvas es lo único que funciona:
<canvas id="mapa" role="img" aria-label="Mapa de sensores"></canvas><p id="estado" aria-live="polite" class="visualmente-oculto"></p>function anunciar(texto) { const p = document.getElementById('estado'); if (p.textContent !== texto) p.textContent = texto; // repetir el mismo texto no anuncia}anunciar('Sensor 14 en alerta. 3 sensores en alerta de 40.');La clase visualmente-oculto es la conocida de siempre: posición absoluta, un píxel de tamaño, clip-path y sin display: none, que eliminaría el elemento del árbol de accesibilidad y con él el anuncio.
El subárbol de elementos reales
Para un canvas interactivo, el contenido de reserva puede ser algo más que texto: puede contener elementos de verdad, con foco, con rol y con manejadores. La especificación obliga a los navegadores a exponer en el árbol de accesibilidad las zonas focalizables que haya dentro de un canvas, y sobre eso se construye la única técnica que existe.
<canvas id="editor" width="600" height="360" style="touch-action:none" tabindex="0" aria-label="Editor de diagrama"> <ul id="reserva"> <li><button data-id="1">Nodo Entrada, en la posicion 1 de 3</button></li> <li><button data-id="2">Nodo Proceso, en la posicion 2 de 3</button></li> <li><button data-id="3">Nodo Salida, en la posicion 3 de 3</button></li> </ul></canvas>Esos botones no se ven, no ocupan espacio y no se pueden pinchar con el ratón, pero existen: se pueden tabular, reciben el foco, se anuncian con su nombre y responden a la tecla Intro. El trabajo consiste en mantenerlos sincronizados con lo dibujado y en cerrar el círculo con drawFocusIfNeeded.
const canvas = document.getElementById('editor');const ctx = canvas.getContext('2d');const reserva = document.getElementById('reserva');// 1. El subarbol se genera desde el modelo, nunca a manofunction sincronizarReserva(escena) { reserva.textContent = ''; escena.orden.forEach((id, i) => { const o = escena.porId.get(id); const li = document.createElement('li'); const b = document.createElement('button'); b.dataset.id = id; b.textContent = `${o.etiqueta}, elemento ${i + 1} de ${escena.orden.length}`; b.setAttribute('aria-pressed', String(escena.interaccion.seleccion.has(id))); b.addEventListener('focus', () => { escena.interaccion.resaltado = id; redibujar(); }); b.addEventListener('click', () => { alternarSeleccion(id); redibujar(); }); li.appendChild(b); reserva.appendChild(li); });}// 2. Al dibujar cada objeto, se ancla su boton a su rutafunction dibujarObjeto(o) { ctx.save(); ctx.translate(o.x, o.y); ctx.fillStyle = o.color; ctx.fill(o.ruta); const boton = reserva.querySelector(`button[data-id="${o.id}"]`); if (boton) ctx.drawFocusIfNeeded(o.ruta, boton); // anillo de foco y aviso a la ayuda ctx.restore();}drawFocusIfNeeded hace dos cosas y la segunda es la importante. Dibuja el anillo de foco del sistema alrededor de la ruta si y solo si el elemento que le pasas tiene el foco, lo que resuelve el problema visual sin reimplementar el aspecto del anillo de cada plataforma. Y comunica al navegador dónde está en la pantalla el elemento enfocado, para que un magnificador pueda seguirlo. Está disponible en los navegadores desde hace años, aunque el segundo comportamiento no se implementa igual en todos.
Queda el teclado, que hay que escribir entero porque no existe:
canvas.addEventListener('keydown', e => { const orden = escena.orden; const actual = orden.indexOf(escena.interaccion.resaltado); switch (e.key) { case 'ArrowRight': case 'ArrowDown': enfocar(orden[Math.min(orden.length - 1, actual + 1)]); e.preventDefault(); break; case 'ArrowLeft': case 'ArrowUp': enfocar(orden[Math.max(0, actual - 1)]); e.preventDefault(); break; case 'Home': enfocar(orden[0]); e.preventDefault(); break; case 'End': enfocar(orden[orden.length - 1]); e.preventDefault(); break; case 'Enter': case ' ': if (escena.interaccion.resaltado !== null) { alternarSeleccion(escena.interaccion.resaltado); e.preventDefault(); } break; }});function enfocar(id) { if (id === undefined) return; reserva.querySelector(`button[data-id="${id}"]`)?.focus();}Dos modos del sistema que el canvas ignora
Hay dos preferencias del usuario que el navegador aplica solo al CSS, y que dentro del canvas hay que implementar a mano porque los píxeles son tuyos.
El alto contraste forzado. Cuando el sistema operativo fuerza una paleta, el CSS se reescribe entero y el canvas se queda exactamente como lo pintaste, con lo que un gráfico cuidadosamente coloreado puede volverse ilegible sobre el nuevo fondo. Se detecta y se corrige leyendo los colores del sistema desde un elemento sonda:
function coloresDelSistema() { const sonda = document.createElement('div'); sonda.style.cssText = 'position:absolute;width:0;height:0;color:CanvasText;' + 'background-color:Canvas;border-color:LinkText'; document.body.appendChild(sonda); const c = getComputedStyle(sonda); const paleta = { texto: c.color, fondo: c.backgroundColor, acento: c.borderColor }; sonda.remove(); return paleta;}const forzado = matchMedia('(forced-colors: active)');function paletaActual() { return forzado.matches ? coloresDelSistema() : paletaDeLaMarca;}forzado.addEventListener('change', redibujar);La reducción de movimiento. Una animación continua en canvas puede provocar mareo a quien ha pedido al sistema que se lo eviten, y el canvas no respeta esa preferencia por sí mismo:
const reducido = matchMedia('(prefers-reduced-motion: reduce)');function animar() { if (reducido.matches) { dibujarEstadoFinal(); return; } // sin bucle requestAnimationFrame(animar);}reducido.addEventListener('change', animar);Qué hacer cuando no hay solución
Después de todo lo anterior queda una lista de casos en los que la respuesta honesta es que el canvas no vale, y merece la pena tenerla presente antes de empezar, no después.
| Lo que necesitas | Qué hacer |
|---|---|
| Un gráfico estático con pocas formas | Usar SVG: es DOM, se lee, se estila y se imprime |
| Un gráfico de datos con muchos puntos | Canvas para el dibujo más una tabla equivalente en el HTML |
| Una interfaz con botones y campos | Poner elementos reales encima del canvas con posición absoluta |
| Texto que el usuario deba leer, buscar o copiar | HTML encima del canvas, nunca fillText |
| Un editor complejo con selección múltiple | Aceptar que la ruta accesible es una interfaz alternativa, no la misma |
La opción de la fila tercera es la más infravalorada y la que resuelve más casos: nada obliga a que todo esté dentro del canvas. Un canvas que dibuja la parte gráfica con elementos HTML posicionados encima para los controles da lo mejor de los dos mundos, cuesta menos código que sintetizar interacción, y es accesible por construcción porque los controles son controles de verdad.
Y la última fila merece decirse con claridad: un canvas interactivo complejo no se puede hacer plenamente accesible con las APIs que existen hoy. Se puede hacer utilizable con teclado y anunciar sus objetos, que es mucho, y eso no equivale a la experiencia que tendría el mismo contenido en el DOM. Cuando el proyecto tiene una obligación legal de conformidad, la ruta realista es ofrecer una vista alternativa completa —una tabla, una lista, un formulario— que exponga los mismos datos y las mismas operaciones. No es una derrota: es la misma decisión que toma cualquier aplicación de escritorio con un lienzo de dibujo.