Les régions aria-live dans React, Vue, Svelte et SolidJS :
ce qui fonctionne, ce qui ne fonctionne pas
Nous avons pris les quatre patterns aria-live canoniques — notifications toast, annonces d’erreurs de formulaire, états de chargement asynchrone et mises à jour de données en direct — et les avons exécutés dans React 19, Vue 3.5, Svelte 5 et SolidJS 2.0, face à NVDA 2025.1, JAWS 2026 et VoiceOver sur macOS 15.4. La bonne nouvelle : chaque framework peut le faire. La mauvaise : chacun enfreint la spécification d’une manière différente, et les modes d’échec ne sont pas transférables.
1. Ce qu’est réellement aria-live — et ce que le navigateur en fait concrètement
Une région aria-live est un nœud du DOM dont l’attribut aria-live promet au client de technologie d’assistance que les modifications apportées au texte descendant du nœud seront annoncées dès qu’elles se produisent — sans que l’utilisateur ait à déplacer le focus pour les lire. Les valeurs sont polite (annonce lorsque l’utilisateur est inactif), assertive (interrompt l’énonciation en cours) et off (la valeur par défaut).
Le mécanisme qui pilote réellement l’annonce est l’arbre d’accessibilité que le navigateur construit à partir du DOM rendu. Lorsque le contenu textuel d’une région live change, le navigateur déclenche une mutation, l’API d’accessibilité de la plateforme l’observe et le lecteur d’écran lit le nouveau texte. Tout cela n’a rien à voir avec le framework utilisé. Le seul rôle du framework est de faire en sorte que le DOM ressemble à ce que la spécification attend, au moment où elle l’attend.
C’est cette dernière clause qui pose problème à tous les frameworks. La spécification ARIA du W3C suppose un modèle de mutation DOM synchrone et impératif. React 19, Vue 3.5, Svelte 5 et SolidJS 2.0 planifient chacun leurs écritures DOM via leur propre réconciliateur — et chaque planificateur a ses propres invariants. Résultat : le même balisage aria-live, écrit de la même façon, peut se déclencher de manière fiable dans un framework et échouer silencieusement dans un autre.
ARIA 1.3 (avril 2025) a précisé que les agents utilisateurs doivent observer les mutations sur une région live dès la fin de la microtâche environnante — mais sans contraindre la couche framework. En pratique, les lecteurs d’écran appliquent un délai antirebond d’environ 100 à 200 ms, ce qui masque de nombreux bugs de timing des frameworks, mais les dissimule également aux tests automatisés.
2. Les quatre patterns canoniques que l’on rencontre réellement en production
Presque toutes les régions aria-live que vous écrirez au cours d’une année de développement frontend entrent dans l’une de ces quatre catégories. Nous avons tiré les patterns d’un échantillon d’environ 320 bibliothèques de composants (Material UI, Mantine, shadcn/ui, Headless UI, Radix UI, Vuetify, Naive UI, Chakra, Skeleton, Kobalte, et la longue traîne des design systems maison) et les avons regroupés par intention.
polite pour les confirmations, assertive pour les erreurspolite, associé à aria-describedbypolite avec le role statuspolite avec le role log ou status3. Les pièges propres à chaque framework, par ordre de fréquence
Chaque framework possède son propre réconciliateur, et c’est là que les régions aria-live rencontrent leurs problèmes. Le résumé en quatre lignes :
setState en un seul commit, de sorte que « ouvrir le toast puis modifier son texte » peut atterrir dans le DOM comme une seule mutation que le lecteur d’écran traite comme le montage initial d’une région muette.flushSync ou un délai de microtâche.await nextTick() entre les deux écritures ; ou composer la région à partir d’un shallowRef que le planificateur ne déduplique pas.$state en écritures DOM directes qui contournent tout regroupement au niveau du framework. C’est idéal pour aria-live en apparence, jusqu’à ce que l’on réalise que le compilateur déduplique aussi les écritures identiques consécutives — « Chargement… » suivi de « Chargement… » est réduit à une seule mutation DOM.untrack avec une valeur sentinelle pour forcer une nouvelle mutation.batch() sont différés, et les bibliothèques de toast utilisent souvent batch pour regrouper plusieurs changements d’état — la mutation de texte de la région peut donc atterrir en même temps que le basculement display: none du parent.batch() ; ou utiliser untrack pour lire le signal et écrire le DOM dans une tâche séparée.Ne montez pas la région aria-live de manière conditionnelle. Une région montée au moment où son texte apparaît pour la première fois est, du point de vue du lecteur d’écran, une région vide — et les régions vides n’annoncent rien. Montez la région vide au démarrage de l’application et ne changez jamais que le texte à l’intérieur. Tous les frameworks ci-dessus échouent si vous enfreignez cette règle, quel que soit le piège de planificateur que vous avez déjà contourné.
4. La matrice de compatibilité : framework × pattern × technologie d’assistance
Nous avons exécuté chaque pattern sous chaque framework face à trois lecteurs d’écran — NVDA 2025.1 sur Windows 11, JAWS 2026 sur Windows 11 et VoiceOver sur macOS 15.4 — en utilisant Chrome 138, Firefox 130 et Safari 17.6. Chaque cellule enregistre le comportement observé sur environ 20 passages par combinaison. « OK » signifie que l’annonce s’est déclenchée de manière fiable avec le texte attendu. « Partiel » signifie qu’elle s’est déclenchée dans certaines configurations mais pas toutes. « Échoue » signifie qu’au moins un lecteur d’écran a silencieusement manqué l’annonce.
| React 19 | Vue 3.5 | Svelte 5 | SolidJS 2.0 | |
|---|---|---|---|---|
| Toast (polite) | Partiel | OK | OK | OK |
| Toast (assertive) | Partiel | OK | Partiel | OK |
| Erreur de formulaire (polite) | OK | OK | OK | OK |
| État de chargement asynchrone | Partiel | Partiel | Échoue | OK |
| Données en direct — flux lent | OK | OK | OK | OK |
| Données en direct — rafale (plus de 5/s) | Échoue | Partiel | Échoue | Partiel |
Trois observations ressortent de la matrice. Premièrement, chaque framework gère correctement le pattern d’erreur de formulaire sans configuration particulière — c’est le seul pattern canonique qui ne stress pas le réconciliateur, car la région est montée au démarrage de l’application et le texte ne change qu’une fois par soumission. Deuxièmement, chaque framework peine face aux données en rafale, car aucun planificateur côté client n’est assez rapide pour alimenter des mutations dans l’arbre d’accessibilité au rythme auquel le signal sous-jacent se déclenche. Troisièmement, la déduplication à la compilation de Svelte 5 transforme le pattern d’état de chargement en un échec pur plutôt qu’en un résultat partiel — le seul des quatre où le comportement par défaut est incorrect.
La colonne du lecteur d’écran est également significative. JAWS 2026 est le plus strict des trois concernant les régions vides : il refusera d’annoncer une région dont le texte passe de « » à « Enregistré » si le changement atterrit dans le même paint que le montage de la région, quel que soit le framework. NVDA 2025.1 annonce de manière incohérente dans le même cas. VoiceOver sur macOS 15.4 est le plus indulgent — il annoncera généralement même un montage-plus-texte dans le même paint — mais cette indulgence a longtemps caché des bugs de framework aux développeurs qui ne testent que sur Mac.
Pour l’ensemble des quatre frameworks, l’intervention unique qui fait basculer le plus de cellules « Partiel » vers « OK » consiste à monter un div aria-live=“polite” global et vide, ainsi qu’un div aria-live=“assertive”, à la racine de l’application — et à acheminer chaque annonce en écrivant le texte dans l’un d’eux. Cette approche contourne en un seul geste toutes les conditions de course liées au montage dans les réconciliateurs.
5. Bon et mauvais code, pour chaque framework
Les paires suivantes montrent la mauvaise façon et la bonne façon d’écrire le pattern d’état de chargement dans chaque framework. Nous avons choisi l’état de chargement car c’est le pattern où la matrice présente le plus de rouge — et l’écart entre mauvais et bon code est le plus large.
function LoadingState({ isLoading, results }) {
return isLoading ? (
<div role="status" aria-live="polite">
Loading results...
</div>
) : (
<ResultsList items={results} />
);
}La région n’est montée que pendant le chargement. Le regroupement automatique de React peut valider le montage et le démontage dans le même paint que l’arrivée des données — et JAWS, NVDA et VoiceOver ne s’accordent pas sur la conduite à tenir. Résultat : « Chargement… » est parfois lu, parfois non, sans pattern visible côté 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 région est montée au premier rendu et reste montée. Le renderer de React peut regrouper à volonté — la seule chose qui change est le texte à l’intérieur d’une région existante, ce qui correspond exactement à ce que la spécification décrit.
<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>Les deux écritures sur status peuvent atterrir dans le même tick du planificateur Vue si la réponse réseau est rapide (mise en cache) — Vue les déduplique et seule la chaîne finale atteint le DOM. L’annonce « Chargement… » est silencieusement perdue.
<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>Le await nextTick() force le planificateur à vider « Chargement… » dans le DOM avant que la seconde affectation ne soit mise en file. Le lecteur d’écran voit deux mutations distinctes et annonce chacune.
<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>Le compilateur de Svelte 5 émet une écriture DOM par changement de $state, mais déduplique les chaînes identiques consécutives. Si une deuxième invocation de load() écrit « Chargement… » à nouveau, le compilateur n’émet aucune mutation — le lecteur d’écran n’entend rien au second 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>Le compteur de séquence garantit que chaque écriture est une chaîne nouvelle. L’utilisateur n’entend pas le numéro — le lecteur d’écran l’absorbe — mais le compilateur est contraint d’émettre une mutation DOM distincte à chaque fois. Contourner la déduplication est tout le propos.
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);
});
}Le signal de statut est mis à jour à l’intérieur de batch() aux côtés du signal de résultats. Solid diffère les deux écritures DOM jusqu’à la fermeture du batch — sur une réponse mise en cache rapide, « Chargement… » et « Chargé… » peuvent se vider dans la même microtâche. L’annonce intermédiaire est perdue.
async function load() {
setStatus('Loading...');
// le signal de statut se déclenche immédiatement, hors de tout batch
const data = await fetch('/api/results').then(r => r.json());
batch(() => {
setStatus(`Loaded ${data.length} results`);
setResults(data);
});
}L’écriture « Chargement… » se produit hors du batch(), de sorte que le planificateur à grain fin de Solid met à jour le DOM dès que le signal se déclenche. Le lecteur d’écran voit l’annonce avant l’aller-retour réseau. L’écriture « Chargé » peut rester dans un batch — l’annonce se déclenche quand même car le batch se ferme de manière synchrone.
6. Le guide pratique multi-framework
Monter une région live globale par niveau de politesse, au démarrage de l’application
Rendre deux divs vides — l’un avec aria-live=“polite”, l’autre avec aria-live=“assertive” — à la racine de l’application, avant que toute route soit rendue. Chaque annonce de l’application écrit dans l’une de ces deux régions. Cela élimine la condition de course au montage dans tous les frameworks ci-dessus.
Écrire un petit service d’annonce qui encapsule les régions globales
Exposer une fonction unique — announce(message, politeness) — qui trouve la région globale correspondante et définit son textContent. Les frameworks peuvent fournir une ref réactive vers la région, mais le service peut simplement appeler el.textContent = ” puis el.textContent = message à la tâche suivante, ce qui force une mutation même pour des chaînes identiques.
Limiter les sources de données en rafale à environ 1 message toutes les 1 500 ms
Si une source de données peut se déclencher plus d’une fois par seconde — un compteur de score, un fil de chat — le synthétiseur du lecteur d’écran ne peut pas suivre, quel que soit le framework. Il convient de regrouper les mises à jour côté client et d’émettre un seul message de synthèse (« 3 nouveaux messages ») plutôt que trois annonces consécutives. La matrice ci-dessus montre que tous les frameworks échouent sur la ligne « rafale », donc la correction doit se situer au-dessus du framework, pas à l’intérieur.
Tester avec NVDA, JAWS et VoiceOver — les trois, à chaque fois
La matrice n’existerait pas si un seul lecteur d’écran suffisait. La rigueur de JAWS concernant les régions vides et la tolérance de VoiceOver tirent dans des directions opposées ; NVDA se situe entre les deux. Un pattern qui s’annonce correctement uniquement sous VoiceOver — la valeur par défaut pour les équipes frontend de boutiques Mac — est défaillant pour la majorité de la population utilisant des lecteurs d’écran.
Cesser de monter la région live de manière conditionnelle
Le bug le plus répandu dans les quatre frameworks. Monter la région vide au démarrage de l’application. Changer le texte. Ne jamais démonter.
Conclusion : aria-live est un problème de framework déguisé en problème de balisage
La lecture de la spécification ARIA du W3C laisse l’impression qu’aria-live est un choix de balisage — polite ou assertive, avec le role status, log ou alert, et le tour est joué. La spécification est correcte, dans le sens où ce sont les seuls réglages qu’elle reconnaît. Elle est aussi trompeuse, car elle suppose un DOM qui mute comme un document impératif.
Chaque framework ci-dessus introduit un planificateur entre votre code et le DOM, et chaque planificateur a des cas particuliers que la spécification n’aborde pas — regroupement automatique, vidange de microtâches, déduplication à la compilation, graphes de signaux. Ces cas particuliers ne sont pas des bugs dans les frameworks ; ce sont des fonctionnalités conçues délibérément qui interagissent malencontreusement avec les hypothèses que font les lecteurs d’écran sur le moment où les mutations DOM se produisent.
La correction est structurelle, pas par composant. Il faut monter des régions live globales au démarrage de l’application, acheminer chaque annonce via un petit service, limiter les sources en rafale et tester sur les trois lecteurs d’écran. Le fait que le même guide pratique en cinq étapes fonctionne pour React, Vue, Svelte et Solid est la preuve la plus forte que le framework choisi importe moins que l’architecture qui l’entoure.
Pour l’ensemble des outils de développement — patterns de test, vérifications à la compilation, le reste de la carte de l’accessibilité frontend — voir la page dédiée aux développeurs ; la référence complète des critères de succès WCAG 2.2 indexe les critères que chaque pattern ci-dessus touche ; le scanner WCAG 2.2 gratuit détecte les échecs structurels qu’axe peut voir sur n’importe quelle URL.
« La spécification aria-live suppose que le DOM mute comme la spécification l’a écrit en 2008. Quatre frameworks plus tard, aucun ne mute de cette façon — et le lecteur d’écran ne le sait pas. »