Saltar al contenido

La accesibilidad del 3D: alternativa, teclado y movimiento

Por qué un canvas es opaco para las tecnologías de asistencia, cómo construir una alternativa que funcione de verdad, control completo por teclado, y qué hacer con prefers-reduced-motion en una escena que se mueve sola.

Qué incumple un canvas y por qué

Merece la pena nombrar los criterios concretos, porque convierte una discusión de opiniones en una lista de cosas que arreglar.

Contenido no textual. Cualquier contenido que transmita información necesita una alternativa textual equivalente. Un canvas con un producto, un mapa o una visualización de datos transmite información y no tiene ninguna.

Teclado. Toda la funcionalidad debe poder operarse con teclado. Una escena que solo responde a arrastrar con el ratón excluye a quien usa teclado, conmutadores o control por voz.

Pausar, detener u ocultar. El contenido que se mueve automáticamente durante más de cinco segundos debe poder pausarse. Una escena con rotación automática lo incumple sin excepción.

Destellos. Nada debe destellar más de tres veces por segundo. Un efecto estroboscópico o un bloom parpadeante puede desencadenar una crisis en personas con epilepsia fotosensible.

Sin trampa de teclado. Si el foco entra en el canvas y las teclas de flecha se capturan para mover la cámara, hay que poder salir. Un canvas que se traga el tabulador atrapa al usuario en la página.

Animación por interacciones. Las animaciones de movimiento disparadas por interacción deben poder desactivarse, salvo que sean esenciales. La cámara que se desplaza al hacer scroll es exactamente eso.

Seis criterios, y una escena 3D construida sin pensar en esto los incumple los seis. La buena noticia es que las soluciones son concretas y ninguna exige renunciar al 3D.

La alternativa que funciona

La solución más extendida es también la peor: poner aria-label en el canvas con una frase. Un lector de pantalla anuncia “gráfico, visualización de ventas trimestrales” y ahí se acaba. El usuario sabe que hay algo, no sabe qué dice.

La alternativa correcta expresa el contenido, con la misma estructura que tendría si lo escribieras. Y para eso hay que aceptar una cosa incómoda: el canvas no es la fuente de la verdad, los datos lo son. Si tu escena representa datos, la alternativa se genera de los datos. Si representa un objeto, la alternativa lo describe con la misma precisión que un catálogo.

HTML
<figure class="visor-3d">  <!-- El canvas se oculta a las tecnologias de asistencia: es decorativo       para ellas, porque toda su informacion esta duplicada abajo. -->  <div class="lienzo" aria-hidden="true"></div>  <figcaption>    <h3 id="titulo-visor">Silla Kompas en roble claro</h3>    <p>      Silla de comedor con estructura de roble macizo en acabado natural,      asiento tapizado en lana gris topo y respaldo curvado de una pieza.      Mide 82 cm de alto, 46 de ancho y 52 de fondo. El asiento queda a      45 cm del suelo.    </p>    <h4>Partes del modelo</h4>    <ul>      <li><strong>Respaldo</strong>: contrachapado curvado, 12 mm de grosor.</li>      <li><strong>Asiento</strong>: espuma de alta densidad, tapizada en lana.</li>      <li><strong>Patas</strong>: sección cuadrada de 28 mm, unidas por travesaños.</li>    </ul>  </figcaption></figure>

aria-hidden="true" en el contenedor del canvas es deliberado y correcto si la información está íntegramente en el texto. Es preferible ocultar algo vacío que anunciarlo como si tuviera contenido. Lo que nunca hay que hacer es ocultarlo sin proporcionar la alternativa.

Para escenas interactivas donde el usuario selecciona objetos, la alternativa tiene que ser interactiva también. El patrón que funciona es un espejo en el DOM: una lista de botones reales, uno por objeto seleccionable, que hacen exactamente lo mismo que hacer clic en el objeto.

HTML
<div class="lienzo" aria-hidden="true"></div><nav aria-label="Puntos de interés del modelo">  <ul class="puntos">    <li><button data-objeto="motor" type="button">Motor eléctrico</button></li>    <li><button data-objeto="bateria" type="button">Batería de 400 Wh</button></li>    <li><button data-objeto="frenos" type="button">Frenos hidráulicos</button></li>  </ul></nav><div id="detalle" role="region" aria-live="polite" aria-atomic="true">  <!-- Se rellena al seleccionar, desde el DOM o desde la escena --></div>
JS
const detalle = document.querySelector('#detalle');function seleccionar(clave) {  const info = CATALOGO[clave];  enfocarCamaraEn(info.objeto3d);      // la escena responde  detalle.textContent = info.descripcion;  // el lector de pantalla anuncia}// Los botones del DOM y el clic en la escena llaman a lo mismofor (const b of document.querySelectorAll('.puntos button')) {  b.addEventListener('click', () => seleccionar(b.dataset.objeto));}

La región con aria-live="polite" es lo que hace que el cambio se anuncie sin interrumpir. aria-atomic="true" hace que se lea el contenido completo y no solo lo que cambió, que en una descripción es lo que quieres.

Y fíjate en el detalle más importante de todo: la escena y el DOM llaman a la misma función. No hay dos caminos que puedan divergir. Cuando alguien añade un punto de interés nuevo, aparece en los dos sitios o en ninguno.

Control completo por teclado

Una escena operable con teclado necesita tres cosas: que el canvas pueda recibir el foco, que el foco se vea, y que las teclas hagan algo predecible sin atrapar al usuario.

JS
const lienzo = renderer.domElement;// 1. Enfocable y anunciado como lo que eslienzo.tabIndex = 0;lienzo.setAttribute('role', 'application');lienzo.setAttribute(  'aria-label',  'Visor 3D interactivo. Flechas para girar, más y menos para acercar, Escape para salir del visor.',);

role="application" le dice al lector de pantalla que dentro de este elemento las teclas las gestiona la aplicación y no debe interceptarlas para su propia navegación. Es necesario para que las flechas lleguen a tu código, y es una responsabilidad grande: a partir de ahí, todo el comportamiento de teclado dentro es cosa tuya.

El foco visible no es opcional y el canvas no lo trae:

CSS
.lienzo canvas:focus-visible {  outline: 3px solid var(--color-foco, #89b4fa);  outline-offset: 2px;}

Y el manejo de teclas, con la salida garantizada:

JS
const teclas = new Set();lienzo.addEventListener('keydown', (e) => {  // La salida siempre disponible: sin esto es una trampa de teclado  if (e.key === 'Escape') {    lienzo.blur();    return;  }  // Tabulador NO se captura: tiene que poder salir navegando  if (e.key === 'Tab') return;  const gestionadas = [    'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight',    '+', '-', 'Home', ' ',  ];  if (!gestionadas.includes(e.key)) return;  e.preventDefault();  teclas.add(e.key);  if (e.key === 'Home') restablecerCamara();  if (e.key === ' ') alternarRotacionAutomatica();});lienzo.addEventListener('keyup', (e) => teclas.delete(e.key));lienzo.addEventListener('blur', () => teclas.clear());

El blur que vacía el conjunto de teclas es el detalle que evita el bug clásico: el usuario mantiene una flecha, pulsa el tabulador para salir, y la cámara sigue girando eternamente porque el keyup nunca llegó al canvas.

En el bucle, las teclas se aplican con delta de tiempo como cualquier otro control:

JS
const VELOCIDAD_ORBITA = 1.2;   // radianes por segundoconst VELOCIDAD_ZOOM = 2.5;function aplicarTeclado(dt) {  if (teclas.has('ArrowLeft')) orbita.theta -= VELOCIDAD_ORBITA * dt;  if (teclas.has('ArrowRight')) orbita.theta += VELOCIDAD_ORBITA * dt;  if (teclas.has('ArrowUp')) orbita.phi = Math.max(0.1, orbita.phi - VELOCIDAD_ORBITA * dt);  if (teclas.has('ArrowDown')) orbita.phi = Math.min(Math.PI - 0.1, orbita.phi + VELOCIDAD_ORBITA * dt);  if (teclas.has('+')) orbita.radio = Math.max(1, orbita.radio - VELOCIDAD_ZOOM * dt);  if (teclas.has('-')) orbita.radio = Math.min(20, orbita.radio + VELOCIDAD_ZOOM * dt);}

Un apunte sobre OrbitControls: tiene soporte de teclado a través de controls.keys y controls.listenToKeyEvents(elemento), pero solo mueve la cámara lateralmente, no orbita. Para control completo hace falta el manejo propio de arriba, y es una razón habitual para escribir el control de cámara en lugar de usar el de los addons.

Y una última pieza, barata y muy valorada: un enlace para saltarse el visor.

HTML
<a href="#despues-del-visor" class="salto">Saltar el visor 3D</a><div class="visor-3d">...</div><div id="despues-del-visor" tabindex="-1"></div>

El movimiento, que es el problema serio

De todo lo anterior, lo que tiene consecuencias físicas es el movimiento. Para una persona con trastorno vestibular, una cámara que se desplaza sola o que responde al scroll no es una molestia estética: produce vértigo, náuseas y desorientación que pueden durar horas. No es una minoría pequeña, y es la parte de la accesibilidad del 3D que más se ignora porque el efecto no se ve en una captura de pantalla.

La preferencia del sistema se consulta con una media query, y hay que escuchar los cambios, no solo leerla al arrancar:

JS
const consulta = matchMedia('(prefers-reduced-motion: reduce)');let movimientoReducido = consulta.matches;consulta.addEventListener('change', (e) => {  movimientoReducido = e.matches;  aplicarPreferenciaDeMovimiento();});

Lo que hay que hacer con esa preferencia no es “apagar la animación”. Apagarlo todo convierte una escena 3D en una imagen fija y elimina información. La distinción útil es entre movimiento de la cámara y movimiento en la escena.

El movimiento de la cámara es el problemático, porque el cerebro lo interpreta como movimiento propio y entra en conflicto con el oído interno, que dice que estás sentado. El movimiento de objetos dentro de una escena con la cámara quieta no produce ese conflicto: es como ver un vídeo.

JS
function aplicarPreferenciaDeMovimiento() {  if (movimientoReducido) {    // Nada que mueva el punto de vista sin que el usuario lo pida    rotacionAutomatica = false;    camaraSigueAlScroll = false;    controls.enableDamping = false;      // sin inercia despues de soltar    transicionesDeCamara.duracion = 0;   // los saltos son instantaneos    // Lo que se mueve dentro de la escena puede quedarse, mas suave    animaciones.escalaVelocidad = 0.5;    particulas.visible = false;          // el ruido periferico si molesta  } else {    rotacionAutomatica = true;    camaraSigueAlScroll = true;    controls.enableDamping = true;    transicionesDeCamara.duracion = 0.8;    animaciones.escalaVelocidad = 1;    particulas.visible = true;  }}

Cuatro decisiones concretas y el razonamiento de cada una. La rotación automática se apaga porque es movimiento de cámara no solicitado. El seguimiento del scroll se apaga porque desacopla lo que el usuario hace de lo que ve, que es la peor combinación. El amortiguado se apaga porque el movimiento que continúa después de soltar es exactamente el que desorienta. Y las transiciones de cámara pasan a ser saltos instantáneos, que es mucho más cómodo que un desplazamiento suave de un segundo.

Además de respetar la preferencia del sistema, ofrece un control visible. Mucha gente no sabe que esa preferencia existe o la tiene desactivada por otros motivos.

HTML
<button  type="button"  id="alternar-movimiento"  aria-pressed="false">  Reducir el movimiento</button>

Y el control de pausa, que es el criterio de pausar, detener u ocultar:

HTML
<button type="button" id="pausa" aria-pressed="false">  Pausar la animación</button>

Ambos con aria-pressed, actualizado al cambiar de estado, para que el lector de pantalla anuncie si está activo.

Contraste, tamaño y destellos

Tres apuntes finales que se olvidan porque parecen de otro dominio.

Contraste. El texto renderizado dentro de la escena, sea con geometría o con sprites, tiene que cumplir la misma relación de contraste que el texto del DOM. Y es más difícil, porque el fondo cambia con la cámara. La solución práctica es no renderizar texto informativo en 3D: superponerlo en HTML, donde el contraste es controlable y además es seleccionable, ampliable y accesible por defecto.

Objetivos táctiles. Un objeto 3D pequeño que hay que tocar con precisión es un objetivo táctil diminuto. El área efectiva de un raycast contra un objeto de veinte píxeles es de veinte píxeles. Si un objeto es interactivo, dale una zona de impacto invisible más grande, o expón la acción también como un botón del DOM con tamaño suficiente.

Destellos. Cualquier efecto que parpadee más de tres veces por segundo en un área grande de la pantalla es un riesgo real. Los sospechosos habituales en 3D son el bloom sobre una fuente de luz que oscila, los efectos de rayo o chispa, las transiciones de escena con flash, y los shaders de ruido de alta frecuencia sobre pantalla completa. Si tu efecto entra en esa descripción, mídelo: cuenta los picos de luminancia media por segundo, y si pasa de tres, redúcelo.