Regiony aria-live w React, Vue, Svelte i SolidJS:
co działa, co nie
Wzięliśmy cztery kanoniczne wzorce aria-live — powiadomienia toast, ogłoszenia błędów formularza, asynchroniczne stany ładowania oraz aktualizacje danych na żywo — i uruchomiliśmy je w React 19, Vue 3.5, Svelte 5 i SolidJS 2.0, testując z NVDA 2025.1, JAWS 2026 i VoiceOver na macOS 15.4. Dobra wiadomość: każdy framework to potrafi. Zła wiadomość: każdy łamie specyfikację w inny sposób, a tryby awarii nie są wymienne.
1. Czym właściwie jest aria-live — i co przeglądarka z tym robi
Region aria-live to węzeł DOM, którego atrybut aria-live informuje klienta technologii wspomagającej, że zmiany tekstu potomnych węzłów będą ogłaszane na bieżąco — bez konieczności przenoszenia fokusa przez użytkownika. Dostępne wartości to polite (ogłoszenie gdy użytkownik jest bezczynny), assertive (przerwanie bieżącej wypowiedzi) i off (wartość domyślna).
Mechanizmem napędzającym ogłoszenie jest drzewo dostępności budowane przez przeglądarkę na podstawie wyrenderowanego DOM. Gdy tekst w regionie live ulega zmianie, przeglądarka wywołuje mutację, platforma API dostępności ją obserwuje, a czytnik ekranu odczytuje nowy tekst. Nie ma to żadnego związku z frameworkiem. Jedynym zadaniem frameworka jest sprawienie, żeby DOM wyglądał tak, jak zakłada specyfikacja, w momencie, w którym specyfikacja tego wymaga.
To właśnie w tym ostatnim zdaniu każdy framework wpada w kłopoty. Specyfikacja W3C ARIA zakłada synchroniczny, imperatywny model mutacji DOM. React 19, Vue 3.5, Svelte 5 i SolidJS 2.0 planują zapisy do DOM przez własne mechanizmy rekoncyliacji — i każdy z tych mechanizmów ma inne niezmienniki. W efekcie: te same znaczniki aria-live, napisane w identyczny sposób, mogą działać niezawodnie w jednym frameworku i po cichu zawodzić w innym.
ARIA 1.3 (kwiecień 2025) doprecyzowała, że user agenci muszą obserwować mutacje w regionie live natychmiast po zakończeniu otaczającego mikrozadania — nie ogranicza to jednak warstwy frameworka. W praktyce czytniki ekranu stosują debouncing w przedziale ok. 100–200 ms, co maskuje wiele błędów wynikających z harmonogramowania w frameworkach, ale jednocześnie je ukrywa przed testami automatycznymi.
2. Cztery kanoniczne wzorce, które faktycznie pojawiają się w kodzie produkcyjnym
Niemal każdy region aria-live napisany w ciągu roku pracy frontendowej mieści się w jednym z czterech przypadków. Wzorce zostały zebrane z próby ok. 320 bibliotek komponentów (Material UI, Mantine, shadcn/ui, Headless UI, Radix UI, Vuetify, Naive UI, Chakra, Skeleton, Kobalte oraz długi ogon firmowych systemów projektowych) i pogrupowane według intencji.
polite dla potwierdzeń, assertive dla błędówpolite w połączeniu z aria-describedbypolite z rolą statuspolite z rolą log lub status3. Pułapki specyficzne dla frameworków, według częstości występowania
Każdy framework ma własny mechanizm rekoncyliacji, a to właśnie tam regiony aria-live trafiają na swój koniec. Cztery zdania podsumowania:
setState w jeden commit, więc „otwórz toast, następnie zmień jego tekst“ może trafić do DOM jako pojedyncza mutacja, którą czytnik ekranu traktuje jako pierwsze zamontowanie niemówiącego regionu.flushSync lub opóźnienia mikrozadania.await nextTick() między dwoma zapisami; albo złóż region z shallowRef, którego harmonogram nie deduplikuje.$state do bezpośrednich zapisów DOM, pomijając wsadowanie frameworka. Brzmi idealnie dla aria-live — dopóki nie okaże się, że kompilator deduplikuje kolejne identyczne zapisy. „Ładowanie…“ po „Ładowanie…“ jest zwijane do jednej mutacji DOM.untrack z wartością strażniczą, aby wymusić nową mutację.batch() są odkładane, a biblioteki toast często używają batch do grupowania kilku zmian stanu — mutacja tekstu regionu może trafić do DOM jednocześnie z przełączeniem display: none rodzica.batch(); albo użyj untrack do odczytu sygnału i zapisu do DOM w oddzielnym zadaniu.Nie montuj regionu aria-live warunkowo. Region zamontowany w chwili, gdy po raz pierwszy pojawia się jego tekst, jest z punktu widzenia czytnika ekranu regionem pustym — a puste regiony niczego nie ogłaszają. Zamontuj region jako pusty przy starcie aplikacji i zmieniaj wyłącznie tekst wewnątrz niego. Każdy z powyższych frameworków zawodzi, jeśli naruszysz tę zasadę — niezależnie od tego, którą pułapkę harmonogramowania już obeszłeś.
4. Macierz zgodności: framework × wzorzec × technologia wspomagająca
Każdy wzorzec w każdym frameworku testowano z trzema czytnikami ekranu — NVDA 2025.1 na Windows 11, JAWS 2026 na Windows 11 i VoiceOver na macOS 15.4 — używając Chrome 138, Firefox 130 i Safari 17.6. Każda komórka odnotowuje zachowanie zaobserwowane w ok. 20 próbach na kombinację. „OK“ oznacza niezawodne ogłoszenie z oczekiwanym tekstem. „Częściowe“ oznacza ogłoszenie w niektórych konfiguracjach. „Zawodzi“ oznacza, że przynajmniej jeden czytnik ekranu po cichu pominął ogłoszenie.
| React 19 | Vue 3.5 | Svelte 5 | SolidJS 2.0 | |
|---|---|---|---|---|
| Toast (polite) | Częściowe | OK | OK | OK |
| Toast (assertive) | Częściowe | OK | Częściowe | OK |
| Błąd formularza (polite) | OK | OK | OK | OK |
| Asynchroniczny stan ładowania | Częściowe | Częściowe | Zawodzi | OK |
| Dane na żywo — wolny strumień | OK | OK | OK | OK |
| Dane na żywo — seria (więcej niż 5/s) | Zawodzi | Częściowe | Zawodzi | Częściowe |
Trzy obserwacje z macierzy. Po pierwsze, każdy framework obsługuje wzorzec błędu formularza poprawnie bez dodatkowych zabiegów — to jedyny wzorzec kanoniczny, który nie obciąża mechanizmu rekoncyliacji, bo region jest montowany przy starcie aplikacji, a tekst zmienia się raz na wysłanie formularza. Po drugie, każdy framework ma problemy z serią danych na żywo, bo żaden harmonogram po stronie klienta nie jest wystarczająco szybki, aby dostarczać mutacje do drzewa dostępności z prędkością sygnału. Po trzecie, kompilacyjna deduplikacja Svelte 5 sprawia, że wzorzec stanu ładowania jest jawną porażką, a nie tylko częściową — jedyny z czterech, gdzie domyślne zachowanie jest błędne.
Kolumna czytnika ekranu też ma znaczenie. JAWS 2026 jest najsurowszy ze wszystkich trzech w kwestii pustych regionów: odmówi ogłoszenia regionu, którego tekst zmienia się z „“ na „Zapisano“, jeśli zmiana trafia do DOM w tym samym malowaniu co montowanie regionu — w każdym frameworku. NVDA 2025.1 ogłasza niespójnie w tym samym przypadku. VoiceOver na macOS 15.4 jest najbardziej wyrozumiały — zazwyczaj ogłosi nawet montowanie z jednoczesnym tekstem — ale ta wyrozumiałość ukryła przed programistami wiele błędów frameworków testujących wyłącznie na Macu.
We wszystkich czterech frameworkach jedyną interwencją, która zamienia najwięcej komórek „Częściowe“ na „OK“, jest zamontowanie jednego globalnego, pustego div aria-live=“polite” i jednego div aria-live=“assertive” w korzeniu aplikacji — i kierowanie każdego ogłoszenia przez nie poprzez zapis tekstu do ich potomka. Eliminuje to każdy wyścig przy montowaniu mechanizmu rekoncyliacji jednym ruchem.
5. Kod dobry kontra zły — w każdym frameworku
Poniższe pary pokazują złe i dobre podejście do implementacji wzorca stanu ładowania w każdym frameworku. Wybrano stan ładowania, ponieważ macierz pokazuje tu najwięcej czerwieni — a przepaść między złym a dobrym kodem jest największa.
function LoadingState({ isLoading, results }) {
return isLoading ? (
<div role="status" aria-live="polite">
Loading results...
</div>
) : (
<ResultsList items={results} />
);
}Region jest montowany tylko podczas ładowania. Automatyczne wsadowanie Reacta może zacommitować montowanie i odmontowanie w tym samym malowaniu co nadejście danych — a JAWS, NVDA i VoiceOver nie zgadzają się, co z tym zrobić. Efekt netto: „Loading…“ jest czasem czytany, czasem nie, bez żadnego wykrywalnego wzorca po stronie klienta.
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} />}
</>
);
}Region jest montowany przy pierwszym renderowaniu i pozostaje zamontowany. Renderer Reacta może wsadować ile chce — jedyną rzeczą, która się zmienia, jest tekst wewnątrz istniejącego regionu, co jest dokładnie tym, co opisuje specyfikacja.
<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>Dwa zapisy do status mogą trafić w ten sam tick harmonogramu Vue, jeśli odpowiedź sieciowa jest szybka (z cache) — Vue deduplikuje i do DOM trafia tylko końcowy ciąg. Ogłoszenie „Loading…“ jest po cichu tracone.
<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>await nextTick() wymusza na harmonogramie opróżnienie „Loading…“ do DOM przed kolejnym przypisaniem. Czytnik ekranu widzi dwie odrębne mutacje i ogłasza każdą z nich.
<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>Kompilator Svelte 5 emituje zapis tekstu DOM na każdą zmianę $state, ale deduplikuje kolejne identyczne ciągi. Jeśli drugie wywołanie load() zapisuje ponownie „Loading…“, kompilator nie emituje mutacji — czytnik ekranu przy drugim kliknięciu nie słyszy niczego.
<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>Licznik sekwencji gwarantuje, że każdy zapis to nowy ciąg. Użytkownik nie słyszy liczby — czytnik ekranu ją wygładza — ale kompilator jest zmuszony emitować odrębną mutację DOM za każdym razem. Ominięcie deduplikacji to cały sens.
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);
});
}Sygnał status jest aktualizowany wewnątrz batch() razem z sygnałem results. Solid odkłada oba zapisy DOM do zamknięcia batcha — a przy szybkiej odpowiedzi z cache „Loading…“ i „Loaded…“ mogą zostać opróżnione w tym samym mikrozadaniu. Pośrednie ogłoszenie jest tracone.
async function load() {
setStatus('Loading...');
// sygnał status strzela natychmiast, poza jakimkolwiek batch
const data = await fetch('/api/results').then(r => r.json());
batch(() => {
setStatus(`Loaded ${data.length} results`);
setResults(data);
});
}Zapis „Loading…“ następuje poza batch(), więc precyzyjny harmonogram Solid aktualizuje DOM w momencie wystrzelenia sygnału. Czytnik ekranu widzi ogłoszenie przed zakończeniem rundy sieciowej. Zapis „Loaded“ może pozostać wewnątrz batch — ogłoszenie i tak jest emitowane, bo batch zamyka się synchronicznie.
6. Przekrojowa instrukcja postępowania
Zamontuj jeden globalny region live na poziom uprzejmości przy starcie aplikacji
Wyrenderuj dwa puste divy — jeden z aria-live=“polite” i jeden z aria-live=“assertive” — w korzeniu aplikacji, przed wyrenderowaniem jakiejkolwiek trasy. Każde ogłoszenie w aplikacji zapisuje się do jednego z tych dwóch regionów. Eliminuje to wyścig przy montowaniu we wszystkich powyższych frameworkach.
Napisz małą usługę announcera opakowującą globalne regiony
Udostępnij jedną funkcję — announce(message, politeness) — która odnajduje odpowiedni globalny region i ustawia jego textContent. Frameworki mogą dostarczyć reaktywną referencję do regionu, ale announcer może po prostu wywołać el.textContent = ”, a następnie el.textContent = message w następnym zadaniu, co wymusza mutację nawet dla identycznych ciągów.
Ogranicz serie danych do ok. 1 komunikatu na 1500 ms
Jeśli źródło danych może strzelać częściej niż raz na sekundę — licznik wyników, feed czatu — syntezator czytnika ekranu nie nadąży, niezależnie od frameworka. Skonsoliduj aktualizacje po stronie klienta i emituj jeden komunikat zbiorczy („3 nowe wiadomości“) zamiast trzech kolejnych ogłoszeń. Macierz pokazuje, że każdy framework zawodzi w wierszu „seria“ — rozwiązanie musi więc leżeć ponad frameworkiem, a nie wewnątrz niego.
Testuj z NVDA, JAWS i VoiceOver — wszystkimi trzema, za każdym razem
Macierz nie istniałaby, gdyby wystarczył jeden czytnik ekranu. Surowość JAWS w kwestii pustych regionów i wyrozumiałość VoiceOver ciągną w przeciwnych kierunkach; NVDA jest pośrodku. Wzorzec, który ogłasza poprawnie tylko pod VoiceOver — domyślnym czytnikiem dla zespołów frontendowych na Macu — jest zepsuty dla większości użytkowników korzystających z czytników ekranu.
Przestań warunkowo montować region live
Najczęstszy błąd we wszystkich czterech frameworkach. Zamontuj region jako pusty przy starcie aplikacji. Zmień tekst. Nigdy nie odmontowuj.
Podsumowanie: aria-live to problem frameworka ukryty za problemem znaczników
Lektura specyfikacji W3C ARIA może stwarzać wrażenie, że aria-live to kwestia wyboru znaczników — polite lub assertive, z rolą status, log lub alert, i gotowe. Specyfikacja ma rację w tym sensie, że to jedyne pokrętła, które rozpoznaje. Jednocześnie jest myląca, bo zakłada DOM mutujący się tak, jak mutuje dokument imperatywny.
Każdy z powyższych frameworków wprowadza harmonogram między kodem a DOM, a każdy harmonogram ma przypadki brzegowe, których specyfikacja nie omawia — automatyczne wsadowanie, opróżnianie mikrozadań, kompilacyjna deduplikacja, grafy sygnałów. Przypadki brzegowe nie są błędami frameworków; to celowo zaprojektowane funkcje, które wchodzą w złe interakcje z założeniami czytników ekranu co do momentu, w którym dochodzi do mutacji DOM.
Rozwiązanie jest strukturalne, a nie per-komponentowe. Zamontuj globalne regiony live przy starcie aplikacji, kieruj każde ogłoszenie przez małą usługę, ogranicz serie, testuj na wszystkich trzech czytnikach ekranu. Fakt, że ta sama pięcioetapowa instrukcja działa w React, Vue, Svelte i Solid, jest najsilniejszym dowodem na to, że wybrany framework ma mniejsze znaczenie niż architektura, którą go otaczasz.
Szerszy zestaw narzędzi dla programistów — wzorce testowania, sprawdzenia na etapie budowania, reszta mapy dostępności frontendu — znajdziesz na stronie przeznaczonej dla programistów; pełna dokumentacja kryteriów sukcesu WCAG 2.2 indeksuje kryteria, których dotyczą powyższe wzorce; bezpłatny skaner WCAG 2.2 wykrywa strukturalne awarie widoczne dla axe pod dowolnym URL.
„Specyfikacja aria-live zakłada, że DOM mutuje się tak, jak pisano w 2008 roku. Cztery frameworki później — żaden z nich nie mutuje w ten sposób, a czytnik ekranu o tym nie wie.“