Przejdź do treści
Dostępność WCAG

Powiększenie 400 proc. zostawia 320 px szerokości. Wygenerowany layout dostaje wtedy poziomy pasek

Kryterium 1.4.10 wymaga braku przewijania w dwóch wymiarach przy 320 px CSS, a axe-core nie ma dla niego reguły. Przyczyny w kodzie, pomiar i bramka w CI.

Redakcjadeveloper front-end11 minkod + tabela
400liczba z tezy tekstu

Okno przeglądarki ma 1280 px, powiększenie ustawione na 400 procent, i na dole okna pojawia się poziomy pasek przewijania. Przycisk zakupu jest za prawą krawędzią, nagłówek karty urwany w połowie, a żeby przeczytać cenę, trzeba przesunąć widok w prawo i wrócić. Strona na laptopie wygląda bez zarzutu.

Ten widok to nie egzotyczny przypadek testowy. Powiększenie 400 procent w oknie 1280 px daje dokładnie 320 px szerokości liczonej w pikselach CSS, a to wartość wpisana w kryterium 1.4.10 Reflow na poziomie AA. Layout wygenerowany przez model trafia na nią regularnie, bo generator optymalizuje układ pod szerokości, które widział w danych treningowych, a nie pod skrajny przypadek zwężenia.

Skąd się bierze 320 px, a nie 360 albo 375

Wartość nie pochodzi z żadnego telefonu. Kryterium w rekomendacji WCAG 2.2 wymaga, by treść dała się przedstawić bez utraty informacji i funkcjonalności oraz bez przewijania w dwóch wymiarach przy szerokości odpowiadającej 320 pikselom CSS dla treści przewijanej pionowo i przy wysokości 256 pikseli CSS dla treści przewijanej poziomo.

Obie liczby są pochodną powiększenia. Nota do kryterium w dokumencie Understanding Reflow podaje, że 320 px CSS to odpowiednik startowego okna 1280 px przy powiększeniu 400 procent, a 256 px CSS to odpowiednik okna o wysokości 1024 px przy tym samym powiększeniu. Adresatem kryterium jest więc osoba, która na dużym monitorze podkręca powiększenie, bo inaczej nie przeczyta tekstu — nie użytkownik telefonu. Telefon dostaje ten układ przy okazji.

Kryterium ma jeden wyjątek i jest on węższy, niż się go zwykle czyta: dotyczy tych fragmentów treści, które wymagają dwuwymiarowego układu dla swojego użycia lub znaczenia. Nota wylicza mapy, diagramy, wideo, gry, prezentacje oraz tabele danych — z jawnym zastrzeżeniem, że chodzi o tabelę, a nie o pojedynczą komórkę. Dla takich fragmentów przewijanie w dwóch wymiarach jest dopuszczalne. Wyjątek obejmuje sam fragment, nie całą stronę, na której ten fragment stoi.

W axe-core nie ma ani jednej reguły dla 1.4.10

Audyt automatyczny tego nie wyłapie, i nie jest to usterka narzędzia. W zestawieniu reguł axe-core nie występuje żadna reguła oznaczona tagiem wcag1410. Powiększeniem zajmuje się tam jedna reguła, meta-viewport, opisana jako sprawdzenie, czy znacznik <meta name="viewport"> nie blokuje skalowania tekstu i powiększania — ale jej tag to wcag144, czyli kryterium 1.4.4 Resize Text. Usunięcie user-scalable=no zamyka 1.4.4 i nie mówi nic o tym, czy przy 320 px cokolwiek się mieści.

Metoda sprawdzenia jest opisana wprost i jest ręczna. Technika błędu F102 każe spisać widoczne elementy treści przy szerokości okna typu 1280 px, ustawić szerokość okna na 320 px — przez zwężenie okna albo przez powiększenie do 400 procent z poziomu 1280 px — i dla każdego elementu, którego przy 320 px nie ma, sprawdzić, czy istnieje droga do tej samej lub równoważnej treści przez rozwijany panel, wyskakujące okno albo odnośnik do innego widoku. Jeśli takiej drogi nie ma, kryterium jest złamane.

Kryterium Reguła w axe-core Co zostaje do sprawdzenia ręcznie
1.4.4 Resize Text meta-viewport Czy przy powiększeniu tekstu do 200 procent nie ginie treść ani funkcja
1.4.10 Reflow brak reguły Cała procedura F102 przy 320 px: poziomy pasek i treść, której nie ma
2.1.1 Keyboard scrollable-region-focusable Czy obszar przewijany ma nazwę i czy da się nim przewijać z klawiatury

Cztery konstrukcje, które rozpychają stronę w poziomie

Poziomy pasek przy 320 px prawie zawsze ma jedną z czterech przyczyn, a wszystkie cztery są typowe dla kodu z generatora. Pierwsza to sztywna szerokość w pikselach wpisana w element, który stoi w kontenerze flex:

/* wersja z generatora */
.rzad { display: flex; gap: 1rem; }
.rzad > .karta { width: 360px; }

/* po poprawce */
.rzad { display: flex; flex-wrap: wrap; gap: 1rem; }
.rzad > .karta { flex: 1 1 16rem; min-width: 0; }

Druga przyczyna siedzi w wartości domyślnej i bez znajomości specyfikacji wygląda na magię. Dokumentacja MDN dla min-width podaje, że wartością początkową jest auto, a dla elementów flex i grid to auto rozwiązuje się do rozmiaru zadeklarowanego, rozmiaru przeniesionego z aspect-ratio albo — w pozostałych przypadkach — do rozmiaru min-content. Dla zwykłych bloków auto rozwiązuje się do zera. Praktyczny skutek: element flex z długim adresem e-mail, numerem konta albo linią kodu nie zwęzi się poniżej najdłuższego nierozdzielnego ciągu i wypchnie stronę, choć w arkuszu nie ma ani jednej sztywnej szerokości. Lekarstwem jest min-width: 0 plus overflow-wrap: anywhere na tym konkretnym elemencie.

Trzecia to white-space: nowrap użyte hurtowo — na okruszkach nawigacji, na komórkach tabeli, na etykietach filtrów. Deklaracja ma sens na kolumnie z kwotami, gdzie złamanie liczby szkodzi czytelności. Na całym wierszu nagłówkowym rozpycha stronę o sumę szerokości wszystkich etykiet. Czwarta to width: 100vw na elemencie position: fixed: jednostka vw liczy szerokość okna razem z klasycznym paskiem przewijania, więc element szerszy od obszaru treści wywołuje przewijanie w poziomie dokładnie o szerokość tego paska. Zamiana na width: 100% kończy sprawę.

Konstrukcja w kodzie Co się dzieje przy 320 px Poprawka
width: 360px na elemencie flex Element nie zwęża się, rząd wystaje o 40 px flex: 1 1 16rem i min-width: 0
min-width: auto (domyślne) i długi ciąg bez spacji Element nie zejdzie poniżej min-content min-width: 0 i overflow-wrap: anywhere
white-space: nowrap na wierszu nagłówkowym Strona rośnie o sumę szerokości etykiet nowrap tylko na kolumnie z liczbami
min-width: 800px na tabeli bez opakowania Przewija się cała strona, nie tabela Opakowanie z overflow-x: auto
width: 100vw na elemencie fixed Nadmiar równy szerokości paska przewijania width: 100%

Wysokość skaluje się tak samo, ale łamie inne kryterium

Przy powiększeniu 400 procent okno o wysokości 1024 px daje 256 px CSS. Jednostka vh to jeden procent wysokości okna, więc min-height: 100vh na sekcji powitalnej oznacza w tym widoku 256 px, a przyklejony nagłówek o wysokości 72 px zabiera z tego ponad jedną czwartą. Jeśli do tego dochodzi pasek zgody na ciasteczka, na treść zostaje pasek wysokości kilkudziesięciu pikseli.

Tu trzeba rozdzielić dwie rzeczy, bo zlewanie ich w jedno prowadzi do poprawiania nie tego, co trzeba. Samo przewijanie w pionie jest przy 1.4.10 dozwolone — kryterium zabrania przewijania w dwóch wymiarach, nie w jednym. Niska wysokość okna łamie 1.4.10 tylko wtedy, gdy coś zostaje przycięte przez overflow: hidden i staje się nieosiągalne, czyli gdy spełnia się warunek z F102. Osobnym kryterium jest 2.4.11 Focus Not Obscured, które dotyczy sytuacji, gdy przyklejony nagłówek zasłania element z fokusem. Pierwsze naprawia się układem, drugie — właściwością scroll-margin-top i wysokością nagłówka zależną od wysokości okna.

Jest też różnica, o której łatwo zapomnieć przy wyborze metody pomiaru. Zwężenie okna i powiększenie dają ten sam wynik dla zapytań medialnych o szerokość, ale nie dla cech wejścia. Laptop przy powiększeniu 400 procent nadal raportuje wskaźnik precyzyjny, więc reguły schowane pod (pointer: coarse) nie wejdą. Układ, który powiększa przyciski tylko dla ekranów dotykowych, przy powiększeniu zostawia cele w rozmiarze z monitora.

Pomiar: 320 na 256 w konsoli, potem bramka w CI

Pierwszy pomiar zajmuje kilkanaście sekund. W DevTools ustaw rozmiar widoku na 320 na 256 i wpisz w konsoli dwie liczby: document.documentElement.scrollWidth i clientWidth. Jeśli pierwsza jest większa, strona przewija się w poziomie. Winowajcę znajduje przegląd elementów wychodzących za krawędź:

const d = document.documentElement;
console.log(d.scrollWidth, d.clientWidth);

const szer = d.clientWidth;
[...document.querySelectorAll('*')]
  .map((el) => [el, el.getBoundingClientRect()])
  .filter(([, r]) => r.width > 0 && (r.right > szer + 1 || r.left < -1))
  .forEach(([el, r]) => console.log(Math.round(r.left), Math.round(r.right), el));

Wynik wymaga jednej korekty w głowie: skrypt zgłosi również zawartość obszarów, które przewijają się w poziomie legalnie, czyli komórki tabeli w opakowaniu z overflow-x: auto. Te pozycje odpada się po rodzicu. Liczy się pierwszy element od góry drzewa, który wystaje — reszta zwykle tylko w nim siedzi.

Pomiar ręczny wystarcza raz. Przy trzecim przekazaniu projektu po zmianach lepiej, żeby liczył to CI:

import { test, expect } from '@playwright/test';

const strony = ['/', '/produkt/przyklad', '/koszyk', '/kontakt'];

for (const url of strony) {
  test(`reflow 320x256 bez poziomego paska: ${url}`, async ({ page }) => {
    await page.setViewportSize({ width: 320, height: 256 });
    await page.goto(url);
    const nadmiar = await page.evaluate(() => {
      const d = document.documentElement;
      return d.scrollWidth - d.clientWidth;
    });
    expect(nadmiar).toBeLessThanOrEqual(1);
  });
}

Wysokość 256 px w tym teście nie jest ozdobą — bierze się z tej samej noty, z której bierze się 320 px, i wyłapuje elementy pozycjonowane względem wysokości okna. Tolerancja jednego piksela zdejmuje z testu zaokrąglenia przy niecałkowitym powiększeniu. Taki test nie zastępuje procedury F102, bo nie wie nic o treści, która zniknęła. Wyłapuje natomiast dokładnie tę klasę regresji, którą wnosi każdy kolejny wygenerowany komponent wklejony do istniejącego widoku.

Jest też strona pieniędzy. Bramka, która pilnuje reflow przy każdym wdrożeniu, to dokładnie ten rodzaj pracy, który źle się sprzedaje jako pozycja w kosztorysie projektu, a dobrze jako element umowy utrzymaniowej — ta sama logika, którą opisuje ebook o przejściu z pojedynczych zleceń na przychód abonamentowy. Klient płaci za to, że regresja nie dojedzie do produkcji, a nie za godziny wpisane w arkusz.

Tabela danych: wyjątek z kryterium i pułapka w jego obejściu

Tabela z dwunastoma kolumnami nie zmieści się w 320 px i nie musi. Mieści się w wyjątku dla treści wymagającej dwuwymiarowego układu — pod warunkiem że to tabela się przewija, a nie strona. Standardowe obejście wygląda tak:

<div class="przewijak" tabindex="0" role="region"
     aria-label="Cennik: porównanie pakietów">
  <table>...</table>
</div>

.przewijak { overflow-x: auto; }
.przewijak table { min-width: 48rem; }

Dwa atrybuty w tym fragmencie nie są kosmetyką. Obszar przewijany, którego nie da się przewinąć z klawiatury, łamie kryterium 2.1.1 — i to jedna z niewielu rzeczy z tego obszaru, które audyt automatyczny wyłapie, bo reguła scrollable-region-focusable z opisem „ensure elements that have scrollable content are accessible by keyboard in Safari” ma w zestawieniu axe-core wagę serious i tagi wcag211 oraz wcag213. Sam tabindex="0" daje dostęp z klawiatury, ale bez nazwy czytnik ekranu zapowie bezimienny obszar, dlatego role="region" idzie w parze z aria-label. Generatory wstawiają opakowanie z overflow-x: auto często, a te dwa atrybuty — rzadko.

Co wpisać do protokołu odbioru, a co do umowy utrzymaniowej

Sprawdzenie reflow ma tę właściwość, że jest tanie przy pierwszym uruchomieniu i kosztowne, gdy się o nim zapomni na trzy miesiące. Dlatego miejsce dla niego jest w liście kontrolnej odbioru, a nie w osobnym zleceniu audytowym. Lista, która się sprawdza przy oddawaniu serwisu, ma cztery pozycje i każdą da się wykonać bez wiedzy o specyfikacji:

  • Widok 320 na 256 na stronie głównej, jednym widoku listy, jednym widoku szczegółu, koszyku i formularzu kontaktu.
  • scrollWidth równy clientWidth z tolerancją jednego piksela na każdym z tych widoków.
  • Spis elementów widocznych przy 1280 px zestawiony z tym, co widać przy 320 px, zgodnie z procedurą F102.
  • Każdy obszar z overflow-x: auto ma tabindex="0" i nazwę.

Taka lista działa tylko wtedy, gdy ktoś ją wykonuje, gdy autor kodu jest zajęty czym innym — a to już kwestia tego, jak się przekazuje zadanie, nie jak się pisze CSS. Dobrze opisana lista kontrolna przed przekazaniem zadania rozwiązuje przy odbiorze stron ten sam problem, co w każdej innej powtarzalnej czynności: wykonanie przestaje zależeć od tego, kto akurat ma dyżur.

Osobna sprawa to zakres odpowiedzialności. Od 28 czerwca 2025 r. część podmiotów gospodarczych ma obowiązki dostępności cyfrowej w handlu elektronicznym na podstawie ustawy z 26 kwietnia 2024 r. (Dz.U. 2024 poz. 731), opisane na stronie rządowej o Polskim Akcie o Dostępności. Ustawa przewiduje wyłączenia i okresy przejściowe, a zakres podmiotowy wynika z jej art. 3 — czy konkretny klient jest objęty, sprawdza się w tym przepisie, nie w ogólnym opisie. Dla wykonawcy wniosek jest praktyczny niezależnie od wyniku: jeśli protokół odbioru milczy o tym, przy jakiej szerokości widoku serwis był sprawdzany, to pół roku później nie da się ustalić, czy zgodność zepsuła zmiana po stronie klienta, czy nie było jej od początku.

Jeśli w projekcie nie ma jeszcze spisanej żadnej procedury odbioru, nie ma sensu zaczynać od wszystkich — lepiej wybrać te procesy, które warto opisać najpierw, i odbiór techniczny jest dobrym kandydatem, bo powtarza się w każdym zleceniu bez wyjątku.

Następny krok jest jeden i zajmuje kwadrans. Otwórz ostatni oddany projekt, ustaw widok 320 na 256 i porównaj scrollWidth z clientWidth na pięciu widokach. Jeśli liczby się różnią, masz jedną z czterech przyczyn z tabeli i poprawkę obok niej. Jeśli się nie różnią, zostaje decyzja, czy ten pomiar wchodzi do CI jako test, czy zostaje czynnością, którą ktoś kiedyś wykona ręcznie — przy trzecim wygenerowanym komponencie w tym samym widoku odpowiedź zwykle już się zna.