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.
Un elemento canvas es, para una tecnología de asistencia, un rectángulo sin contenido. No hay estructura, no hay texto, no hay elementos enfocables, no hay nada que anunciar. Todo lo que has construido dentro es invisible para quien no lo ve con los ojos, y eso incluye a mucha más gente de la que se suele calcular: personas ciegas, personas con baja visión que usan ampliación, personas que no pueden usar un ratón, y personas con trastornos vestibulares para quienes una cámara que se mueve sola provoca náuseas reales y duraderas. Ninguno de estos casos se resuelve con un atributo, y todos se resuelven.
- Construir una alternativa textual estructurada que exprese el contenido, no la ausencia de él.
- Hacer una escena completamente operable con el teclado, incluida la cámara.
- Aplicar
prefers-reduced-motionen una escena con movimiento propio con criterio, no apagándolo todo. - Identificar los criterios de las pautas de accesibilidad que un canvas 3D incumple por defecto.
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.
<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.
<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>
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 mismo
for (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.
Los div con role="button" y los objetos 3D con manejadores de clic no son botones. No reciben foco, no responden a la barra espaciadora ni al Enter, no se anuncian como interactivos y no aparecen en la lista de elementos interactivos que los lectores de pantalla ofrecen para navegar. Usa button de verdad. Es más corto de escribir y funciona.
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.
const lienzo = renderer.domElement;
// 1. Enfocable y anunciado como lo que es
lienzo.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:
.lienzo canvas:focus-visible {
outline: 3px solid var(--color-foco, #89b4fa);
outline-offset: 2px;
}
Y el manejo de teclas, con la salida garantizada:
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:
const VELOCIDAD_ORBITA = 1.2; // radianes por segundo
const 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.
<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:
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.
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.
<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:
<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.
El error más extendido en las escenas dirigidas por scroll no es no respetar la preferencia: es respetarla mal. Cuando se detecta movimiento reducido, mucha gente desactiva la animación de cámara y deja la escena congelada en el primer fotograma, con lo que el usuario hace scroll durante cinco pantallas viendo siempre lo mismo y se pierde todo el contenido. La escena tenía información distribuida a lo largo del recorrido y ahora es inalcanzable. La solución correcta no es congelar sino discretizar: en lugar de interpolar la cámara continuamente con el scroll, salta entre las posiciones clave sin transición. El usuario ve las mismas cinco vistas, recibe la misma información, y en ningún momento hay movimiento de cámara. Es más trabajo que un condicional, y es la diferencia entre respetar la preferencia y castigar a quien la tiene activada.
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.
Coge una escena tuya y navégala entera con el teclado, sin tocar el ratón, empezando desde la barra de direcciones del navegador. Anota cada punto donde te quedas atascado o donde no sabes dónde está el foco. Después actívala con un lector de pantalla (VoiceOver en macOS, NVDA en Windows, ambos gratuitos) con los ojos cerrados y comprueba cuánta información recibes. El resultado de esas dos pruebas suele ser más contundente que cualquier auditoría automática, y las dos son gratis.