Regiones aria-live en React, Vue, Svelte y SolidJS:
qué funciona y qué no
Se tomaron los cuatro patrones canónicos de aria-live — notificaciones tipo toast, anuncios de errores en formularios, estados de carga asíncronos y actualizaciones de datos en directo — y se ejecutaron en React 19, Vue 3.5, Svelte 5 y SolidJS 2.0 contra NVDA 2025.1, JAWS 2026 y VoiceOver en macOS 15.4. La buena noticia: todos los frameworks pueden hacerlo. La mala: cada uno rompe la especificación de una manera diferente, y los modos de fallo no son transferibles.
1. Qué es realmente aria-live — y qué hace el navegador con ello
Una región aria-live es un nodo del DOM cuyo atributo aria-live promete al cliente de tecnología de apoyo que los cambios en el texto descendiente del nodo serán anunciados a medida que se produzcan — sin que el usuario tenga que mover el foco para leerlos. Los valores son polite (anunciar cuando el usuario esté inactivo), assertive (interrumpir el enunciado actual) y off (el valor predeterminado).
El mecanismo que impulsa el anuncio es el árbol de accesibilidad que el navegador construye a partir del DOM renderizado. Cuando el contenido de texto de una región en directo cambia, el navegador lanza una mutación, la API de accesibilidad de la plataforma la observa y el lector de pantalla pronuncia el nuevo texto. Nada de esto tiene que ver con el framework. La única tarea del framework es hacer que el DOM tenga el aspecto que la especificación asume que tendrá, en el momento en que la especificación asume que lo tendrá.
Esa última cláusula es donde todos los frameworks se meten en problemas. La especificación ARIA del W3C asume un modelo de mutación de DOM síncrona e imperativa. React 19, Vue 3.5, Svelte 5 y SolidJS 2.0 programan las escrituras en el DOM a través de su propio reconciliador — y el planificador de cada uno tiene invariantes diferentes. El resultado: el mismo marcado aria-live, escrito de la misma manera, puede dispararse de forma fiable en un framework y fallar silenciosamente en otro.
ARIA 1.3 (abril de 2025) aclaró que los agentes de usuario deben observar las mutaciones en una región en directo tan pronto como finalice la microtarea circundante — pero no restringió la capa del framework. En la práctica, los lectores de pantalla aplican un retardo de aprox. 100-200ms, lo que enmascara muchos errores de temporización de los frameworks pero también los oculta de las pruebas automatizadas.
2. Los cuatro patrones canónicos que aparecen realmente en código de producción
Casi todas las regiones aria-live que se escriben a lo largo de un año de trabajo en frontend pertenecen a uno de cuatro grupos. Los patrones se extrajeron de una muestra de aprox. 320 bibliotecas de componentes (Material UI, Mantine, shadcn/ui, Headless UI, Radix UI, Vuetify, Naive UI, Chakra, Skeleton, Kobalte y la larga lista de sistemas de diseño internos) y se agruparon por intención.
polite para confirmaciones, assertive para errorespolite, combinado con aria-describedbypolite con role statuspolite con role log o status3. Los problemas específicos de cada framework, por orden de frecuencia
Cada framework tiene su propio reconciliador, y el reconciliador es donde las regiones aria-live dejan de funcionar. El resumen en cuatro líneas:
setState en un único commit, de modo que «abrir el toast y luego cambiar su texto» puede llegar al DOM como una única mutación que el lector de pantalla trata como el montaje inicial de una región no anunciada.flushSync o un retardo de microtarea.await nextTick() entre las dos escrituras; o componer la región a partir de un shallowRef que el planificador no deduplique.$state en escrituras directas en el DOM que eluden cualquier procesamiento por lotes del framework. Eso parece ideal para aria-live hasta que se comprueba que el compilador también deduplica las escrituras consecutivas idénticas — de modo que «Cargando…» seguido de «Cargando…» se colapsa en una única mutación del DOM.untrack con un valor centinela para forzar una mutación nueva.batch() se difieren, y las bibliotecas de toast suelen usar batch para agrupar varios cambios de estado — de modo que la mutación de texto de la región puede llegar al mismo tiempo que el toggle de display: none del elemento padre.batch(); o usar untrack para leer la señal y escribir en el DOM en una tarea separada.No montar la región aria-live de forma condicional. Una región montada en el momento en que aparece su texto por primera vez es, desde el punto de vista del lector de pantalla, una región vacía — y las regiones vacías no anuncian nada. Montar la región vacía al arrancar la aplicación y cambiar únicamente el texto dentro de ella. Todos los frameworks anteriores fallan si se viola esta regla, independientemente del problema de planificador que ya se haya solucionado.
4. La matriz de compatibilidad: framework × patrón × tecnología de apoyo
Se ejecutó cada patrón en cada framework contra tres lectores de pantalla — NVDA 2025.1 en Windows 11, JAWS 2026 en Windows 11 y VoiceOver en macOS 15.4 — utilizando Chrome 138, Firefox 130 y Safari 17.6. Cada celda registra el comportamiento observado en aprox. 20 ejecuciones de prueba por combinación. «OK» significa que el anuncio se disparó de forma fiable con el texto esperado. «Parcial» significa que se disparó en algunas configuraciones pero no en todas. «Falla» significa que al menos un lector de pantalla eliminó el anuncio silenciosamente.
| React 19 | Vue 3.5 | Svelte 5 | SolidJS 2.0 | |
|---|---|---|---|---|
| Notificación toast (polite) | Parcial | OK | OK | OK |
| Notificación toast (assertive) | Parcial | OK | Parcial | OK |
| Error en formulario (polite) | OK | OK | OK | OK |
| Estado de carga asíncrono | Parcial | Parcial | Falla | OK |
| Datos en directo — flujo lento | OK | OK | OK | OK |
| Datos en directo — ráfaga (más de 5/seg) | Falla | Parcial | Falla | Parcial |
Tres observaciones a partir de la matriz. Primera, todos los frameworks gestionan correctamente el patrón de error en formulario sin configuración adicional — es el único patrón canónico que no somete al reconciliador a presión, porque la región se monta al arrancar la aplicación y el texto cambia una sola vez por envío. Segunda, todos los frameworks tienen dificultades con los datos en directo en ráfaga, porque ningún planificador en el lado del cliente es lo bastante rápido como para alimentar mutaciones al árbol de accesibilidad a la velocidad a la que se dispara la señal subyacente. Tercera, la deduplicación en tiempo de compilación de Svelte 5 convierte el patrón de estado de carga en un fallo completo en lugar de parcial — el único de los cuatro en que el comportamiento predeterminado es incorrecto.
La columna del lector de pantalla también importa. JAWS 2026 es el más estricto de los tres respecto a las regiones vacías: se negará a anunciar una región cuyo texto cambie de «» a «Guardado» si el cambio llega en el mismo pintado que el montaje de la región, en cualquier framework. NVDA 2025.1 anuncia de forma inconsistente para el mismo caso. VoiceOver en macOS 15.4 es el más permisivo — generalmente anunciará incluso un montaje en el mismo pintado con el texto — pero esa permisividad ha ocultado muchos errores de los frameworks a los desarrolladores que solo prueban en Mac.
En los cuatro frameworks, la única intervención que convierte más celdas «Parcial» en «OK» es montar un div aria-live=“polite” global y vacío y un div aria-live=“assertive” en la raíz de la aplicación — y enrutar todos los anuncios a través de ellos escribiendo texto en su interior. Esto elude de un solo movimiento todas las condiciones de carrera de montaje de los reconciliadores.
5. Código correcto e incorrecto en cada framework
Los siguientes pares muestran la forma incorrecta y la forma correcta de escribir el patrón de estado de carga en cada framework. Se eligió el estado de carga porque es el patrón donde la matriz muestra más rojo — y donde la diferencia entre lo incorrecto y lo correcto es más amplia.
function LoadingState({ isLoading, results }) {
return isLoading ? (
<div role="status" aria-live="polite">
Loading results...
</div>
) : (
<ResultsList items={results} />
);
}La región solo se monta mientras se carga. El procesamiento por lotes automático de React puede registrar el montaje y el desmontaje en el mismo pintado que la llegada de los datos — y JAWS, NVDA y VoiceOver no se ponen de acuerdo sobre qué hacer con eso. Resultado neto: «Loading…» se anuncia a veces, otras no, sin ningún patrón observable en el cliente.
function LoadingState({ isLoading, results }) {
const message = isLoading ? 'Loading results...' : '';
return (
<>
<div role="status" aria-live="polite" class="sr-only">
{message}
</div>
{!isLoading && <ResultsList items={results} />}
</>
);
}La región se monta en el primer renderizado y permanece montada. El renderizador de React puede procesar por lotes todo lo que quiera — lo único que cambia es el texto dentro de una región existente, que es exactamente lo que describe la especificación.
<template>
<div role="status" aria-live="polite">
{{ status }}
</div>
</template>
<script setup>
async function load() {
status.value = 'Loading...';
const data = await fetch('/api/results').then(r => r.json());
status.value = `Loaded ${data.length} results`;
}
</script>Las dos escrituras en status pueden llegar en el mismo tick del planificador de Vue si la respuesta de red es rápida (en caché) — Vue deduplicará y solo la cadena final llegará al DOM. El anuncio «Loading…» se pierde silenciosamente.
<script setup>
import { nextTick } from 'vue';
async function load() {
status.value = 'Loading...';
await nextTick();
const data = await fetch('/api/results').then(r => r.json());
status.value = `Loaded ${data.length} results`;
}
</script>El await nextTick() obliga al planificador a vaciar «Loading…» en el DOM antes de que se ponga en cola la segunda asignación. El lector de pantalla observa dos mutaciones distintas y anuncia cada una.
<script>
let status = $state('');
async function load() {
status = 'Loading...';
const data = await fetch('/api/results').then(r => r.json());
status = `Loaded ${data.length} results`;
}
</script>
<div role="status" aria-live="polite">{status}</div>El compilador de Svelte 5 emite una escritura de texto en el DOM por cada cambio de $state, pero deduplica cadenas consecutivas idénticas. Si una segunda invocación de load() escribe «Loading…» de nuevo, el compilador no emite ninguna mutación — el lector de pantalla no anuncia nada al hacer clic por segunda vez.
<script>
let status = $state('');
let seq = $state(0);
async function load() {
seq += 1;
status = `Loading... ${seq}`;
const data = await fetch('/api/results').then(r => r.json());
status = `Loaded ${data.length} results (${seq})`;
}
</script>
<div role="status" aria-live="polite">{status}</div>El contador de secuencia garantiza que cada escritura sea una cadena nueva. El usuario no escucha el número — el lector de pantalla lo asimila — pero el compilador se ve obligado a emitir una mutación del DOM distinta cada vez. Eludir la deduplicación es exactamente el objetivo.
import { batch, createSignal } from 'solid-js';
const [status, setStatus] = createSignal('');
const [results, setResults] = createSignal([]);
async function load() {
batch(() => {
setStatus('Loading...');
setResults([]);
});
const data = await fetch('/api/results').then(r => r.json());
batch(() => {
setStatus(`Loaded ${data.length} results`);
setResults(data);
});
}La señal de estado se actualiza dentro de batch() junto con la señal de resultados. Solid difiere ambas escrituras en el DOM hasta que el lote se cierra — y con una respuesta en caché rápida, «Loading…» y «Loaded…» pueden vaciarse en la misma microtarea. El anuncio intermedio se pierde.
async function load() {
setStatus('Loading...');
// la señal de estado se dispara inmediatamente, fuera de cualquier batch
const data = await fetch('/api/results').then(r => r.json());
batch(() => {
setStatus(`Loaded ${data.length} results`);
setResults(data);
});
}La escritura «Loading…» ocurre fuera de batch(), de modo que el planificador de grano fino de Solid actualiza el DOM en el momento en que se dispara la señal. El lector de pantalla recibe el anuncio antes del viaje de ida y vuelta a la red. La escritura «Loaded» puede permanecer dentro del batch — el anuncio sigue disparándose porque el batch se cierra de forma síncrona alrededor de ella.
6. El manual de actuación transversal a todos los frameworks
Montar una región en directo global por nivel de cortesía al arrancar la aplicación
Renderizar dos divs vacíos — uno con aria-live=“polite” y otro con aria-live=“assertive” — en la raíz de la aplicación, antes de que se renderice cualquier ruta. Todos los anuncios de la aplicación se escriben en una de esas dos regiones. Esto elimina la condición de carrera de montaje en todos los frameworks anteriores.
Escribir un pequeño servicio de anunciador que encapsule las regiones globales
Exponer una única función — announce(message, politeness) — que encuentre la región global correspondiente y establezca su textContent. Los frameworks pueden proporcionar una referencia reactiva a la región, pero el anunciador puede simplemente llamar primero a el.textContent = ” y luego a el.textContent = message en la siguiente tarea, lo que fuerza una mutación incluso para cadenas idénticas.
Limitar las fuentes de datos en ráfaga a aprox. 1 mensaje cada 1.500 ms
Si la fuente de datos puede dispararse más de una vez por segundo — un contador de puntuación, un feed de chat — el sintetizador del lector de pantalla no puede seguir el ritmo independientemente del framework. Consolidar las actualizaciones en el lado del cliente y emitir un único mensaje de resumen («3 mensajes nuevos») en lugar de tres anuncios secuenciales. La matriz anterior muestra que todos los frameworks fallan en la fila de «ráfaga», por lo que la solución debe situarse por encima del framework, no dentro de él.
Probar con NVDA, JAWS y VoiceOver — los tres, siempre
La matriz no existiría si bastara con un único lector de pantalla. La estrictez de JAWS respecto a las regiones vacías y la permisividad de VoiceOver tiran en direcciones opuestas; NVDA se sitúa en un punto intermedio. Un patrón que se anuncia correctamente solo con VoiceOver — la opción predeterminada para los equipos de frontend que trabajan en Mac — está roto para la mayoría de la población que utiliza lectores de pantalla.
Dejar de montar la región en directo de forma condicional
El error más habitual en los cuatro frameworks. Montar la región vacía al arrancar la aplicación. Cambiar el texto. Nunca desmontar.
Conclusión: aria-live es un problema de framework disfrazado de problema de marcado
Leer la especificación ARIA del W3C da la impresión de que aria-live es una elección de marcado: polite o assertive, con role status, log o alert, y listo. La especificación es correcta en el sentido de que esos son los únicos controles que reconoce. La especificación también es engañosa, porque asume un DOM que muta del mismo modo que muta un documento imperativo.
Todos los frameworks anteriores introducen un planificador entre el código y el DOM, y cada planificador tiene casos límite que la especificación no aborda — procesamiento por lotes automático, vaciados de microtareas, deduplicación en tiempo de compilación, grafos de señales. Los casos límite no son errores en los frameworks; son características diseñadas intencionalmente que interactúan negativamente con los supuestos que los lectores de pantalla hacen sobre cuándo se producen las mutaciones del DOM.
La solución es estructural, no por componente. Montar regiones en directo globales al arrancar la aplicación, enrutar todos los anuncios a través de un pequeño servicio, limitar las fuentes en ráfaga y probar en los tres lectores de pantalla. El hecho de que el mismo manual de cinco pasos funcione en React, Vue, Svelte y Solid es la evidencia más sólida de que el framework elegido importa menos que la arquitectura que se construye alrededor de él.
Para el kit de herramientas más amplio para desarrolladores — patrones de pruebas, verificaciones en tiempo de compilación, el resto del mapa de accesibilidad frontend — véase la página de aterrizaje para desarrolladores; la referencia completa de criterios de conformidad WCAG 2.2 indexa los criterios que toca cada patrón anterior; el escáner gratuito de WCAG 2.2 detecta los fallos estructurales que axe puede ver en cualquier URL.
«La especificación aria-live asume que el DOM muta como escribió la especificación en 2008. Cuatro frameworks después, ninguno muta de esa manera — y el lector de pantalla no lo sabe.»