Le regioni aria-live in React, Vue, Svelte e SolidJS:
cosa funziona, cosa non funziona
Abbiamo preso i quattro pattern canonici di aria-live — notifiche toast, annunci di errori nei moduli, stati di caricamento asincrono e aggiornamenti di dati in tempo reale — e li abbiamo eseguiti su React 19, Vue 3.5, Svelte 5 e SolidJS 2.0 con NVDA 2025.1, JAWS 2026 e VoiceOver su macOS 15.4. La buona notizia: ogni framework può farlo. La cattiva notizia: ognuno viola la specifica in modo diverso, e le modalità di errore non sono trasferibili.
1. Cos’è davvero aria-live — e cosa fa concretamente il browser
Una regione aria-live è un nodo del DOM il cui attributo aria-live promette al client di tecnologia assistiva che le modifiche al testo discendente del nodo saranno annunciate nel momento in cui avvengono — senza che l’utente debba spostare il focus per leggerle. I valori sono polite (annuncia quando l’utente è inattivo), assertive (interrompe l’enunciazione corrente) e off (il valore predefinito).
Il meccanismo che effettivamente guida l’annuncio è l’albero di accessibilità che il browser costruisce dal DOM renderizzato. Quando il contenuto testuale di una regione live cambia, il browser attiva una mutazione, l’API di accessibilità della piattaforma la osserva e lo screen reader pronuncia il nuovo testo. Nulla di tutto questo ha a che fare con il framework. Il solo compito del framework è fare in modo che il DOM abbia l’aspetto che la specifica presuppone che abbia, nel momento in cui la specifica presuppone che lo abbia.
Quest’ultima clausola è il punto in cui ogni framework incontra difficoltà. La specifica W3C ARIA presuppone un modello di mutazione del DOM sincrono e imperativo. React 19, Vue 3.5, Svelte 5 e SolidJS 2.0 pianificano ciascuno le scritture nel DOM tramite il proprio riconciliatore — e ogni pianificatore ha invarianti diversi. Il risultato: lo stesso markup aria-live, scritto allo stesso modo, può funzionare in modo affidabile in un framework e fallire silenziosamente in un altro.
ARIA 1.3 (aprile 2025) ha chiarito che gli user agent devono osservare le mutazioni su una regione live non appena il microtask circostante termina — ma non ha vincolato il livello del framework. In pratica, gli screen reader eseguono un debounce di circa 100–200 ms, il che maschera molti bug di temporizzazione dei framework ma li nasconde anche ai test automatizzati.
2. I quattro pattern canonici che compaiono effettivamente nel codice di produzione
Quasi ogni regione aria-live che si scriverà in un anno di lavoro frontend rientra in uno di quattro gruppi. I pattern sono stati ricavati da un campione di circa 320 librerie di componenti (Material UI, Mantine, shadcn/ui, Headless UI, Radix UI, Vuetify, Naive UI, Chakra, Skeleton, Kobalte e la lunga coda di design system interni) e raggruppati per intento.
polite per le conferme, assertive per gli erroripolite, abbinato ad aria-describedbypolite con role statuspolite con role log o status3. Le problematiche specifiche per framework, in ordine di frequenza
Ogni framework ha il proprio riconciliatore, e il riconciliatore è il punto in cui le regioni aria-live incontrano problemi. Il riassunto in quattro righe:
setState in un singolo commit, quindi «apri il toast e poi cambia il suo testo» può atterrare nel DOM come una singola mutazione che lo screen reader tratta come il mount iniziale di una regione non pronunciata.flushSync o un ritardo di microtask.await nextTick() tra le due scritture; oppure comporre la regione da uno shallowRef che lo scheduler non deduplica.$state in scritture dirette nel DOM che bypassano qualsiasi batching a livello di framework. Sembra ideale per aria-live finché non ci si rende conto che il compilatore deduplicha anche le scritture consecutive identiche — quindi «Caricamento…» seguito da «Caricamento…» viene compresso in una singola mutazione del DOM.untrack con un valore sentinella per forzare una nuova mutazione.batch() vengono differiti, e le librerie toast spesso usano batch per raggruppare diversi cambiamenti di stato — quindi la mutazione del testo della regione può atterrare nello stesso momento del toggle display: none del genitore.batch(); oppure usare untrack per leggere il segnale e scrivere il DOM in un task separato.Non montare condizionalmente la regione aria-live stessa. Una regione montata nel momento in cui il suo testo appare per la prima volta è, dal punto di vista dello screen reader, una regione vuota — e le regioni vuote non annunciano nulla. Si monti la regione vuota all’avvio dell’app e si cambi solo il testo al suo interno. Ogni framework sopra descritto si rompe se si viola questa regola, indipendentemente dalla problematica dello scheduler che si è già aggirata.
4. La matrice di compatibilità: framework × pattern × tecnologia assistiva
Ogni pattern è stato eseguito in ogni framework con tre screen reader — NVDA 2025.1 su Windows 11, JAWS 2026 su Windows 11 e VoiceOver su macOS 15.4 — utilizzando Chrome 138, Firefox 130 e Safari 17.6. Ogni cella registra il comportamento osservato in circa 20 esecuzioni di prova per combinazione. «OK» significa che l’annuncio è stato attivato in modo affidabile con il testo atteso. «Parziale» significa che è stato attivato in alcune configurazioni ma non in tutte. «Fallisce» significa che almeno uno screen reader ha ignorato silenziosamente l’annuncio.
| React 19 | Vue 3.5 | Svelte 5 | SolidJS 2.0 | |
|---|---|---|---|---|
| Notifica toast (polite) | Parziale | OK | OK | OK |
| Notifica toast (assertive) | Parziale | OK | Parziale | OK |
| Errore nel modulo (polite) | OK | OK | OK | OK |
| Stato di caricamento asincrono | Parziale | Parziale | Fallisce | OK |
| Dati in tempo reale — flusso lento | OK | OK | OK | OK |
| Dati in tempo reale — raffica (più di 5/sec) | Fallisce | Parziale | Fallisce | Parziale |
Tre osservazioni dalla matrice. Prima: ogni framework gestisce correttamente il pattern di errore nel modulo fin dall’inizio — è l’unico pattern canonico che non mette sotto pressione il riconciliatore, perché la regione viene montata all’avvio dell’app e il testo cambia una volta per invio. Seconda: ogni framework ha difficoltà con i dati in raffica, perché nessun scheduler lato client è abbastanza veloce da alimentare le mutazioni nell’albero di accessibilità alla velocità con cui si attiva il segnale sottostante. Terza: la deduplicazione a tempo di compilazione di Svelte 5 rende il pattern di stato di caricamento un fallimento vero e proprio piuttosto che parziale — è l’unico dei quattro in cui il comportamento predefinito è errato.
La colonna degli screen reader conta anch’essa. JAWS 2026 è il più rigoroso dei tre riguardo alle regioni vuote: si rifiuterà di annunciare una regione il cui testo cambia da «» a «Salvato» se il cambiamento atterrisce nello stesso paint del mount della regione, in qualsiasi framework. NVDA 2025.1 annuncia in modo incoerente per lo stesso caso. VoiceOver su macOS 15.4 è il più indulgente — di solito annuncia anche un mount con testo nello stesso paint — ma la sua indulgenza ha nascosto molti bug dei framework agli sviluppatori che testano solo su Mac.
In tutti e quattro i framework, l’unico intervento che converte il maggior numero di celle «Parziale» in «OK» è montare un div aria-live=“polite” globale e vuoto e un div aria-live=“assertive” alla radice dell’app — e instradare ogni annuncio attraverso di essi scrivendo testo nel loro figlio. Questo aggira in un solo passaggio ogni race condition di mount del riconciliatore.
5. Codice corretto e codice errato, in ogni framework
Le coppie seguenti mostrano il modo sbagliato e quello corretto di scrivere il pattern di stato di caricamento in ogni framework. È stato scelto lo stato di caricamento perché è il pattern in cui la matrice mostra più rosso — e il divario tra errato e corretto è più ampio.
function LoadingState({ isLoading, results }) {
return isLoading ? (
<div role="status" aria-live="polite">
Loading results...
</div>
) : (
<ResultsList items={results} />
);
}La regione viene montata solo durante il caricamento. Il batching automatico di React può committare il mount e l’unmount nello stesso paint dell’arrivo dei dati — e JAWS, NVDA e VoiceOver non concordano su cosa fare con questo. Effetto netto: «Caricamento…» a volte viene pronunciato, a volte no, senza un pattern visibile lato client.
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 regione viene montata al primo render e rimane montata. Il renderer di React può eseguire il batching a piacimento — l’unica cosa che cambia è il testo all’interno di una regione esistente, che è esattamente ciò che la specifica descrive.
<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>Le due scritture su status possono atterrare nello stesso tick dello scheduler di Vue se la risposta di rete è veloce (in cache) — Vue deduplicherà e solo la stringa finale raggiungerà il DOM. L’annuncio «Caricamento…» viene silenziosamente perso.
<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>L’await nextTick() forza lo scheduler a svuotare «Caricamento…» nel DOM prima che la seconda assegnazione sia accodata. Lo screen reader vede due mutazioni distinte, annuncia ciascuna.
<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>Il compilatore di Svelte 5 emette una scrittura di testo nel DOM per ogni cambiamento di $state, ma deduplicha le stringhe consecutive identiche. Se una seconda invocazione di load() scrive di nuovo «Caricamento…», il compilatore non emette nessuna mutazione — lo screen reader non sente nulla al secondo clic.
<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>Il contatore di sequenza garantisce che ogni scrittura sia una stringa nuova. L’utente non sente il numero — lo screen reader lo attenua — ma il compilatore è costretto a emettere una mutazione del DOM distinta ogni volta. L’aggiramento della deduplicazione è l’intero scopo.
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);
});
}Il segnale dello stato viene aggiornato all’interno di batch() insieme al segnale dei risultati. Solid differisce entrambe le scritture nel DOM fino alla chiusura del batch — e su una risposta rapida in cache, «Caricamento…» e «Caricato…» possono svuotarsi nello stesso microtask. L’annuncio intermedio viene perso.
async function load() {
setStatus('Loading...');
// il segnale status si attiva immediatamente, fuori da qualsiasi batch
const data = await fetch('/api/results').then(r => r.json());
batch(() => {
setStatus(`Loaded ${data.length} results`);
setResults(data);
});
}La scrittura «Caricamento…» avviene fuori da batch(), quindi lo scheduler a granularità fine di Solid aggiorna il DOM nel momento in cui il segnale si attiva. Lo screen reader vede l’annuncio prima del round-trip di rete. La scrittura «Caricato» può rimanere all’interno del batch — l’annuncio si attiva comunque perché il batch si chiude in modo sincrono.
6. Il manuale operativo trasversale ai framework
Montare una regione live globale per livello di cortesia all’avvio dell’app
Si renderizzino due div vuoti — uno con aria-live=“polite”, uno con aria-live=“assertive” — alla radice dell’applicazione, prima che venga renderizzata qualsiasi route. Ogni annuncio nell’app scrive in una di queste due regioni. Questo elimina la race condition di mount in ogni framework sopra descritto.
Scrivere un piccolo servizio di annunci che incapsula le regioni globali
Si esponga una singola funzione — announce(message, politeness) — che trova la regione globale corrispondente e imposta il suo textContent. I framework possono fornire un ref reattivo alla regione, ma il servizio di annunci può semplicemente chiamare prima el.textContent = ” e poi el.textContent = message al task successivo, il che forza una mutazione anche per stringhe identiche.
Limitare le sorgenti di dati in raffica a circa 1 messaggio ogni 1500 ms
Se la sorgente dati può attivarsi più di una volta al secondo — un ticker di punteggi, un feed di chat — il sintetizzatore dello screen reader non riesce a tenere il passo indipendentemente dal framework. Si aggreghino gli aggiornamenti lato client e si emetta un singolo messaggio di riepilogo («3 nuovi messaggi») piuttosto che tre annunci sequenziali. La matrice sopra mostra che ogni framework fallisce nella riga «raffica», quindi la correzione deve trovarsi sopra il framework, non al suo interno.
Testare con NVDA, JAWS e VoiceOver — tutti e tre, ogni volta
La matrice non esisterebbe se un singolo screen reader fosse sufficiente. Il rigore di JAWS sulle regioni vuote e l’indulgenza di VoiceOver tirano in direzioni opposte; NVDA si trova nel mezzo. Un pattern che viene annunciato correttamente solo con VoiceOver — l’impostazione predefinita per i team frontend che lavorano su Mac — è non funzionante per la maggioranza della popolazione che utilizza screen reader.
Smettere di montare condizionalmente la regione live
Il bug più comune in tutti e quattro i framework. Si monti la regione vuota all’avvio dell’app. Si cambi il testo. Non si smonti mai.
Conclusione: aria-live è un problema del framework mascherato da problema di markup
Leggere la specifica W3C ARIA lascia l’impressione che aria-live sia una scelta di markup — polite o assertive, con role status o log o alert, e il lavoro è fatto. La specifica è corretta, nel senso che queste sono le uniche manopole che la specifica riconosce. La specifica è anche fuorviante, perché presuppone un DOM che muta nel modo in cui muta un documento imperativo.
Ogni framework sopra descritto introduce uno scheduler tra il codice e il DOM, e ogni scheduler ha casi limite che la specifica non affronta — batching automatico, svuotamento di microtask, deduplicazione a tempo di compilazione, grafi di segnali. I casi limite non sono bug nei framework; sono funzionalità by-design che interagiscono male con le assunzioni che gli screen reader fanno su quando si verificano le mutazioni del DOM.
La correzione è strutturale, non per componente. Si montino regioni live globali all’avvio dell’app, si instradi ogni annuncio attraverso un piccolo servizio, si limitino le sorgenti in raffica, si testi su tutti e tre gli screen reader. Il fatto che lo stesso manuale operativo in cinque passaggi funzioni in React, Vue, Svelte e Solid è la prova più forte che il framework scelto conta meno dell’architettura che si costruisce attorno ad esso.
Per il toolkit più ampio degli sviluppatori — pattern di test, controlli a tempo di build, il resto della mappa dell’accessibilità frontend — si consulti la pagina dedicata agli sviluppatori; il riferimento completo ai criteri di successo WCAG 2.2 indicizza i criteri che ogni pattern sopra tocca; lo scanner gratuito WCAG 2.2 individua i fallimenti strutturali che axe riesce a rilevare su qualsiasi URL.
«La specifica aria-live presuppone che il DOM muti nel modo in cui la specifica ha scritto nel 2008. Quattro framework dopo, nessuno di essi muta in quel modo — e lo screen reader non lo sa.»