dostępnośćreactnext.js

Budowanie komponentów dostępnych dla czytników ekranu

Budowanie komponentów dostępnych dla czytników ekranu

Wyobraź sobie użytkownika NVDA, który otwiera modal z formularzem, wypełnia go i klika „Zamknij”. Modal wizualnie znika – display: none w CSS, wszystko wygląda w porządku. Tyle że w drzewie DOM element wciąż tam jest, focus nigdzie się nie przeniósł, a czytnik ekranu dalej czyta zawartość niewidocznego okna. Użytkownik słyszy treść formularza, którego przed chwilą „zamknął”, i nie ma pojęcia, gdzie teraz jest na stronie. To nie jest wymyślony przykład – to jeden z najczęstszych błędów w komponentach pisanych „na oko”, bez zrozumienia, jak faktycznie działa dostępność. W tym artykule pokażę nie tylko listę zasad, ale mechanizm stojący za nimi – i kilka realnych komponentów, na których widać, gdzie dokładnie leżą pułapki.

Jak czytnik ekranu w ogóle „widzi” stronę

Zanim zaczniemy poprawiać komponenty, warto rozumieć, z czego korzysta czytnik ekranu. Nie renderuje on strony tak jak przeglądarka – operuje na tzw. accessibility tree, strukturze budowanej przez przeglądarkę równolegle do DOM. Każdy węzeł tego drzewa ma trzy podstawowe właściwości: rolę (czy to przycisk, link, nagłówek, pole formularza), nazwę (co użytkownik usłyszy jako opis) i stan (czy jest rozwinięty, zaznaczony, wyłączony).

Kluczowa konsekwencja: czytnik ekranu nie widzi Twojego CSS. display: none i visibility: hidden usuwają element z accessibility tree – to poprawne ukrycie. Ale samo wizualne przesunięcie elementu poza ekran (position: absolute; left: -9999px) albo zmiana koloru na transparent nic nie zmienia w drzewie dostępności – element nadal tam jest i nadal zostanie odczytany. To dokładnie to, co poszło nie tak w przykładzie z modalem powyżej.

Drugi filar to kolejność DOM. Czytnik ekranu w trybie przeglądania czyta stronę w kolejności, w jakiej elementy występują w drzewie – niezależnie od tego, jak ułożyłeś je wizualnie przez flex-direction: row-reverse czy grid-template-areas. Jeśli Twój layout wizualnie ma sens, ale kolejność w kodzie jest przypadkowa, osoba niewidoma dostanie stronę „w losowej kolejności”.

Semantyka jako pierwsza linia obrony

Najtańszym sposobem na dostępność jest użycie właściwego elementu HTML zamiast odtwarzania jego zachowania od zera. Zobacz różnicę:

jsx

// Źle – wygląda jak przycisk, ale nim nie jest
const SaveButton = ({ onSave }) => (
<div className="btn" onClick={onSave}>
Zapisz zmiany
</div>
);

Ten div ma poważne braki, które nie są widoczne na pierwszy rzut oka:

  • Nie jest fokusowalny – nie da się do niego dojść klawiszem Tab.
  • Nie reaguje na klawiaturę – Enter i Spacja nic nie robią, bo onClick w Reakcie nasłuchuje tylko na mysz/dotyk.
  • Nie ma roli button – czytnik ekranu przeczyta go jako zwykły tekst, bez informacji, że da się go „aktywować”.
  • Nie ma stanu disabled – nie możesz go po prostu wyłączyć atrybutem disabled, musisz to symulować ręcznie.

jsx

// Dobrze – wszystko powyższe dostajesz za darmo
const SaveButton = ({ onSave, isSaving }) => (
<button type="button" onClick={onSave} disabled={isSaving}>
{isSaving ? "Zapisywanie…" : "Zapisz zmiany"}
</button>
);

Ta sama zasada dotyczy <nav> zamiast <div className="nav">, <label> powiązanego z polem przez htmlFor zamiast placeholdera pełniącego rolę etykiety, czy <ul>/<li> dla list zamiast ciągu <div>-ów. Semantyczny HTML nie jest „ładniejszy” – on dosłownie generuje inną, bogatszą strukturę w accessibility tree.

ARIA: kiedy pomaga, a kiedy szkodzi

Specyfikacja WAI-ARIA zaczyna się od zasady, którą warto zapamiętać dosłownie: „No ARIA is better than Bad ARIA”. Atrybuty ARIA nie dodają zachowania – tylko nadpisują to, co czytnik ekranu zgłasza użytkownikowi. Jeśli obiecasz coś, czego komponent nie potrafi, robisz gorzej, niż gdybyś nie dodał nic.

jsx

// Źle – ARIA obiecuje przycisk, ale nic więcej go nie wspiera
const DeleteIcon = ({ onDelete }) => (
<span role="button" aria-label="Usuń element" onClick={onDelete}>
🗑
</span>
);

Ten kod mówi czytnikowi ekranu „to jest przycisk” – ale nie dodaje obsługi klawiatury ani tabIndex, więc użytkownik klawiatury nigdy się do niego nie dostanie. To gorsze niż brak role, bo sprawia wrażenie, że funkcja istnieje, choć jest fizycznie niedostępna.

jsx

// Dobrze – po prostu użyj natywnego przycisku
const DeleteIcon = ({ onDelete }) => (
<button type="button" onClick={onDelete} aria-label="Usuń element">
<span aria-hidden="true">🗑</span>
</button>
);

Zwróć uwagę na aria-hidden="true" na emoji – bez tego niektóre czytniki próbują odczytać nazwę znaku Unicode („kosz na śmieci”), co brzmi absurdalnie zaraz po przeczytaniu aria-label. aria-label całkowicie zastępuje treść wizualną w tym, co usłyszy użytkownik – jeśli element ma już czytelny tekst, zwykle lepszym wyborem jest aria-labelledby wskazujący na ten tekst, żeby nie utrzymywać dwóch niezależnych opisów, które mogą się z czasem rozjechać.

Praktyczny przykład: dostępny akordeon

Prosty przycisk niewiele pokazuje. Zobaczmy komponent, w którym rzeczywiście trzeba świadomie zarządzać stanem i relacjami ARIA – akordeon, czyli sekcję rozwijaną, popularną w FAQ czy panelach ustawień.

jsx

import { useId, useState } from "react";
const AccordionItem = ({ title, children, defaultOpen = false }) => {
const [isOpen, setIsOpen] = useState(defaultOpen);
const contentId = useId();
return (
<div className="accordion-item">
<h3 className="accordion-header">
<button
type="button"
className="accordion-trigger"
aria-expanded={isOpen}
aria-controls={contentId}
onClick={() => setIsOpen((open) => !open)}
>
{title}
<span className="accordion-icon" aria-hidden="true">
{isOpen ? "−" : "+"}
</span>
</button>
</h3>
<div id={contentId} role="region" aria-labelledby={contentId} hidden={!isOpen}>
{children}
</div>
</div>
);
};
export default AccordionItem;

Kilka decyzji w tym kodzie nie jest przypadkowych:

  • aria-expanded informuje o aktualnym stanie – bez niego użytkownik czytnika słyszy tylko „przycisk”, nie wie, czy sekcja jest rozwinięta.
  • aria-controls wiąże przycisk z panelem, który kontroluje – część czytników ogłasza tę relację, ułatwiając nawigację.
  • hidden (a nie ukrywanie samym CSS) gwarantuje, że zamknięta zawartość realnie znika z accessibility tree i z kolejności Tab, więc nie da się „wpaść” focusem w niewidoczną treść.
  • <h3> opakowujący przycisk utrzymuje poprawną hierarchię nagłówków – użytkownicy czytników bardzo często nawigują właśnie po nagłówkach, skacząc między sekcjami klawiszem H.
  • Ikona +/ ma aria-hidden="true", bo informacja o stanie już płynie przez aria-expanded – bez tego czytnik ogłosiłby ją podwójnie, w dodatku w mylącej formie.

Live regions i komunikaty dynamiczne

Kolejny częsty problem: coś zmienia się na stronie bez przeładowania, a czytnik ekranu w ogóle tego nie zauważa, bo nie ma powodu, żeby ponownie „przeczytać” fragment, którego akurat nie eksploruje w danej chwili. Klasyczny przykład – błąd walidacji formularza pojawiający się dynamicznie po wyjściu z pola:

jsx

import { useState } from "react";
const EmailField = () => {
const [error, setError] = useState("");
const handleBlur = (event) => {
const value = event.target.value;
setError(value.includes("@") ? "" : "Podaj poprawny adres e-mail.");
};
return (
<div className="field">
<label htmlFor="email">E-mail</label>
<input
id="email"
type="email"
aria-invalid={Boolean(error)}
aria-describedby={error ? "email-error" : undefined}
onBlur={handleBlur}
/>
<span id="email-error" role="alert" className="field-error">
{error}
</span>
</div>
);
};
export default EmailField;

role="alert" sprawia, że element działa jak niejawny aria-live="assertive" – gdy jego treść się zmienia, czytnik przerywa bieżące czytanie i od razu ogłasza nowy komunikat. To dobre dla błędów wymagających natychmiastowej uwagi, ale nie nadużywaj go do rzeczy mniej pilnych (np. „zapisano szkic”) – tam lepiej sprawdzi się aria-live="polite", który poczeka, aż użytkownik skończy bieżącą czynność, zamiast mu przerywać. aria-describedby dodatkowo wiąże komunikat błędu z polem, więc czytnik przeczyta go razem z etykietą za każdym razem, gdy użytkownik wróci do tego inputu – nie tylko w momencie, gdy błąd się pojawił.

Focus management przy nawigacji w Next.js

To pułapka specyficzna dla aplikacji SPA, w tym App Routera w Next.js. Przy klasycznym przejściu między stronami (pełne przeładowanie) przeglądarka resetuje focus na <body>, a czytnik ekranu ogłasza nowy tytuł dokumentu – użytkownik wie, że wylądował na nowej stronie. Przy nawigacji po stronie klienta nic z tego nie dzieje się automatycznie: focus zostaje na linku, który kliknięto (często w nawigacji, poza obszarem nowej treści), a screen reader nie ma żadnego sygnału, że cokolwiek się zmieniło.

jsx

"use client";
import { usePathname } from "next/navigation";
import { useEffect, useRef } from "react";
const RouteAnnouncer = ({ pageTitle }) => {
const pathname = usePathname();
const headingRef = useRef(null);
useEffect(() => {
headingRef.current?.focus();
}, [pathname]);
return (
<h1 ref={headingRef} tabIndex={-1} className="visually-focusable-heading">
{pageTitle}
</h1>
);
};
export default RouteAnnouncer;

Sztuczka polega na tabIndex={-1} – normalnie nagłówki nie są fokusowalne, ale ta wartość pozwala przenieść na nie focus programowo (.focus()), bez dodawania ich do naturalnej kolejności Tab. Po każdej zmianie ścieżki (pathname) focus wraca na nagłówek nowej strony, więc czytnik ekranu ogłasza jej tytuł – dokładnie tak, jak przy tradycyjnym przeładowaniu. To jeden komponent, który warto raz dodać w layoucie i mieć z głowy w całej aplikacji.

Jak naprawdę testować dostępność

Automatyczne narzędzia jak axe-core czy eslint-plugin-jsx-a11y warto mieć włączone od pierwszego dnia projektu – łapią oczywiste błędy (brak alt, kontrast, brakujące etykiety) zanim trafią na produkcję. Ale trzeba mieć świadomość ich granic: według badań Deque System narzędzia automatyczne wykrywają realnie ok. 30–40% problemów z dostępnością. Reszta wymaga ręcznego sprawdzenia, bo dotyczy sensu i kontekstu, których maszyna nie oceni – np. czy kolejność czytania ma logiczny sens, czy komunikat błędu faktycznie tłumaczy, co zrobić, czy focus trap w modalu naprawdę nie wypuszcza użytkownika poza okno.

Konkretna, powtarzalna procedura, która wyłapuje najwięcej realnych problemów:

  • Odłóż mysz i przejdź cały flow samą klawiaturą – Tab, Shift+Tab, Enter, Spacja, Escape, strzałki tam gdzie to naturalne (np. w menu). Jeśli w jakimkolwiek momencie nie wiesz, gdzie jest focus, to już jest błąd.
  • Uruchom VoiceOver (macOS: Cmd+F5) albo NVDA (Windows, darmowy) i przejdź ten sam flow z zamkniętymi oczami. Dopiero to pokazuje, czy kolejność, nazwy i stany faktycznie mają sens na głos, a nie tylko na papierze.
  • Sprawdź komunikaty dynamiczne osobno – dodaj błąd do formularza, zmień route, otwórz modal – i zweryfikuj, czy czytnik faktycznie coś ogłosił, a nie tylko czy element ma poprawny atrybut w kodzie.

Podsumowanie

Dostępność komponentów to nie checklist atrybutów do doklejenia na końcu, tylko konsekwencja tego, jak modelujesz stan i strukturę od samego początku. Trzy rzeczy, które warto zapamiętać z tego artykułu: po pierwsze, semantyczny HTML daje Ci za darmo fokusowalność, obsługę klawiatury i poprawną rolę – ARIA powinna dopowiadać to, czego HTML nie potrafi wyrazić, a nie zastępować go w komponentach, które równie dobrze mogłyby być natywnym elementem. Po drugie, zmiany stanu i treści muszą być aktywnie ogłaszane – aria-live, role="alert" i zarządzanie focusem po nawigacji nie są dodatkiem, tylko warunkiem, żeby dynamiczna aplikacja w ogóle była używalna bez wzroku. Po trzecie, żadne narzędzie automatyczne nie zastąpi przejścia przez własny interfejs z zamkniętymi oczami – to najszybszy sposób, żeby zobaczyć, gdzie Twój „dostępny” komponent w praktyce gubi użytkownika.