wandres.dev
INTERACCIÓN EN CANVAS · Hit testing sin DOM

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.

⏱ 20 min

Un canvas es, para una ayuda técnica, un rectángulo vacío. No hay texto que leer, no hay estructura que recorrer, no hay nada donde poner el foco y no hay forma de saber que ahí dentro había un botón. La plataforma ofrece dos mecanismos para paliarlo y ninguno de los dos es suficiente por sí solo. Conviene conocerlos, usarlos siempre, y a la vez tener muy claro dónde está el límite, porque hay aplicaciones de canvas que sencillamente no se pueden hacer accesibles y la respuesta correcta es no hacerlas en canvas.

🎯 Al terminar esta lección sabrás
  • Escribir el contenido de reserva y entender cuándo lo ve alguien y cuándo no.
  • Construir un subárbol de elementos reales sincronizado con lo dibujado.
  • Usar drawFocusIfNeeded para anclar el foco a una posición del dibujo.
  • Enumerar lo que no tiene solución y elegir la alternativa adecuada.

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 mano
function 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 ruta
function 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();
}
La técnica del subárbol funciona a medias, y hay que saber exactamente qué mitad falla

Conviene decir sin rodeos qué se consigue con todo lo anterior, porque la literatura sobre el tema es optimista de una forma que no se corresponde con lo que ocurre al probarlo con un lector de pantalla real. Lo que sí funciona de forma fiable: los elementos del subárbol se anuncian, se pueden tabular, tienen nombre y rol, y el usuario puede recorrer los objetos y activarlos. Para un diagrama con veinte nodos, eso ya es la diferencia entre inutilizable y utilizable. Lo que funciona de forma desigual: la posición. Antes existía una API llamada addHitRegion que asociaba una región del canvas con un elemento del subárbol, de modo que la ayuda técnica sabía dónde estaba cada cosa y podía hacer exploración táctil o por magnificación. Se retiró de la especificación y de los navegadores, y no ha sido reemplazada. Lo único que queda es drawFocusIfNeeded, que reporta la posición del elemento enfocado y de ninguno más. La consecuencia práctica es que un usuario de magnificador puede seguir el foco, pero un usuario que explore con el dedo en una pantalla táctil no encuentra nada, porque para el sistema no hay nada en ninguna coordenada concreta. Lo que no funciona en absoluto: el texto dibujado no es texto. No aparece en la búsqueda de la página, no se puede seleccionar ni copiar, no lo traduce el traductor del navegador, no se reajusta al aumentar el tamaño de letra del sistema y no cambia de color en el modo de alto contraste. Y hay una consecuencia de mantenimiento que hunde estas implementaciones con el tiempo: el subárbol y el dibujo son dos representaciones del mismo modelo que hay que mantener sincronizadas a mano, y en cuanto el proyecto tiene varios desarrolladores, alguien añade un objeto al dibujo y no al subárbol. La única defensa es la que aparece en el código de arriba: generar el subárbol desde el mismo bucle que dibuja, de modo que sea imposible que uno tenga algo que el otro no. Si tu subárbol se escribe a mano en el HTML, está desincronizado ya, aunque todavía no lo sepas.

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.