Una gráfica de datos accesible
Por qué un título no basta, la tabla de datos como alternativa canónica, la navegación por teclado de las series, el color que no puede ser el único canal, y la animación que hay que poder desactivar.
Un gráfico sin alternativa textual es contenido perdido para una parte de tus usuarios, y ninguna cantidad de atributos ARIA lo arregla, porque el problema no es de etiquetado sino de que la información vive en la posición de unos píxeles. La solución es concreta y lleva años probada: los datos tienen que existir en algún sitio en forma de texto estructurado, y el gráfico es una vista más de ellos.
- Explicar por qué el nombre y la descripción no bastan para un gráfico de datos.
- Construir la alternativa de tabla y conectarla con el gráfico.
- Añadir navegación por teclado a las series sin romper el resto.
- Aplicar los tres requisitos que no son de marcado: color, contraste y movimiento.
Por qué un título no basta
«Ventas mensuales de 2025» es un nombre correcto y no transmite ningún dato. Un usuario que no ve el gráfico sabe de qué va y no sabe nada de lo que dice.
La descripción ayuda y tiene un límite. «Las ventas suben de enero a junio, caen en julio y agosto, y se recuperan en el último trimestre» es información real y es una interpretación, no los datos. El usuario no puede sacar sus propias conclusiones, no puede comparar dos meses concretos, y depende de que quien escribió la descripción viera lo mismo que él habría visto.
La prueba que hay que aplicar: ¿puede alguien responder con la alternativa textual las mismas preguntas que respondería mirando el gráfico? Si el gráfico permite comparar dos meses, la alternativa tiene que permitirlo. Si permite ver la tendencia, tiene que permitirlo.
Solo hay una estructura que cumple eso sin escribir prosa infinita: la tabla.
La tabla como alternativa canónica
El patrón completo:
<figure class="grafico">
<div class="lienzo">
<svg role="img" aria-labelledby="g1-t g1-d" viewBox="0 0 400 200">
<title id="g1-t">Ventas mensuales de 2025 en miles de euros</title>
<desc id="g1-d">Los datos completos están en la tabla que acompaña al gráfico.</desc>
<!-- las barras -->
</svg>
</div>
<figcaption id="g1-cap">
Ventas mensuales de 2025.
<button type="button" aria-expanded="false" aria-controls="g1-tabla">
Ver los datos
</button>
</figcaption>
<table id="g1-tabla" hidden>
<caption>Ventas mensuales de 2025, en miles de euros</caption>
<thead>
<tr><th scope="col">Mes</th><th scope="col">Ventas</th></tr>
</thead>
<tbody>
<tr><th scope="row">Enero</th><td>128</td></tr>
<tr><th scope="row">Febrero</th><td>141</td></tr>
<tr><th scope="row">Marzo</th><td>139</td></tr>
</tbody>
</table>
</figure>
Cinco decisiones ahí, todas deliberadas.
La tabla está en el DOM, no generada al pulsar. Así existe para el lector de pantalla desde el principio y para el buscador. El botón solo cambia su visibilidad.
El hidden la oculta del todo, incluido el árbol de accesibilidad. Alternativa: mostrarla siempre con la clase de ocultación visual, para que esté disponible sin interacción. Las dos son válidas; la segunda es más accesible y la primera permite que un usuario que ve la revele.
Las cabeceras usan scope. Sin él, un lector de pantalla no puede anunciar «Febrero, ventas 141» al navegar por celdas. Con scope="row" en la primera columna y scope="col" en la fila de cabecera, la tabla es navegable de verdad.
El figcaption está fuera del SVG. Es texto de la página, seleccionable, traducible, con formato.
El botón declara aria-expanded y aria-controls. El primero anuncia el estado, el segundo la relación. Y hay que actualizarlo al pulsar, no solo cambiar el hidden.
boton.addEventListener('click', () => {
const abierto = boton.getAttribute('aria-expanded') === 'true';
boton.setAttribute('aria-expanded', String(!abierto));
tabla.hidden = abierto;
});
Esta tabla no es un extra: es la fuente de datos del gráfico. Si generas el SVG desde un array, generas la tabla del mismo array y nunca se desincronizan. El coste marginal es de veinte líneas.
Navegación por teclado de las series
Cuando el gráfico es interactivo (los puntos muestran un tooltip al pasar el ratón), esa interacción tiene que existir también con teclado. El patrón:
<g role="list" aria-label="Serie de ventas mensuales">
<g role="listitem" tabindex="0" aria-label="Enero: 128 mil euros">
<rect x="10" y="60" width="24" height="140" fill="#89b4fa" />
</g>
<g role="listitem" tabindex="0" aria-label="Febrero: 141 mil euros">
<rect x="44" y="45" width="24" height="155" fill="#89b4fa" />
</g>
</g>
Y el SVG no lleva role="img", porque eso borraría el subárbol interactivo.
Con doce barras esto es razonable. Con quinientos puntos es una tortura: quinientas paradas de tabulación. La solución estándar es la gestión de foco con un solo punto de entrada: el grupo tiene tabindex="0" y los elementos tabindex="-1", y las flechas mueven el foco entre ellos con JavaScript. Es el mismo patrón que una barra de herramientas o una rejilla.
const puntos = [...grupo.querySelectorAll('[role="listitem"]')];
let activo = 0;
grupo.addEventListener('keydown', (e) => {
if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
e.preventDefault();
activo = (activo + (e.key === 'ArrowRight' ? 1 : -1) + puntos.length) % puntos.length;
puntos[activo].focus();
});
Y el estilo de foco tiene que ser visible dentro del SVG, lo que no es automático:
[role="listitem"]:focus-visible rect {
stroke: #f9e2af;
stroke-width: 3;
}
[role="listitem"]:focus { outline: none; }
El outline por omisión de un elemento SVG enfocado se dibuja alrededor de su caja y a menudo queda cortado por el viewport. Sustituirlo por un trazo en la propia forma es más fiable y más bonito.
Los tres requisitos que no son de marcado
Uno: el color no puede ser el único canal. Dos series distinguidas solo por color son indistinguibles para un porcentaje significativo de la población. La cifra estándar: alrededor del 8 por ciento de los hombres de ascendencia noreuropea tienen alguna forma de deficiencia en la percepción rojo-verde, frente a un 0,5 por ciento de las mujeres. El arreglo es añadir un segundo canal: forma del marcador, patrón de guiones en la línea, textura de relleno, o etiquetado directo de cada serie junto a su extremo.
El etiquetado directo es además la mejor solución de usabilidad para todo el mundo, porque elimina el ir y venir a la leyenda.
Dos: el contraste de los elementos gráficos. Las pautas exigen 3 a 1 para los objetos gráficos necesarios para entender el contenido. Eso incluye las barras contra el fondo, las líneas de datos, y el propio marcador de foco. No incluye las líneas de rejilla, que son decorativas si los valores están etiquetados.
Tres: el movimiento tiene que poder desactivarse. Una animación de entrada de las barras es agradable y para algunas personas es un problema real, desde la distracción hasta el mareo vestibular. La consulta de medios existe y hay que respetarla:
@media (prefers-reduced-motion: reduce) {
.barra { transition: none; animation: none; }
}
Y en JavaScript, cuando la animación se controla desde código:
const reducir = matchMedia('(prefers-reduced-motion: reduce)').matches;
const duracion = reducir ? 0 : 600;
Más allá de la tabla hay dos técnicas que se usan en productos serios y que casi nadie conoce.
La sonificación mapea los valores a tono y los reproduce como una secuencia. Con la API de audio del navegador se implementa en unas cuarenta líneas: un oscilador cuya frecuencia se mapea desde el valor con una escala logarítmica, y una nota por punto. Para una serie temporal es sorprendentemente eficaz: la tendencia se percibe en dos segundos, mucho más rápido que leyendo doce números. No sustituye a la tabla, la complementa: la tabla da precisión, el sonido da forma.
La exploración táctil con la vibración del dispositivo, en móvil, hace algo parecido con otro sentido, y es la única alternativa que da información espacial de verdad.
Pero la victoria más grande y la más barata es otra: un resumen textual generado automáticamente desde los datos. No una descripción escrita a mano que se desactualiza, sino una frase construida en el mismo código que construye el gráfico.
function resumen(serie, unidad) {
const vals = serie.map(d => d.valor);
const max = serie[vals.indexOf(Math.max(...vals))];
const min = serie[vals.indexOf(Math.min(...vals))];
const delta = ((vals.at(-1) - vals[0]) / vals[0]) * 100;
const dir = delta >= 0 ? 'sube' : 'baja';
return `Serie de ${serie.length} valores en ${unidad}. ` +
`Máximo ${max.valor} en ${max.etiqueta}, mínimo ${min.valor} en ${min.etiqueta}. ` +
`Del primer al último punto ${dir} un ${Math.abs(delta).toFixed(1)} por ciento.`;
}Ese texto va en el desc o en un párrafo junto al gráfico, se genera de los mismos datos, y nunca miente porque se recalcula. Cuesta veinte líneas y convierte un gráfico mudo en uno que se explica.
La combinación completa (nombre, resumen generado, tabla navegable, teclado, doble canal de color y movimiento reducible) es lo que separa un gráfico accesible de uno que solo tiene el atributo puesto. Y ninguna herramienta automática de auditoría la exige, porque ninguna sabe leer un gráfico. Esto se hace a mano o no se hace.
Coge tu gráfico más complejo y aplícale la lista completa: nombre, resumen generado desde los datos, tabla con scope, botón con aria-expanded, navegación por flechas, doble canal de color y respeto del movimiento reducido. Después pásaselo a alguien que navegue solo con teclado y lector de pantalla, y pídele que responda tres preguntas concretas sobre los datos. Si puede responderlas, has terminado.