Що це. a11y — нумеронім слова accessibility: між «a» і «y» 11 літер. Доступність означає, що продуктом можуть користуватися люди з різними можливостями: незрячі й слабозорі (скрінрідер, збільшення), глухі (субтитри), люди з моторними обмеженнями (лише клавіатура, перемикачі, голосове керування), з когнітивними особливостями, епілепсією чи вестибулярними розладами.
What it is. a11y is a numeronym for accessibility: there are 11 letters between «a» and «y». Accessibility means people with different abilities can use the product: blind and low-vision users (screen reader, magnification), Deaf users (captions), people with motor impairments (keyboard only, switches, voice control), with cognitive differences, epilepsy or vestibular disorders.
Масштаб. За оцінкою ВООЗ, приблизно 1,3 млрд людей, тобто кожен шостий, мають суттєву інвалідність. До цього додаються тимчасові й ситуативні обмеження: зламана рука, яскраве сонце на екрані телефона, шумна вулиця без навушників, літній вік. Тому рішення «a11y потрібна кільком відсоткам» — помилка в оцінці аудиторії.
Scale. The WHO estimates about 1.3 billion people, roughly one in six, live with a significant disability. Add temporary and situational limitations on top: a broken arm, bright sun on a phone screen, a noisy street without headphones, old age. So the idea that «a11y is for a few percent» is a mistake in sizing the audience.
Чому це якість. Недоступна форма оплати для незрячого користувача — такий самий блокер, як кнопка, що не натискається. Користувач не завершить сценарій, бізнес втратить гроші, а в ЄС і США ще й отримає юридичний ризик. Бонусом доступність покращує SEO, автотести (локатори за ролями) і зручність для всіх: субтитри дивляться в метро, клавіатурою швидше заповнювати форми.
Why it is quality. A payment form a blind user cannot complete is the same blocker as a button that does not click. The user drops out of the flow, the business loses money, and in the EU and US there is legal risk on top. As a bonus, accessibility improves SEO, automated tests (role-based locators) and convenience for everyone: people watch captions on the metro and fill forms faster with the keyboard.
Типова помилка. Відкладати доступність «на потім» і перевіряти її один раз перед релізом. Виправлення семантики в готовому дизайн-системному компоненті коштує в рази дорожче, ніж правильна розмітка на етапі розробки.
The common mistake. Pushing accessibility «to later» and checking it once before a release. Fixing semantics in a finished design-system component costs many times more than correct markup during development.
# Користувач / технологія -> Що перевіряю
Незрячий, скрінрідер -> ролі, імена, порядок читання, оголошення змін
Слабозорий, збільшення 200-400% -> reflow без горизонтального скролу, контраст
Лише клавіатура / перемикач -> усе досяжне через Tab, видимий фокус, немає пасток
Голосове керування -> видимий текст кнопки = її accessible name
Глухий / в шумі -> субтитри, текстова альтернатива звуковим сигналам
Епілепсія, вестибулярні розлади -> немає спалахів, prefers-reduced-motion
Когнітивні особливості -> зрозумілі помилки, немає жорстких таймерів
# User / technology -> What I check
Blind, screen reader -> roles, names, reading order, announced changes
Low vision, zoom 200-400% -> reflow without horizontal scroll, contrast
Keyboard only / switch -> everything reachable via Tab, visible focus, no traps
Voice control -> visible button text = its accessible name
Deaf / noisy place -> captions, text alternative for sound cues
Epilepsy, vestibular disorders -> no flashing, prefers-reduced-motion
Cognitive differences -> clear errors, no hard timers
США. ADA (Americans with Disabilities Act) прямо не згадує вебсайти, але суди тлумачать сайти й застосунки бізнесу як «місця публічного користування»; щороку подаються тисячі позовів, відома справа — Robles v. Domino's. Section 508 зобов'язує федеральні агенції й постачальників для них відповідати WCAG 2.0 AA. Правило Міністерства юстиції 2024 року для сайтів органів штатів і місцевого самоврядування посилається на WCAG 2.1 AA.
United States. The ADA (Americans with Disabilities Act) does not mention websites explicitly, but courts treat business sites and apps as «places of public accommodation»; thousands of suits are filed every year, a well-known one being Robles v. Domino's. Section 508 requires federal agencies and their vendors to meet WCAG 2.0 AA. The 2024 Department of Justice rule for state and local government sites references WCAG 2.1 AA.
ЄС. European Accessibility Act (Директива 2019/882) застосовується з 28 червня 2025 року до багатьох продуктів і послуг для споживачів: e-commerce, банківські послуги, електронні книги, транспортні квитки, термінали. Технічна основа — гармонізований стандарт EN 301 549, чиї вимоги до вебу збігаються з WCAG рівня AA (чинна версія базується на WCAG 2.1, нова версія узгоджується з WCAG 2.2). Мікропідприємства, що надають послуги, мають виняток, а контроль і штрафи визначає кожна країна.
European Union. The European Accessibility Act (Directive 2019/882) applies from 28 June 2025 to many consumer products and services: e-commerce, banking, e-books, transport tickets, terminals. Its technical basis is the harmonised standard EN 301 549, whose web requirements match WCAG level AA (the current version is based on WCAG 2.1, and the new one aligns with WCAG 2.2). Micro-enterprises providing services are exempt, and each country sets its own enforcement and penalties.
Що це означає для QA. Юридична ціль майже завжди — WCAG AA, тому я тестую саме за ним і тегую автоматичні перевірки відповідно. Результат аудиту має бути відстежуваним: критерій, сторінка, статус. Для B2B-продажів потрібен VPAT/ACR, і QA дає для нього докази. Нарешті, тестую не лише веб: закон охоплює мобільні застосунки й документи.
What it means for QA. The legal target is almost always WCAG AA, so I test against it and tag automated checks accordingly. Audit results must be traceable: criterion, page, status. B2B sales need a VPAT/ACR, and QA supplies the evidence for it. Finally, I test more than the web: the law covers mobile apps and documents too.
Пастка. Вважати, що оверлей-віджет «доступності за один скрипт» робить сайт відповідним. Такі віджети не виправляють розмітку, а позови проти сайтів з ними теж подаються.
The trap. Believing that an overlay widget, «accessibility in one script», makes a site compliant. Such widgets do not fix the markup, and sites using them get sued as well.
# Ринок Норма Технічна планка Що здає QA
США ADA Title III (суди) WCAG 2.x AA де-факто звіт аудиту, план виправлень
США Section 508 (федеральні) WCAG 2.0 AA ACR на VPAT 508 edition
ЄС EAA, з 28.06.2025 EN 301 549 ~ WCAG AA ACR на VPAT EU edition
Усі Внутрішня політика WCAG 2.2 AA теги axe: wcag2a..wcag22aa
# Висновок для тестування: ціль = WCAG 2.2 AA,
# вона покриває вимоги 2.0 і 2.1 AA з запасом.
# Market Rule Technical bar What QA delivers
US ADA Title III (courts) WCAG 2.x AA de facto audit report, fix plan
US Section 508 (federal) WCAG 2.0 AA ACR on VPAT 508 edition
EU EAA, from 28.06.2025 EN 301 549 ~ WCAG AA ACR on VPAT EU edition
All Internal policy WCAG 2.2 AA axe tags: wcag2a..wcag22aa
# Takeaway for testing: target = WCAG 2.2 AA,
# it covers the 2.0 and 2.1 AA requirements with margin.
Що це. WCAG (Web Content Accessibility Guidelines) — стандарт W3C для доступності вебконтенту. Актуальна рекомендація — WCAG 2.2 (жовтень 2023). Він технологічно нейтральний: описує результат для користувача, а не конкретний код, тому його застосовують і до мобільних застосунків та документів.
What it is. WCAG (Web Content Accessibility Guidelines) is the W3C standard for accessible web content. The current recommendation is WCAG 2.2 (October 2023). It is technology-neutral: it describes the outcome for the user rather than specific code, which is why it is also applied to mobile apps and documents.
Структура. 4 принципи (POUR) → 13 настанов (guidelines) → понад 80 критеріїв успіху (success criteria). Номер читається як принцип.настанова.критерій: 1.4.3 — принцип 1 «Perceivable», настанова 1.4 «Distinguishable», критерій 3 «Contrast (Minimum)». Тестують саме критерії: кожен сформульований так, щоб його можна було перевірити як «pass» або «fail». До стандарту додаються Understanding-документи (навіщо) і Techniques (як виконати та типові помилки, наприклад F65 — відсутній alt).
Structure. 4 principles (POUR) → 13 guidelines → more than 80 success criteria. A number reads as principle.guideline.criterion: 1.4.3 is principle 1 «Perceivable», guideline 1.4 «Distinguishable», criterion 3 «Contrast (Minimum)». Testing happens at the criterion level: each one is written so it can be checked as pass or fail. The standard comes with Understanding documents (the why) and Techniques (how to meet it and common failures, for example F65 — a missing alt).
Рівні. A — мінімум, без якого частина людей взагалі не скористається сторінкою (1.1.1 текстова альтернатива, 2.1.1 клавіатура). AA — стандартна ціль законів і політик (1.4.3 контраст 4.5:1, 2.4.7 видимий фокус). AAA — найвища планка (1.4.6 контраст 7:1); W3C прямо не рекомендує вимагати AAA для цілих сайтів, бо для деякого контенту її неможливо досягти.
Levels. A is the minimum without which some people cannot use the page at all (1.1.1 text alternatives, 2.1.1 keyboard). AA is the usual target of laws and policies (1.4.3 contrast 4.5:1, 2.4.7 focus visible). AAA is the highest bar (1.4.6 contrast 7:1); the W3C explicitly does not recommend requiring AAA for whole sites, because some content cannot meet it.
Нюанс. Рівні кумулятивні: відповідати AA означає виконати всі критерії A і AA. Відповідність оцінюється для цілої сторінки і цілого процесу: якщо крок оплати недоступний, недоступний увесь сценарій.
Nuance. Levels are cumulative: meeting AA means meeting every A and AA criterion. Conformance is judged per full page and per complete process: if the payment step is inaccessible, the whole flow is.
1 . 4 . 3 Contrast (Minimum) рівень AA
| | +-- критерій успіху (перевіряється: pass / fail)
| +------ настанова 1.4 Distinguishable
+---------- принцип 1 Perceivable
A 1.1.1 Non-text Content 2.1.1 Keyboard 2.4.1 Bypass Blocks
AA 1.4.3 Contrast (Minimum) 2.4.7 Focus Visible 2.5.8 Target Size (Minimum)
AAA 1.4.6 Contrast (Enhanced) 2.4.13 Focus Appearance
1 . 4 . 3 Contrast (Minimum) level AA
| | +-- success criterion (testable: pass / fail)
| +------ guideline 1.4 Distinguishable
+---------- principle 1 Perceivable
A 1.1.1 Non-text Content 2.1.1 Keyboard 2.4.1 Bypass Blocks
AA 1.4.3 Contrast (Minimum) 2.4.7 Focus Visible 2.5.8 Target Size (Minimum)
AAA 1.4.6 Contrast (Enhanced) 2.4.13 Focus Appearance
Perceivable (сприйнятний). Інформацію можна отримати хоча б одним із чуттів. Баги: картинка-посилання без alt, відео без субтитрів, сірий текст на білому з контрастом 2.8:1, статус лише кольором.
Perceivable. Information can be received through at least one sense. Bugs: an image link without alt, a video without captions, grey text on white at a 2.8:1 contrast, status shown by colour alone.
Operable (керований). Інтерфейсом можна керувати різними способами. Баги: дропдаун відкривається лише наведенням миші, фокус застрягає в iframe з картою, каруселлю не можна керувати клавіатурою, сесія закінчується без попередження.
Operable. The interface can be operated in different ways. Bugs: a dropdown that opens only on mouse hover, focus stuck in a map iframe, a carousel that cannot be controlled from the keyboard, a session that expires without warning.
Understandable (зрозумілий). Контент і поведінка передбачувані. Баги: помилка «Invalid input» без пояснення, що саме не так, фокус на полі сам перемикає сторінку, мова сторінки не вказана, і скрінрідер читає українське англійською вимовою.
Understandable. Content and behaviour are predictable. Bugs: an «Invalid input» error with no hint of what is wrong, focusing a field switches the page on its own, the page language is missing so the screen reader reads Ukrainian with English pronunciation.
Robust (надійний). Розмітка коректно інтерпретується браузерами й допоміжними технологіями. Баги: кастомний перемикач без ролі й стану aria-checked, дублікати id, на які посилається aria-labelledby, статусне повідомлення, яке скрінрідер не оголошує.
Robust. Markup is interpreted correctly by browsers and assistive technology. Bugs: a custom toggle without a role and an aria-checked state, duplicate ids referenced by aria-labelledby, a status message the screen reader never announces.
Як використовувати. POUR — зручна рамка для експлораторної сесії: проходжу сторінку чотири рази з різними питаннями. І корисна відповідь на співбесіді: коли питають про конкретний баг, я називаю принцип і критерій.
How to use it. POUR is a handy frame for an exploratory session: I walk the page four times with different questions. It is also a strong interview answer: when asked about a specific bug, I name the principle and the criterion.
P Сприйняти: вимкни зображення і звук, увімкни 200% і сірі тони
-> чи лишилась уся інформація? (1.1.1, 1.2.2, 1.4.1, 1.4.3)
O Керувати: відклади мишу, пройди сценарій Tab / Enter / Space / Esc
-> чи все досяжне і чи видно фокус? (2.1.1, 2.1.2, 2.4.7)
U Зрозуміти: введи помилкові дані, зміни мову, дочекайся таймауту
-> чи ясно, що сталося і як виправити? (3.1.1, 3.3.1, 3.3.3)
R Надійність: відкрий дерево доступності, прослухай NVDA
-> чи є ролі, імена, стани, оголошення? (4.1.2, 4.1.3)
P Perceive: turn off images and sound, zoom to 200%, switch to greyscale
-> is all information still there? (1.1.1, 1.2.2, 1.4.1, 1.4.3)
O Operate: put the mouse away, run the flow with Tab / Enter / Space / Esc
-> is everything reachable and is focus visible? (2.1.1, 2.1.2, 2.4.7)
U Understand: enter wrong data, change language, wait for a timeout
-> is it clear what happened and how to fix it? (3.1.1, 3.3.1, 3.3.3)
R Robust: open the accessibility tree, listen with NVDA
-> are roles, names, states and announcements there? (4.1.2, 4.1.3)
Що додали. WCAG 2.2 додав 9 критеріїв, з них 6 на рівнях A і AA — саме їх зазвичай питають. Також вилучено 4.1.1 Parsing: сучасні браузери однаково виправляють невалідний HTML, тож критерій втратив сенс, а реальні наслідки (наприклад, дубльовані id у зв'язках ARIA) ловляться іншими критеріями.
What was added. WCAG 2.2 added 9 criteria, 6 of them at levels A and AA, which are the ones interviews usually ask about. It also removed 4.1.1 Parsing: modern browsers repair invalid HTML consistently, so the criterion lost its point, and the real consequences (such as duplicate ids in ARIA relationships) are caught by other criteria.
Фокус. 2.4.11 Focus Not Obscured (Minimum), AA: елемент у фокусі не може бути повністю закритий контентом автора — липкою шапкою, cookie-банером, чат-віджетом. 2.4.12 (AAA) вимагає, щоб він не був закритий навіть частково, а 2.4.13 Focus Appearance (AAA) задає мінімальний розмір і контраст індикатора.
Focus. 2.4.11 Focus Not Obscured (Minimum), AA: the focused element must not be entirely hidden by author content — a sticky header, a cookie banner, a chat widget. 2.4.12 (AAA) says it must not be hidden even partly, and 2.4.13 Focus Appearance (AAA) sets a minimum size and contrast for the indicator.
Вказівник. 2.5.7 Dragging Movements, AA: кожна дія перетягуванням має альтернативу одним натисканням (кнопки «вгору/вниз», клік по доріжці слайдера). 2.5.8 Target Size (Minimum), AA: ціль щонайменше 24×24 CSS px або достатній відступ до сусідніх цілей.
Pointer. 2.5.7 Dragging Movements, AA: every drag action has a single-pointer alternative (up/down buttons, a click on the slider track). 2.5.8 Target Size (Minimum), AA: a target is at least 24×24 CSS px or has enough spacing from neighbouring targets.
Допомога і форми. 3.2.6 Consistent Help, A: посилання на допомогу чи чат стоїть в одному й тому самому місці на всіх сторінках. 3.3.7 Redundant Entry, A: не змушувати вводити вже введені в цьому процесі дані. 3.3.8 Accessible Authentication (Minimum), AA: вхід без когнітивного тесту — дозволені менеджери паролів, вставка, magic link, passkey.
Help and forms. 3.2.6 Consistent Help, A: a help link or chat sits in the same place on every page. 3.3.7 Redundant Entry, A: do not make users retype data already entered in this process. 3.3.8 Accessible Authentication (Minimum), AA: sign-in without a cognitive test — password managers, paste, magic links and passkeys are allowed.
Помилка кандидата. Називати 2.2 «майбутньою версією» чи плутати 2.5.8 з 2.5.5 (44×44, AAA).
Candidate mistake. Calling 2.2 a «future version», or confusing 2.5.8 with 2.5.5 (44×44, AAA).
2.4.11 Focus Not Obscured (AA) Tab по сторінці з відкритим cookie-банером і
липкою шапкою: фокус ніколи не ховається повністю
2.5.7 Dragging Movements (AA) кожен drag (сортування, слайдер, карта) має
кнопки або клік як альтернативу
2.5.8 Target Size (AA) іконки, хрестики, пагінація >= 24x24 CSS px
або відстань між центрами >= 24 px
3.2.6 Consistent Help (A) «Допомога» / чат в одному місці на всіх сторінках
3.3.7 Redundant Entry (A) адреса доставки не вводиться вдруге для оплати
3.3.8 Accessible Auth (AA) пароль можна вставити, менеджер паролів працює,
немає капчі-головоломки без альтернативи
2.4.11 Focus Not Obscured (AA) Tab through the page with the cookie banner and
sticky header present: focus is never fully hidden
2.5.7 Dragging Movements (AA) every drag (sorting, slider, map) has buttons
or a click as an alternative
2.5.8 Target Size (AA) icons, close buttons, pagination >= 24x24 CSS px
or centre-to-centre spacing >= 24 px
3.2.6 Consistent Help (A) «Help» / chat in the same place on every page
3.3.7 Redundant Entry (A) delivery address is not typed again for billing
3.3.8 Accessible Auth (AA) password can be pasted, password manager works,
no puzzle captcha without an alternative
Accessible Authentication. Критерій забороняє вимагати «когнітивний тест» (запам'ятати пароль і набрати його вручну, розв'язати пазл, переписати символи з картинки), якщо немає альтернативи або допоміжного механізму. На практиці сайт проходить, якщо дозволяє менеджер паролів і вставку з буфера. Типові провали: onpaste з preventDefault на полі пароля, поле OTP, розбите на 6 інпутів, куди не вставляється код, autocomplete=off на логіні, капча-головоломка без аудіо чи іншого способу. Розпізнавання об'єктів («оберіть світлофори») на рівні AA — дозволений виняток, але не на AAA.
Accessible Authentication. The criterion forbids requiring a «cognitive function test» (remembering a password and typing it by hand, solving a puzzle, transcribing characters from an image) unless there is an alternative or a helping mechanism. In practice a site passes if it allows password managers and pasting from the clipboard. Typical failures: onpaste with preventDefault on the password field, an OTP field split into 6 inputs that refuses a pasted code, autocomplete=off on sign-in, a puzzle captcha with no audio or other route. Object recognition («select the traffic lights») is an allowed exception at AA, but not at AAA.
Redundant Entry. Якщо користувач уже ввів дані в цьому процесі, їх треба підставити або дати вибрати: чекбокс «адреса оплати збігається з адресою доставки», збережена адреса у випадному списку. Винятки: повторне введення пароля для безпеки та ситуації, коли попередні дані вже неактуальні.
Redundant Entry. If the user has already entered data in this process, it must be auto-filled or selectable: a «billing address same as delivery» checkbox, a saved address in a dropdown. Exceptions: re-entering a password for security, and cases where the earlier data is no longer valid.
Як тестую. Ручно — зберігаю логін у менеджері паролів і перевіряю автозаповнення, вставляю пароль і код, проходжу чекаут двічі. Автоматично — перевіряю атрибути autocomplete і те, що вставка не блокується.
How I test. By hand — I save the login in a password manager and check autofill, paste a password and a code, go through checkout twice. Automatically — I assert the autocomplete attributes and that paste is not blocked.
Нюанс. Критерій не забороняє паролі, він забороняє заважати інструментам, які знімають навантаження з пам'яті.
Nuance. The criterion does not ban passwords; it bans getting in the way of tools that take the load off memory.
import { test, expect } from '@playwright/test';
test('логін дозволяє вставку і менеджер паролів (WCAG 3.3.8)', async ({ page }) => {
await page.goto('/login');
const password = page.getByLabel('Пароль');
// менеджер паролів знаходить поле за autocomplete
await expect(password).toHaveAttribute('autocomplete', 'current-password');
// імітуємо вставку: подія paste не повинна бути скасована
const cancelled = await password.evaluate((el) => {
const data = new DataTransfer();
data.setData('text/plain', 'S3cret!pass');
const event = new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true });
return !el.dispatchEvent(event);
});
expect(cancelled).toBe(false);
// код з SMS має бути одним полем з one-time-code
await expect(page.getByLabel('Код підтвердження')).toHaveAttribute('autocomplete', 'one-time-code');
});
import { test, expect } from '@playwright/test';
test('sign-in allows paste and password managers (WCAG 3.3.8)', async ({ page }) => {
await page.goto('/login');
const password = page.getByLabel('Password');
// a password manager finds the field by autocomplete
await expect(password).toHaveAttribute('autocomplete', 'current-password');
// simulate a paste: the paste event must not be cancelled
const cancelled = await password.evaluate((el) => {
const data = new DataTransfer();
data.setData('text/plain', 'S3cret!pass');
const event = new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true });
return !el.dispatchEvent(event);
});
expect(cancelled).toBe(false);
// the SMS code should be a single field with one-time-code
await expect(page.getByLabel('Verification code')).toHaveAttribute('autocomplete', 'one-time-code');
});
dispatchEvent повертає false, якщо обробник викликав preventDefault, тому cancelled === true означає, що сайт блокує вставку. Токени current-password і one-time-code — стандартні значення HTML autocomplete, за якими браузер і менеджери паролів знаходять поля.dispatchEvent returns false when a handler called preventDefault, so cancelled === true means the site blocks pasting. The current-password and one-time-code tokens are standard HTML autocomplete values that browsers and password managers use to find fields.Що це. Семантичний HTML — розмітка, де тег описує зміст і призначення, а не вигляд: <button> для дії, <a href> для переходу, <nav>, <main>, <h1>–<h6>, <ul>, <table>, <label>. Браузер перетворює такі теги на вузли дерева доступності з правильною роллю, ім'ям і станом, а скрінрідер, голосове керування чи Switch Access працюють саме з цим деревом.
What it is. Semantic HTML is markup where the tag describes meaning and purpose, not looks: <button> for an action, <a href> for navigation, <nav>, <main>, <h1>–<h6>, <ul>, <table>, <label>. The browser turns these tags into accessibility tree nodes with the correct role, name and state, and a screen reader, voice control or Switch Access work with exactly that tree.
Що дає безкоштовно. <button> фокусується через Tab, спрацьовує на Enter і Space, оголошується як «кнопка», у формі відправляє її. <h2> дозволяє незрячому перейти до розділу клавішею H. <nav> стає орієнтиром (landmark). <label for> дає полю ім'я і збільшує зону кліку. Усе це без жодного рядка JavaScript чи ARIA.
What you get for free. A <button> is focusable with Tab, fires on Enter and Space, is announced as «button» and submits its form. An <h2> lets a blind user jump to the section with the H key. A <nav> becomes a landmark. A <label for> gives the field a name and enlarges the click area. All of that without a single line of JavaScript or ARIA.
Навіщо ще. Той самий зміст використовують пошукові системи, режим читання браузера, автоперекладачі і автотести: getByRole у Playwright знаходить елементи через те саме дерево.
Why else. The same meaning is used by search engines, the browser reader mode, automatic translators and automated tests: getByRole in Playwright finds elements through the same tree.
Типова помилка. «Div-суп»: <div class=title> замість заголовка, <span> з обробником кліку замість кнопки, список з <div>. Візуально сторінка ідентична, але для скрінрідера вона — суцільний текст без структури й без елементів керування. Друга помилка — вибирати тег за стилем: <h4>, бо «потрібен дрібний шрифт».
The common mistake. «Div soup»: <div class=title> instead of a heading, a <span> with a click handler instead of a button, a list made of <div>s. Visually the page is identical, but to a screen reader it is a flat run of text with no structure and no controls. The second mistake is choosing a tag for its style: an <h4> because «we need small text».
<!-- ПОГАНО: для скрінрідера це просто текст -->
<div class="header">
<div class="logo" onclick="goHome()">Shop</div>
<div class="menu"><div onclick="openCart()">Кошик</div></div>
</div>
<div class="big-text">Оформлення замовлення</div>
<!-- ДОБРЕ: ролі, фокус і клавіатура з коробки -->
<header>
<a href="/">Shop</a>
<nav aria-label="Головне меню">
<ul><li><a href="/cart">Кошик</a></li></ul>
</nav>
</header>
<main>
<h1>Оформлення замовлення</h1>
</main>
<!-- BAD: to a screen reader this is just text -->
<div class="header">
<div class="logo" onclick="goHome()">Shop</div>
<div class="menu"><div onclick="openCart()">Cart</div></div>
</div>
<div class="big-text">Checkout</div>
<!-- GOOD: roles, focus and keyboard out of the box -->
<header>
<a href="/">Shop</a>
<nav aria-label="Main menu">
<ul><li><a href="/cart">Cart</a></li></ul>
</nav>
</header>
<main>
<h1>Checkout</h1>
</main>
<header>, <nav> і <main> стають landmark-ами, посилання фокусуються і відкриваються через Enter, а <h1> з'являється у списку заголовків скрінрідера.<header>, <nav> and <main> become landmarks, links are focusable and open with Enter, and the <h1> shows up in the screen reader heading list.Проблема. <div onclick> реагує лише на мишу. Він не потрапляє в порядок Tab, не спрацьовує на Enter чи Space, не має ролі, тож скрінрідер читає його як звичайний текст або взагалі пропускає, а голосове керування («натисни Зберегти») його не знаходить. Порушуються 2.1.1 Keyboard і 4.1.2 Name, Role, Value.
The problem. A <div onclick> responds to the mouse only. It is not in the Tab order, does not fire on Enter or Space and has no role, so a screen reader reads it as plain text or skips it, and voice control («click Save») cannot find it. It fails 2.1.1 Keyboard and 4.1.2 Name, Role, Value.
Правильне виправлення. Замінити на <button type=button>. Стилі кнопки повністю скидаються через CSS, тож аргумент «дизайн» не працює. Якщо елемент веде на іншу сторінку — це <a href>, а не кнопка.
The right fix. Replace it with <button type=button>. Button styles can be fully reset in CSS, so the «design» argument does not hold. If the element takes you to another page, it is an <a href>, not a button.
Якщо тег змінити неможливо (стороння бібліотека), доведеться відтворити поведінку вручну: role=button, tabindex=0, обробник keydown для Enter і Space (Space — з preventDefault, щоб сторінка не прокручувалась), стан aria-disabled замість атрибута disabled, видимий фокус. Це чотири-п'ять речей, кожну з яких легко забути, тому я прошу нативну кнопку.
If the tag cannot change (a third-party library), the behaviour has to be rebuilt by hand: role=button, tabindex=0, a keydown handler for Enter and Space (Space with preventDefault so the page does not scroll), an aria-disabled state instead of the disabled attribute, a visible focus style. That is four or five things, each easy to forget, so I ask for a native button.
Як ловлю. Tab по сторінці: якщо елемент, який виглядає клікабельним, не отримує фокус — баг. axe правило click-events-have-key-events є лише в деяких лінтерах (eslint-plugin-jsx-a11y), сам axe на сторінці такий div не бачить, бо немає ролі. Тому ручна перевірка тут обов'язкова.
How I catch it. Tab through the page: if an element that looks clickable never gets focus, it is a bug. The click-events-have-key-events rule exists only in some linters (eslint-plugin-jsx-a11y); axe on the live page does not see such a div at all, because it has no role. So a manual check is mandatory here.
<!-- ПОГАНО: лише миша, без ролі, без фокусу -->
<div class="btn" onclick="save()">Зберегти</div>
<!-- ТЕРПИМО: відтворена поведінка, легко зламати -->
<div class="btn" role="button" tabindex="0"
onclick="save()"
onkeydown="if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); save(); }">
Зберегти
</div>
<!-- ДОБРЕ: нативна кнопка, стилі скидаються в CSS -->
<button type="button" class="btn" onclick="save()">Зберегти</button>
<!-- BAD: mouse only, no role, no focus -->
<div class="btn" onclick="save()">Save</div>
<!-- TOLERABLE: rebuilt behaviour, easy to break -->
<div class="btn" role="button" tabindex="0"
onclick="save()"
onkeydown="if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); save(); }">
Save
</div>
<!-- GOOD: native button, styles reset in CSS -->
<button type="button" class="btn" onclick="save()">Save</button>
type=button у третьому важливий усередині форми: без нього кнопка за замовчуванням має тип submit і відправить форму.type=button in the third matters inside a form: without it a button defaults to submit and sends the form.Роль. WAI-ARIA — специфікація атрибутів, які доповнюють семантику HTML для допоміжних технологій. role каже, чим є елемент: tablist, tab, dialog, alert, switch, navigation. Групи ролей: віджети (button, checkbox, slider), структура документа (heading, list, table), орієнтири (banner, main, navigation), live-регіони (status, alert, log) і вікна (dialog, alertdialog).
Role. WAI-ARIA is a specification of attributes that add semantics to HTML for assistive technology. role says what the element is: tablist, tab, dialog, alert, switch, navigation. Role groups: widgets (button, checkbox, slider), document structure (heading, list, table), landmarks (banner, main, navigation), live regions (status, alert, log) and windows (dialog, alertdialog).
Стани і властивості. Стан — те, що змінюється під час взаємодії: aria-expanded, aria-checked, aria-selected, aria-pressed, aria-invalid, aria-current. Властивість — відносно стала характеристика або зв'язок: aria-label, aria-labelledby, aria-describedby, aria-controls, aria-required, aria-haspopup. Межа умовна, але для тестування важливо: стани мусять оновлюватися скриптом при кожній дії.
States and properties. A state is something that changes during interaction: aria-expanded, aria-checked, aria-selected, aria-pressed, aria-invalid, aria-current. A property is a relatively stable characteristic or relationship: aria-label, aria-labelledby, aria-describedby, aria-controls, aria-required, aria-haspopup. The line is fuzzy, but for testing it matters: states must be updated by script on every action.
Головне обмеження. ARIA змінює лише те, що бачить дерево доступності. Вона не додає фокус, обробку клавіш чи поведінку. role=tab обіцяє користувачу скрінрідера, що стрілки перемикатимуть вкладки, — і якщо розробник цього не реалізував, стало гірше, ніж без ролі.
The key limitation. ARIA only changes what the accessibility tree sees. It adds no focus, no key handling and no behaviour. role=tab promises a screen reader user that arrow keys will switch tabs — and if the developer did not build that, it is worse than no role at all.
Як тестую віджет. Беру патерн з ARIA Authoring Practices Guide (APG), звіряю ролі й атрибути, а потім клавіатурну модель: для вкладок це стрілки між tab, Tab — у панель, Home/End. Окремо перевіряю, що aria-selected перемикається разом з візуальним станом.
How I test a widget. I take the pattern from the ARIA Authoring Practices Guide (APG), compare roles and attributes, and then the keyboard model: for tabs that is arrow keys between tabs, Tab into the panel, Home/End. Separately I check that aria-selected switches together with the visual state.
<div role="tablist" aria-label="Налаштування акаунта">
<!-- роль tab, стан aria-selected, властивість aria-controls -->
<button role="tab" id="tab-profile" aria-selected="true"
aria-controls="panel-profile">Профіль</button>
<!-- неактивні вкладки поза Tab-порядком: між ними ходять стрілками -->
<button role="tab" id="tab-security" aria-selected="false"
aria-controls="panel-security" tabindex="-1">Безпека</button>
</div>
<div role="tabpanel" id="panel-profile" aria-labelledby="tab-profile">...</div>
<div role="tabpanel" id="panel-security" aria-labelledby="tab-security" hidden>...</div>
<div role="tablist" aria-label="Account settings">
<!-- role tab, state aria-selected, property aria-controls -->
<button role="tab" id="tab-profile" aria-selected="true"
aria-controls="panel-profile">Profile</button>
<!-- inactive tabs are out of the Tab order: arrows move between them -->
<button role="tab" id="tab-security" aria-selected="false"
aria-controls="panel-security" tabindex="-1">Security</button>
</div>
<div role="tabpanel" id="panel-profile" aria-labelledby="tab-profile">...</div>
<div role="tabpanel" id="panel-security" aria-labelledby="tab-security" hidden>...</div>
tabindex=-1 на неактивних вкладках називається roving tabindex: Tab заходить лише в активну вкладку, а між іншими переміщують стрілки, які треба реалізувати в JavaScript.tabindex=-1 on inactive tabs is the roving tabindex technique: Tab enters only the active tab, and arrow keys move between the others, which has to be built in JavaScript.Перше правило. «Якщо можна використати нативний HTML-елемент чи атрибут з потрібною семантикою і поведінкою — використовуй його, а не перепрофільовуй інший елемент, додаючи роль, стан чи властивість». Решта правил W3C: не змінювати нативну семантику без потреби; усі інтерактивні ARIA-елементи мають працювати з клавіатури; не ставити role=presentation чи aria-hidden=true на фокусований елемент; кожен інтерактивний елемент має доступне ім'я.
The first rule. «If you can use a native HTML element or attribute with the semantics and behaviour you require already built in, instead of re-purposing an element and adding an ARIA role, state or property, then do so.» The other W3C rules: do not change native semantics unless you must; all interactive ARIA controls must be keyboard operable; do not put role=presentation or aria-hidden=true on a focusable element; every interactive element has an accessible name.
Чому «краще без ARIA, ніж з поганою ARIA». WebAIM Million щороку показує, що на домашніх сторінках з ARIA в середньому більше виявлених помилок, ніж без неї. ARIA — це обіцянка, і невиконана обіцянка гірша за її відсутність.
Why «no ARIA is better than bad ARIA». Every year the WebAIM Million shows that home pages with ARIA have more detected errors on average than those without. ARIA is a promise, and a broken promise is worse than none.
Топ помилок з аудитів. aria-hidden=true на контейнері з кнопкою: фокус туди потрапляє, а скрінрідер мовчить. aria-expanded, що не оновлюється після відкриття меню. role=menu для звичайної навігації сайту — користувач скрінрідера чекає стрілок, а там посилання. aria-label на <div> без ролі — для generic-елементів він не підтримується. aria-labelledby з id, якого немає. role=button на <a href> — Space не спрацює. aria-disabled без реального блокування дії.
Top audit findings. aria-hidden=true on a container with a button: focus goes in and the screen reader stays silent. aria-expanded that never updates after the menu opens. role=menu for plain site navigation — a screen reader user expects arrow keys and finds links. aria-label on a <div> with no role — it is not supported on generic elements. aria-labelledby pointing to an id that does not exist. role=button on an <a href> — Space will not fire. aria-disabled without actually blocking the action.
Як це подати команді. Не «видаліть ARIA», а «замініть на нативний елемент, де можна, і доведіть до APG-патерну, де не можна». Лінт і axe ловлять частину: aria-hidden-focus, aria-valid-attr-value, aria-allowed-attr. Логіку станів перевіряю руками й тестами.
How to pitch it to the team. Not «delete ARIA», but «replace with a native element where you can, and bring it up to the APG pattern where you cannot». Linting and axe catch part of it: aria-hidden-focus, aria-valid-attr-value, aria-allowed-attr. State logic I check by hand and with tests.
<!-- 1. Надлишкова роль: нативна кнопка вже має role=button -->
<button role="button">Купити</button> <!-- просто <button> -->
<!-- 2. Прихований, але фокусований контент -->
<div aria-hidden="true"><button>Закрити</button></div>
<!-- виправлення: inert або прибрати aria-hidden -->
<div inert><button>Закрити</button></div>
<!-- 3. Стан не синхронізовано з інтерфейсом -->
<button aria-expanded="false">Меню</button> <!-- меню вже відкрите! -->
<!-- 4. aria-label на generic div ігнорується -->
<div aria-label="Ціна">250 грн</div>
<!-- виправлення: видимий текст або правильна структура -->
<p>Ціна: 250 грн</p>
<!-- 1. Redundant role: a native button already has role=button -->
<button role="button">Buy</button> <!-- just <button> -->
<!-- 2. Hidden but focusable content -->
<div aria-hidden="true"><button>Close</button></div>
<!-- fix: use inert or remove aria-hidden -->
<div inert><button>Close</button></div>
<!-- 3. State out of sync with the UI -->
<button aria-expanded="false">Menu</button> <!-- the menu is already open! -->
<!-- 4. aria-label on a generic div is ignored -->
<div aria-label="Price">$25</div>
<!-- fix: visible text or proper structure -->
<p>Price: $25</p>
inert робить піддерево нефокусованим, некліканим і прихованим від дерева доступності одночасно, тому він безпечніший за aria-hidden для фонового контенту. Приклад 3 axe не зловить: значення атрибута валідне, а невідповідність видно лише після дії.inert attribute makes a subtree unfocusable, unclickable and hidden from the accessibility tree all at once, which is why it is safer than aria-hidden for background content. Example 3 is invisible to axe: the attribute value is valid, and the mismatch only shows after an action.Що це. Accessible name — текст, яким допоміжні технології називають елемент: скрінрідер читає «Пошук, кнопка», голосове керування реагує на «натисни Пошук», а Playwright шукає getByRole('button', { name: 'Пошук' }). Інтерактивний елемент без імені — провал 4.1.2, бо користувач чує лише «кнопка».
What it is. The accessible name is the text assistive technology uses to refer to an element: a screen reader says «Search, button», voice control responds to «click Search», and Playwright looks for getByRole('button', { name: 'Search' }). An interactive element with no name fails 4.1.2, because the user only hears «button».
Порядок обчислення (спрощено за Accessible Name and Description Computation). 1) aria-labelledby — перемагає все, може посилатися на кілька id (текст склеюється в їхньому порядку) і навіть на прихований елемент. 2) aria-label. 3) Нативне джерело мови розмітки: <label> для поля, alt для зображення, <caption> для таблиці, <legend> для fieldset, значення кнопки input type=submit. 4) Вміст елемента — для ролей, що дозволяють ім'я з вмісту (button, link, heading, cell, option). 5) Останній резерв: атрибут title, а для полів — текст-підказка всередині поля. Приховані через display:none чи aria-hidden частини вмісту в ім'я не входять.
Computation order (simplified from Accessible Name and Description Computation). 1) aria-labelledby — beats everything, can reference several ids (texts joined in their order) and even a hidden element. 2) aria-label. 3) The native host-language source: <label> for a field, alt for an image, <caption> for a table, <legend> for a fieldset, the value of an input type=submit. 4) The element's contents — for roles that allow a name from content (button, link, heading, cell, option). 5) Last resort: the title attribute, and for fields the hint text shown inside the field. Parts of the content hidden with display:none or aria-hidden do not enter the name.
Практичні наслідки. aria-label повністю замінює видимий текст кнопки, тож якщо вони розходяться, людина з голосовим керуванням не зможе натиснути кнопку. Ім'я з title не видно на мобільних і клавіатурою, тому це слабке джерело.
Practical consequences. aria-label completely replaces a button's visible text, so if they differ, a voice control user cannot press the button. A name from title is invisible on mobile and to keyboard users, so it is a weak source.
Як перевіряю. Панель Accessibility у DevTools показує обчислене ім'я і з чого воно взялося; у Playwright — toHaveAccessibleName.
How I check. The Accessibility pane in DevTools shows the computed name and where it came from; in Playwright it is toHaveAccessibleName.
<!-- 1. aria-labelledby: ім'я «Видалити Звіт за березень» -->
<button id="del" aria-labelledby="del row-7">Видалити</button>
<span id="row-7">Звіт за березень</span>
<!-- 2. aria-label перекриває вміст: ім'я «Закрити», а не «X» -->
<button aria-label="Закрити">X</button>
<!-- 3. нативний label: ім'я «Email» -->
<label for="email">Email</label> <input id="email" type="email">
<!-- 4. вміст: ім'я «Кошик 3», іконка прихована -->
<a href="/cart"><svg aria-hidden="true">...</svg>Кошик <span>3</span></a>
<!-- 5. лише title: ім'я є, але зряча клавіатурна людина його не бачить -->
<button title="Друк"><svg aria-hidden="true">...</svg></button>
<!-- 1. aria-labelledby: name «Delete March report» -->
<button id="del" aria-labelledby="del row-7">Delete</button>
<span id="row-7">March report</span>
<!-- 2. aria-label overrides content: name «Close», not «X» -->
<button aria-label="Close">X</button>
<!-- 3. native label: name «Email» -->
<label for="email">Email</label> <input id="email" type="email">
<!-- 4. content: name «Cart 3», icon hidden -->
<a href="/cart"><svg aria-hidden="true">...</svg>Cart <span>3</span></a>
<!-- 5. title only: there is a name, but a sighted keyboard user cannot see it -->
<button title="Print"><svg aria-hidden="true">...</svg></button>
Порядок вибору. 1) Видимий текст, пов'язаний нативно (<label>, текст кнопки) — працює для всіх: зрячих, скрінрідера, голосу, автоперекладу. 2) aria-labelledby — коли видимий текст уже є на сторінці, але HTML не дає зв'язати його нативно (заголовок діалогу, назва рядка таблиці). 3) aria-label — коли видимого тексту немає взагалі: кнопка-іконка, пошук з лупою.
Order of choice. 1) Visible text linked natively (<label>, button text) — works for everyone: sighted users, screen readers, voice, automatic translation. 2) aria-labelledby — when the visible text is already on the page but HTML cannot link it natively (a dialog heading, a table row name). 3) aria-label — when there is no visible text at all: an icon button, a search field with a magnifier.
Мінуси aria-label. Його не видно, тому зрячі люди з когнітивними труднощами не отримують підказки. Деякі інструменти автоперекладу сторінок не перекладають значення атрибутів, і франкомовний користувач почує англійське ім'я. Його легко забути оновити, коли змінюється видимий текст.
Downsides of aria-label. It is invisible, so sighted people with cognitive difficulties get no cue. Some page translation tools do not translate attribute values, so a French-speaking user hears an English name. It is easy to forget to update when the visible text changes.
Label in Name, 2.5.3 (A). Якщо в елемента є видимий текст, доступне ім'я повинне містити цей текст, найкраще — починатися з нього. Кнопка з написом «Надіслати» і aria-label=Відправити форму зворотного зв'язку провалює критерій: користувач Voice Control каже «натисни Надіслати» — і нічого не відбувається.
Label in Name, 2.5.3 (A). If an element has visible text, its accessible name must contain that text and ideally start with it. A button that reads «Send» with aria-label=Submit the feedback form fails the criterion: a Voice Control user says «click Send» — and nothing happens.
Як тестую. Порівнюю видимий текст з обчисленим іменем (DevTools або тест), а на macOS вмикаю Voice Control і кажу видимий текст уголос. Для полів перевіряю, що клік по label ставить фокус у поле — це доказ нативного зв'язку.
How I test. I compare the visible text with the computed name (DevTools or a test), and on macOS I turn on Voice Control and say the visible text out loud. For fields I check that clicking the label moves focus into the field — proof of a native link.
<!-- ПРОВАЛ 2.5.3: видно «Надіслати», ім'я «Відправити форму» -->
<button aria-label="Відправити форму">Надіслати</button>
<!-- ОК: ім'я починається з видимого тексту -->
<button aria-describedby="send-hint">Надіслати</button>
<p id="send-hint">Відповімо протягом доби</p>
<!-- FAILS 2.5.3: shows «Send», name is «Submit form» -->
<button aria-label="Submit form">Send</button>
<!-- OK: the name starts with the visible text -->
<button aria-describedby="send-hint">Send</button>
<p id="send-hint">We reply within a day</p>
// перевірка: ім'я кнопки містить її видимий текст
const button = page.locator('form#feedback button[type=submit]');
const visible = (await button.innerText()).trim();
await expect(button).toHaveAccessibleName(new RegExp('^' + visible));
// check: the button name contains its visible text
const button = page.locator('form#feedback button[type=submit]');
const visible = (await button.innerText()).trim();
await expect(button).toHaveAccessibleName(new RegExp('^' + visible));
aria-describedby, а не через заміну імені. У тесті innerText бере саме видимий текст, а регулярний вираз з ^ перевіряє, що ім'я з нього починається. Для тексту зі спецсимволами регекс треба екранувати.aria-describedby, not by replacing the name. In the test innerText takes the visible text, and the regular expression with ^ checks that the name starts with it. Text with special characters needs escaping before building the regex.Що це. Accessible description — додатковий текст, який пояснює елемент, але не називає його. Ім'я відповідає на «що це?», опис — на «що ще треба знати?». Скрінрідер зазвичай читає опис після імені, ролі й стану, іноді з паузою, і користувач може налаштувати, чи чути описи взагалі.
What it is. The accessible description is extra text that explains an element without naming it. The name answers «what is this?», the description answers «what else do I need to know?». A screen reader usually reads the description after the name, role and state, sometimes after a pause, and the user can configure whether descriptions are read at all.
Джерела. Основне — aria-describedby з одним або кількома id (текст склеюється, прихований елемент теж працює). Далі aria-description з рядком (новіший атрибут, підтримка неповна) і title, якщо він не пішов на ім'я.
Sources. The main one is aria-describedby with one or more ids (texts are joined, a hidden element works too). Then aria-description with a string (a newer attribute, support is incomplete) and title if it was not used for the name.
Де потрібен. Вимоги до пароля біля поля, текст помилки валідації, підказка про формат дати, попередження біля кнопки «Видалити акаунт», пояснення в діалозі підтвердження. Помилка форми через aria-describedby разом з aria-invalid=true — стандартний патерн для 3.3.1.
Where it is needed. Password requirements next to a field, a validation error message, a date format hint, a warning next to a «Delete account» button, an explanation in a confirmation dialog. A form error linked via aria-describedby together with aria-invalid=true is the standard pattern for 3.3.1.
Типові помилки. Засунути все в ім'я: aria-label=Пароль мінімум 12 символів з цифрою — довге ім'я читається при кожному переході і ламає getByRole. Посилання на id, якого вже немає після перемальовування компонента. Опис, що дублює ім'я, — користувач чує те саме двічі.
Common mistakes. Stuffing everything into the name: aria-label=Password at least 12 characters with a digit — a long name is read on every visit and breaks getByRole. Pointing to an id that no longer exists after the component re-renders. A description that repeats the name, so the user hears the same thing twice.
Перевірка. DevTools показує Description у панелі Accessibility. У Playwright є toHaveAccessibleDescription.
Checking. DevTools shows Description in the Accessibility pane. Playwright has toHaveAccessibleDescription.
<label for="pwd">Новий пароль</label>
<input id="pwd" type="password" autocomplete="new-password"
aria-describedby="pwd-rules pwd-error" aria-invalid="true">
<p id="pwd-rules">Щонайменше 12 символів, одна цифра.</p>
<p id="pwd-error">Пароль закороткий: 8 із 12 символів.</p>
<!-- NVDA: «Новий пароль, поле редагування, пароль, недійсні дані,
Щонайменше 12 символів, одна цифра. Пароль закороткий: 8 із 12 символів.» -->
<label for="pwd">New password</label>
<input id="pwd" type="password" autocomplete="new-password"
aria-describedby="pwd-rules pwd-error" aria-invalid="true">
<p id="pwd-rules">At least 12 characters, one digit.</p>
<p id="pwd-error">Password too short: 8 of 12 characters.</p>
<!-- NVDA: «New password, edit, protected, invalid entry,
At least 12 characters, one digit. Password too short: 8 of 12 characters.» -->
const pwd = page.getByRole('textbox', { name: 'Новий пароль' });
await expect(pwd).toHaveAccessibleDescription(/Щонайменше 12 символів/);
await expect(pwd).toHaveAttribute('aria-invalid', 'true');
const pwd = page.getByRole('textbox', { name: 'New password' });
await expect(pwd).toHaveAccessibleDescription(/At least 12 characters/);
await expect(pwd).toHaveAttribute('aria-invalid', 'true');
aria-describedby приймає список id через пробіл, тож правила і помилка складаються в один опис. Ім'я лишається коротким, тому локатор getByRole з ним стабільний. Точне формулювання озвучки залежить від скрінрідера і мови. Поле типу password у Playwright має роль textbox.aria-describedby takes a space-separated list of ids, so the rules and the error combine into one description. The name stays short, so a getByRole locator using it stays stable. The exact announcement wording depends on the screen reader and language. A password field has the textbox role in Playwright.Проблема. Кнопка з самою іконкою (хрестик, кошик, серце, три крапки) без імені оголошується як «кнопка» або «кнопка, графіка». Користувач скрінрідера не знає, що вона зробить, голосове керування не має слова, яке сказати. Порушення 4.1.2, а для кнопки-зображення ще й 1.1.1. axe ловить це правилом button-name, для посилань — link-name.
The problem. A button with only an icon (a cross, a bin, a heart, three dots) and no name is announced as «button» or «button, graphic». A screen reader user does not know what it will do, and voice control has no word to say. It fails 4.1.2, and for an image button 1.1.1 as well. axe catches it with the button-name rule, and link-name for links.
Способи дати ім'я. 1) aria-label на кнопці і aria-hidden=true на SVG, щоб іконка не додавала шуму. 2) Візуально прихований текст усередині кнопки (CSS-клас на кшталт visually-hidden з clip і розміром 1px) — текст читається і перекладається автоперекладом. 3) Для <img> всередині кнопки — alt, що описує дію, а не картинку: «Видалити», не «сміттєвий бак».
Ways to give it a name. 1) aria-label on the button and aria-hidden=true on the SVG so the icon adds no noise. 2) Visually hidden text inside the button (a CSS class such as visually-hidden with clip and 1px size) — the text is read and translated by automatic translation. 3) For an <img> inside the button — an alt describing the action, not the picture: «Delete», not «rubbish bin».
Що ще перевірити. Ім'я описує дію і контекст: у списку з двадцяти «Видалити» краще «Видалити Звіт за березень». Стан перемикача передається aria-pressed («Додати в обране», натиснуто), а не зміною імені. Розмір цілі щонайменше 24×24 CSS px (2.5.8). Іконка має контраст 3:1 з фоном (1.4.11). Підказка-тултіп при наведенні корисна зрячим, але вона має з'являтися й при фокусі клавіатури.
What else to check. The name describes the action and context: in a list of twenty «Delete» buttons, «Delete March report» is better. A toggle state goes into aria-pressed («Add to favourites», pressed), not into a changing name. Target size is at least 24×24 CSS px (2.5.8). The icon has 3:1 contrast with the background (1.4.11). A hover tooltip helps sighted users, but it must also appear on keyboard focus.
Помилка. Покладатися на title: на тачскрінах і для клавіатури він не показується, а деякі скрінрідери його не читають.
The mistake. Relying on title: it does not show on touch screens or for the keyboard, and some screen readers do not read it.
<!-- ПОГАНО: «кнопка», і все -->
<button class="icon-btn"><svg viewBox="0 0 24 24">...</svg></button>
<!-- ДОБРЕ: ім'я з дією, іконка прихована, стан перемикача -->
<button type="button" aria-label="Закрити сповіщення">
<svg aria-hidden="true" focusable="false" viewBox="0 0 24 24">...</svg>
</button>
<button type="button" aria-pressed="false">
<svg aria-hidden="true" viewBox="0 0 24 24">...</svg>
<span class="visually-hidden">Додати в обране</span>
</button>
<!-- BAD: «button», and that is all -->
<button class="icon-btn"><svg viewBox="0 0 24 24">...</svg></button>
<!-- GOOD: name with the action, icon hidden, toggle state -->
<button type="button" aria-label="Close notification">
<svg aria-hidden="true" focusable="false" viewBox="0 0 24 24">...</svg>
</button>
<button type="button" aria-pressed="false">
<svg aria-hidden="true" viewBox="0 0 24 24">...</svg>
<span class="visually-hidden">Add to favourites</span>
</button>
test('кнопка обраного має ім\'я і стан', async ({ page }) => {
await page.goto('/product/42');
const fav = page.getByRole('button', { name: 'Додати в обране' });
await expect(fav).toHaveAttribute('aria-pressed', 'false');
await fav.click();
await expect(fav).toHaveAttribute('aria-pressed', 'true');
});
test('favourite button has a name and a state', async ({ page }) => {
await page.goto('/product/42');
const fav = page.getByRole('button', { name: 'Add to favourites' });
await expect(fav).toHaveAttribute('aria-pressed', 'false');
await fav.click();
await expect(fav).toHaveAttribute('aria-pressed', 'true');
});
focusable=false — давній обхід для Internet Explorer, де SVG потрапляв у Tab-порядок; сучасним браузерам він не потрібен, але не шкодить. Тест знаходить кнопку саме за ім'ям: якщо ім'я зникне, локатор не знайде елемент і тест впаде.focusable=false is an old workaround for Internet Explorer, where SVGs landed in the Tab order; modern browsers do not need it, but it does no harm. The test finds the button by its name: if the name disappears, the locator finds nothing and the test fails.Що це. Дерево доступності — паралельне DOM дерево, яке браузер будує для допоміжних технологій: лише значущі вузли з роллю, ім'ям, описом, станом і значенням. Декоративні <div> туди не потрапляють, а aria-hidden вирізає піддерево. Скрінрідер бачить саме його, тому це найточніший спосіб зрозуміти, що «почує» користувач, без запуску NVDA.
What it is. The accessibility tree is a structure parallel to the DOM that the browser builds for assistive technology: only meaningful nodes with a role, name, description, state and value. Decorative <div>s do not make it in, and aria-hidden cuts out a subtree. This is exactly what a screen reader sees, so it is the most accurate way to know what the user will «hear» without launching NVDA.
Інструменти. Chrome DevTools → Elements → вкладка Accessibility: обчислені Name, Role, Description і джерело кожного значення; перемикач повного дерева сторінки показує все дерево замість DOM. Firefox Accessibility Inspector має режим «Check for issues» (контраст, клавіатура, текстові альтернативи) і симуляцію порушень кольоросприйняття. Safari на macOS показує роль і ім'я в Web Inspector.
Tools. Chrome DevTools → Elements → Accessibility tab: the computed Name, Role, Description and the source of each value; the full-page tree toggle replaces the DOM view with the whole tree. The Firefox Accessibility Inspector has «Check for issues» (contrast, keyboard, text alternatives) and colour vision deficiency simulation. Safari on macOS shows role and name in Web Inspector.
У тестах. Playwright має locator.ariaSnapshot() і toMatchAriaSnapshot (з версії 1.49): YAML-подібний знімок ролей, імен і атрибутів на кшталт level, checked, expanded. Такий тест ловить регресії семантики, які не видно на скриншоті: кнопка втратила ім'я, заголовок став <div>, з'явився зайвий landmark.
In tests. Playwright has locator.ariaSnapshot() and toMatchAriaSnapshot (since 1.49): a YAML-like snapshot of roles, names and attributes such as level, checked, expanded. Such a test catches semantic regressions that a screenshot cannot: a button lost its name, a heading became a <div>, an extra landmark appeared.
Обережно. Знімок усієї сторінки ламається від будь-якої зміни тексту. Фіксуй невеликі стабільні частини — навігацію, форму, діалог — і використовуй регулярні вирази для динамічних значень.
Careful. A snapshot of the whole page breaks on any text change. Pin small, stable parts — navigation, a form, a dialog — and use regular expressions for dynamic values.
test('семантика форми входу не регресує', async ({ page }) => {
await page.goto('/login');
await expect(page.getByRole('main')).toMatchAriaSnapshot(
'- heading "Вхід" [level=1]\n' +
'- textbox "Email"\n' +
'- textbox "Пароль"\n' +
'- checkbox "Запам\'ятати мене" [checked=false]\n' +
'- button "Увійти"\n' +
'- link "Забули пароль?"'
);
});
test('sign-in form semantics do not regress', async ({ page }) => {
await page.goto('/login');
await expect(page.getByRole('main')).toMatchAriaSnapshot(
'- heading "Sign in" [level=1]\n' +
'- textbox "Email"\n' +
'- textbox "Password"\n' +
'- checkbox "Remember me" [checked=false]\n' +
'- button "Sign in"\n' +
'- link "Forgot password?"'
);
});
npx playwright codegen або з ariaSnapshot().npx playwright codegen or from ariaSnapshot().Навіщо. Заголовки — головний спосіб навігації для незрячих: за опитуваннями WebAIM серед користувачів скрінрідерів більшість спершу шукає інформацію саме через заголовки. У NVDA клавіша H стрибає до наступного заголовка, цифри 1–6 — до заголовка певного рівня, а список елементів показує зміст сторінки. Для зрячих заголовки теж важливі: вони дозволяють сканувати сторінку очима.
Why. Headings are the main way blind users navigate: in WebAIM surveys most screen reader users say they look for information through headings first. In NVDA the H key jumps to the next heading, digits 1–6 jump to a heading of that level, and the elements list shows a table of contents. Headings matter for sighted users too: they let you scan the page.
Правила. Один <h1> з назвою сторінки чи головного вмісту. Рівні відображають вкладеність: під <h2> розділу йдуть <h3> підрозділів. Пропуск рівня (<h2> → <h4>) — не формальне порушення WCAG, але плутає користувача: він думає, що пропустив розділ. Заголовок має описувати, що нижче (2.4.6 Headings and Labels, AA), а все, що візуально виглядає як заголовок, має бути розмічене як заголовок (1.3.1 Info and Relationships, A).
Rules. One <h1> with the page or main content title. Levels reflect nesting: <h3> subsections go under a section's <h2>. Skipping a level (<h2> → <h4>) is not a formal WCAG failure, but it confuses users: they think they missed a section. A heading must describe what follows (2.4.6 Headings and Labels, AA), and anything that looks like a heading must be marked up as one (1.3.1 Info and Relationships, A).
Типові баги. Жирний <div> замість заголовка. <h3>, обраний заради розміру шрифту. Порожній заголовок з іконкою. Заголовки в картках товарів, які перетворюють сторінку каталогу на 50 <h2> без структури. Модалка без заголовка.
Typical bugs. A bold <div> instead of a heading. An <h3> picked for its font size. An empty heading holding an icon. Headings inside product tiles that turn a catalogue page into 50 <h2>s with no structure. A modal without a heading.
Як перевірити. Розширення HeadingsMap чи WAVE (вкладка Structure) показують дерево заголовків. Швидко — сніпет у консолі. У NVDA — Insert+F7, список заголовків. axe має правила heading-order і page-has-heading-one, але вони в категорії best-practice, а не WCAG-тегах.
How to check. The HeadingsMap extension or WAVE (Structure tab) show the heading tree. For a quick look, a console snippet. In NVDA, Insert+F7 opens the heading list. axe has the heading-order and page-has-heading-one rules, but they are in the best-practice category rather than WCAG tags.
// вставити в консоль DevTools на сторінці, що тестується
let prev = 0;
document.querySelectorAll('h1, h2, h3, h4, h5, h6, [role=heading]').forEach((h) => {
const level = Number(h.getAttribute('aria-level') || h.tagName.slice(1));
const text = h.textContent.trim() || '(ПОРОЖНІЙ ЗАГОЛОВОК)';
const skip = prev && level > prev + 1 ? ' <-- пропуск рівня' : '';
console.log(' '.repeat(level - 1) + 'h' + level + ' ' + text + skip);
prev = level;
});
// paste into the DevTools console on the page under test
let prev = 0;
document.querySelectorAll('h1, h2, h3, h4, h5, h6, [role=heading]').forEach((h) => {
const level = Number(h.getAttribute('aria-level') || h.tagName.slice(1));
const text = h.textContent.trim() || '(EMPTY HEADING)';
const skip = prev && level > prev + 1 ? ' <-- skipped level' : '';
console.log(' '.repeat(level - 1) + 'h' + level + ' ' + text + skip);
prev = level;
});
role=heading з aria-level, друкує їх з відступом за рівнем і позначає стрибок більше ніж на один рівень. Він не бачить візуальних «заголовків», зроблених з <div>, — їх знаходить лише людина, порівнюючи вивід з екраном.role=heading with aria-level, prints them indented by level and flags a jump of more than one level. It cannot see visual «headings» made of <div>s — only a person comparing the output with the screen finds those.Що це. Landmark — область сторінки з роллю-орієнтиром, до якої можна перестрибнути одною клавішею: banner (шапка), navigation, main, complementary (бічна панель), contentinfo (футер), search, form і region (останні два — лише якщо мають ім'я). HTML5 дає їх нативно: <header> верхнього рівня → banner, <nav> → navigation, <main> → main, <aside> → complementary, <footer> верхнього рівня → contentinfo, <search> → search.
What it is. A landmark is a page area with a signpost role that you can jump to with a single key: banner (header), navigation, main, complementary (sidebar), contentinfo (footer), search, form and region (the last two only when they have a name). HTML5 provides them natively: a top-level <header> → banner, <nav> → navigation, <main> → main, <aside> → complementary, a top-level <footer> → contentinfo, <search> → search.
Як ними користуються. У NVDA клавіша D переходить до наступного орієнтира, у JAWS — R, у VoiceOver орієнтири є в ротор-меню. Так людина пропускає меню з сорока посилань і одразу потрапляє в основний вміст, як зрячий користувач, що просто дивиться в центр екрана.
How they are used. In NVDA the D key moves to the next landmark, in JAWS it is R, and in VoiceOver landmarks are in the rotor. That way a person skips a forty-link menu and lands straight in the main content, like a sighted user who simply looks at the middle of the screen.
Правила для тесту. Увесь контент усередині якогось орієнтира (axe region, best-practice). Рівно один main. Якщо орієнтирів одного типу кілька — у кожного унікальне ім'я: <nav aria-label=Головне> і <nav aria-label=Хлібні крихти>, причому слово «навігація» в ім'я не пишуть, скрінрідер додає роль сам. <header> всередині <article> не стає banner — це нормально.
Rules to test. All content sits inside some landmark (axe region, best-practice). Exactly one main. When there are several landmarks of one type, each has a unique name: <nav aria-label=Main> and <nav aria-label=Breadcrumbs> — without the word «navigation» in the name, since the screen reader adds the role itself. A <header> inside an <article> does not become a banner, and that is fine.
Помилки. Кожен блок як <section> з aria-label — десятки region, орієнтири втрачають цінність. Модальне вікно поза main без ролі dialog. Банер cookie без орієнтира, який неможливо знайти.
Mistakes. Every block as a <section> with an aria-label — dozens of regions, and landmarks lose their value. A modal outside main without a dialog role. A cookie banner outside any landmark that nobody can find.
<header> <!-- banner -->
<a href="/">Shop</a>
<nav aria-label="Головне">...</nav> <!-- navigation «Головне» -->
<search><form>...</form></search> <!-- search -->
</header>
<nav aria-label="Хлібні крихти">...</nav> <!-- друга navigation, інше ім'я -->
<main id="main"> <!-- main, рівно один -->
<h1>Ноутбуки</h1>
<section aria-labelledby="filters-h"> <!-- region, бо має ім'я -->
<h2 id="filters-h">Фільтри</h2>
</section>
</main>
<aside>...</aside> <!-- complementary -->
<footer>...</footer> <!-- contentinfo -->
<header> <!-- banner -->
<a href="/">Shop</a>
<nav aria-label="Main">...</nav> <!-- navigation «Main» -->
<search><form>...</form></search> <!-- search -->
</header>
<nav aria-label="Breadcrumbs">...</nav> <!-- second navigation, different name -->
<main id="main"> <!-- main, exactly one -->
<h1>Laptops</h1>
<section aria-labelledby="filters-h"> <!-- region, because it has a name -->
<h2 id="filters-h">Filters</h2>
</section>
</main>
<aside>...</aside> <!-- complementary -->
<footer>...</footer> <!-- contentinfo -->
<search> з'явився в HTML нещодавно; у старіших браузерах той самий результат дає <form role=search>. <section> без імені лишається звичайним generic-блоком і орієнтиром не стає.<search> element is a recent HTML addition; in older browsers <form role=search> gives the same result. A <section> with no name stays a plain generic block and does not become a landmark.Навіщо. Скрінрідер обирає голос і правила вимови за мовою. Якщо в <html> немає lang або там en на українській сторінці, NVDA прочитає «Оформлення замовлення» англійським синтезатором, і вийде нерозбірлива каша. Також від мови залежать переклад браузера, перенесення слів (hyphens), лапки в CSS, перевірка правопису і шрифти для деяких мов.
Why. A screen reader picks the voice and pronunciation rules by language. If <html> has no lang, or says en on a Ukrainian page, NVDA reads Ukrainian text with an English synthesiser and the result is gibberish. Language also drives browser translation, hyphenation (hyphens), CSS quotes, spell checking and font choice for some scripts.
Критерії. 3.1.1 Language of Page (A): основна мова вказана в <html lang> коректним кодом BCP 47 (uk, en, en-GB, pt-BR). 3.1.2 Language of Parts (AA): фрагменти іншою мовою позначені власним lang — англійська цитата на українській сторінці, назва мови в перемикачі мов («English» з lang=en, «Українська» з lang=uk). Винятки: власні назви, технічні терміни й слова, що стали частиною мови.
Criteria. 3.1.1 Language of Page (A): the main language is set in <html lang> with a valid BCP 47 code (uk, en, en-GB, pt-BR). 3.1.2 Language of Parts (AA): passages in another language get their own lang — an English quote on a Ukrainian page, language names in a language switcher («English» with lang=en, «Українська» with lang=uk). Exceptions: proper names, technical terms and words that have become part of the language.
Для багатомовних продуктів. При перемиканні мови в SPA треба оновити document.documentElement.lang, а не лише тексти, — частий баг. Невалідний код (ua замість uk — ua це код країни, а не мови) axe ловить правилом valid-lang, відсутній — html-has-lang.
For multilingual products. When the language switches in an SPA, document.documentElement.lang must be updated, not just the texts — a frequent bug. An invalid code (ua instead of uk: ua is a country code, not a language code) is caught by the axe valid-lang rule, a missing one by html-has-lang.
Як перевірити. axe для наявності й валідності, а правильність значення — вручну: перемкни мову інтерфейсу і подивись атрибут, прослухай абзац у NVDA.
How to check. axe for presence and validity; whether the value is right, by hand: switch the UI language and look at the attribute, listen to a paragraph in NVDA.
<!-- ПОГАНО: немає lang, або код країни замість коду мови -->
<html> <html lang="ua">
<!-- ДОБРЕ -->
<html lang="uk">
<p>Як казав Кент Бек: <q lang="en">Make it work, make it right, make it fast.</q></p>
<a href="/en/" lang="en" hreflang="en">English</a>
</html>
<!-- BAD: no lang, or a country code instead of a language code -->
<html> <html lang="ua">
<!-- GOOD -->
<html lang="en">
<p>As the French say, <q lang="fr">c'est la vie</q>.</p>
<a href="/de/" lang="de" hreflang="de">Deutsch</a>
</html>
test('перемикання мови оновлює html lang (WCAG 3.1.1)', async ({ page }) => {
await page.goto('/');
await expect(page.locator('html')).toHaveAttribute('lang', 'uk');
await page.getByRole('link', { name: 'English' }).click();
await expect(page.locator('html')).toHaveAttribute('lang', 'en');
});
test('switching language updates html lang (WCAG 3.1.1)', async ({ page }) => {
await page.goto('/');
await expect(page.locator('html')).toHaveAttribute('lang', 'uk');
await page.getByRole('link', { name: 'English' }).click();
await expect(page.locator('html')).toHaveAttribute('lang', 'en');
});
hreflang підказує мову сторінки за посиланням, а lang на самому посиланні — мову його тексту, тож «English» буде вимовлено англійським голосом навіть на українській сторінці. Цитата англійською в українському абзаці — саме той фрагмент, що вимагає власного lang за 3.1.2.hreflang hints at the language of the target page, while lang on the link sets the language of its text, so «Deutsch» is spoken with a German voice even on an English page. The French phrase inside an English paragraph is exactly the kind of fragment that needs its own lang under 3.1.2.Що дає правильна розмітка. Коли таблиця розмічена <table>, <th> і scope, скрінрідер при переході між клітинками (у NVDA — Ctrl+Alt+стрілки) озвучує заголовок стовпця чи рядка: «Статус, Оплачено» замість голого «Оплачено». Він також каже розмір таблиці і позицію («рядок 3, стовпець 2»). Без цього незрячий користувач отримує купу чисел без контексту — порушення 1.3.1.
What correct markup gives you. When a table uses <table>, <th> and scope, a screen reader moving between cells (in NVDA, Ctrl+Alt+arrows) announces the column or row header: «Status, Paid» instead of a bare «Paid». It also says the table size and position («row 3, column 2»). Without this a blind user gets a heap of numbers with no context — a 1.3.1 failure.
Правила. <caption> — назва таблиці (стає її accessible name). <th scope=col> для заголовків стовпців, <th scope=row> для заголовка рядка (наприклад, номер замовлення). Для складних таблиць з кількома рівнями заголовків — headers і id, хоча часто краще розбити таблицю на простіші. Таблиці для верстки — анти-патерн; якщо така лишилась, їй потрібен role=presentation.
Rules. <caption> is the table's title (it becomes its accessible name). <th scope=col> for column headers, <th scope=row> for a row header (such as an order number). For complex tables with several header levels — headers and id, though splitting into simpler tables is often better. Layout tables are an anti-pattern; if one remains, it needs role=presentation.
Сучасні пастки. «Таблиця» з <div> у CSS grid виглядає як таблиця, але для скрінрідера це текст — потрібні ролі table/row/columnheader/cell або справжній <table>. display:block чи display:flex на <table> в деяких браузерах історично знищували табличну семантику. Сортування: заголовок-кнопка всередині <th> і aria-sort на <th>. Інтерактивні таблиці з навігацією стрілками — це вже патерн grid з APG.
Modern traps. A «table» made of <div>s in CSS grid looks like a table but is plain text to a screen reader — it needs the table/row/columnheader/cell roles or a real <table>. display:block or display:flex on a <table> historically wiped table semantics in some browsers. Sorting: a button inside the <th> and aria-sort on the <th>. Interactive tables with arrow-key navigation are the APG grid pattern.
Як тестую. axe (td-has-header, th-has-data-cells, empty-table-header), а потім NVDA: T до таблиці, Ctrl+Alt+стрілки, слухаю заголовки.
How I test. axe (td-has-header, th-has-data-cells, empty-table-header), then NVDA: T to the table, Ctrl+Alt+arrows, listening for the headers.
<table>
<caption>Мої замовлення за 2026 рік</caption>
<thead>
<tr>
<th scope="col">Номер</th>
<th scope="col" aria-sort="descending">
<button type="button">Дата</button>
</th>
<th scope="col">Статус</th>
<th scope="col">Сума</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">#1042</th>
<td>12.03.2026</td><td>Оплачено</td><td>1 250 грн</td>
</tr>
</tbody>
</table>
<!-- NVDA на клітинці «Оплачено»: «Статус, стовпець 3, Оплачено» -->
<table>
<caption>My orders in 2026</caption>
<thead>
<tr>
<th scope="col">Number</th>
<th scope="col" aria-sort="descending">
<button type="button">Date</button>
</th>
<th scope="col">Status</th>
<th scope="col">Total</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">#1042</th>
<td>2026-03-12</td><td>Paid</td><td>$125.00</td>
</tr>
</tbody>
</table>
<!-- NVDA on the «Paid» cell: «Status, column 3, Paid» -->
aria-sort стоїть лише на одному заголовку — тому, за яким зараз відсортовано, і змінюється при кліку. Кнопка всередині <th> робить сортування доступним з клавіатури, а номер замовлення як <th scope=row> озвучується при русі по рядку.aria-sort sits on one header only — the one currently sorted by — and changes on click. The button inside the <th> makes sorting keyboard accessible, and the order number as a <th scope=row> is announced when moving along the row.Навіщо alt. Атрибут alt — текстова альтернатива зображенню (1.1.1 Non-text Content, A). Скрінрідер читає її замість картинки, браузер показує її, якщо зображення не завантажилось, пошукові системи використовують її для індексації.
Why alt. The alt attribute is the text alternative for an image (1.1.1 Non-text Content, A). A screen reader reads it instead of the picture, the browser shows it if the image fails to load, and search engines use it for indexing.
Дерево рішень. 1) Картинка — єдиний вміст посилання чи кнопки? alt описує дію або ціль: «На головну», а не «логотип». 2) Несе інформацію? Передай ту саму інформацію коротко: для фото товару — що на ньому важливого, для графіка — висновок («продажі зросли на 30 % у березні») плюс таблиця даних поруч. 3) Картинка з текстом? alt містить цей текст. 4) Декоративна або дублює підпис поруч? alt= (порожній), тоді скрінрідер її пропускає. 5) Складна схема? Короткий alt плюс розгорнутий опис поруч чи через aria-describedby.
Decision tree. 1) Is the image the only content of a link or button? The alt describes the action or destination: «Home», not «logo». 2) Does it carry information? Convey the same information briefly: for a product photo, what matters in it; for a chart, the conclusion («sales grew 30 % in March») plus a data table nearby. 3) An image of text? The alt contains that text. 4) Decorative, or duplicating an adjacent caption? alt= (empty), and the screen reader skips it. 5) A complex diagram? A short alt plus a longer description next to it or via aria-describedby.
Порожній проти відсутнього. alt= — свідоме «це декор». Відсутній атрибут — помилка: скрінрідер прочитає ім'я файлу, наприклад «IMG_2034 крапка jpg». axe ловить відсутність (image-alt), але не перевірить, чи alt осмислений.
Empty versus missing. alt= is a deliberate «this is decoration». A missing attribute is a bug: the screen reader reads the file name, such as «IMG underscore 2034 dot jpg». axe catches a missing one (image-alt) but cannot tell whether an alt is meaningful.
Типові помилки. «Зображення …» чи «фото …» на початку — роль і так оголошується. alt=«banner.png». Однаковий alt у всіх товарів. Декоративні SVG без aria-hidden. CSS background-image з важливою інформацією — альтернативи для нього немає зовсім.
Common mistakes. Starting with «image of…» or «photo of…» — the role is announced anyway. alt=«banner.png». The same alt on every product. Decorative SVGs without aria-hidden. A CSS background-image carrying important information — it has no alternative at all.
<!-- функціональне: зображення — єдиний вміст посилання -->
<a href="/"><img src="logo.svg" alt="Valtive Shop — на головну"></a>
<!-- інформативне: графік з висновком і даними поруч -->
<img src="sales.png" alt="Продажі в березні зросли на 30 % порівняно з лютим"
aria-describedby="sales-table">
<!-- декоративне: порожній alt, скрінрідер пропускає -->
<img src="divider-wave.svg" alt="">
<!-- дубль підпису: текст уже поруч, alt порожній -->
<figure>
<img src="ceo.jpg" alt="">
<figcaption>Олена Коваль, CEO, на конференції QA Fest</figcaption>
</figure>
<!-- ПОГАНО: немає alt, прочитається ім'я файлу -->
<img src="IMG_2034.jpg">
<!-- functional: the image is the only content of a link -->
<a href="/"><img src="logo.svg" alt="Valtive Shop home"></a>
<!-- informative: a chart with the conclusion and data nearby -->
<img src="sales.png" alt="March sales up 30 % compared with February"
aria-describedby="sales-table">
<!-- decorative: empty alt, the screen reader skips it -->
<img src="divider-wave.svg" alt="">
<!-- duplicates the caption: the text is already next to it, alt is empty -->
<figure>
<img src="ceo.jpg" alt="">
<figcaption>Olena Koval, CEO, at the QA Fest conference</figcaption>
</figure>
<!-- BAD: no alt, the file name will be read -->
<img src="IMG_2034.jpg">
Навіщо. Клавіатурою користуються незрячі (скрінрідер керується з клавіатури), люди з тремором чи паралічем, користувачі перемикачів і голосового керування (вони емулюють клавіші), а також досвідчені користувачі. 2.1.1 Keyboard (A): уся функціональність доступна з клавіатури без вимог до швидкості натискань.
Why. The keyboard is used by blind people (a screen reader is driven from the keyboard), people with tremors or paralysis, switch and voice control users (they emulate keys) and power users. 2.1.1 Keyboard (A): all functionality is available from the keyboard without timing requirements for keystrokes.
Що саме перевіряю. 1) Досяжність: кожен інтерактивний елемент отримує фокус через Tab, нічого зайвого (декоративні елементи) не фокусується. 2) Порядок (2.4.3): фокус іде логічно, як читається сторінка, без стрибків у футер і назад. 3) Видимість (2.4.7): завжди видно, де фокус. 4) Активація: Enter для посилань і кнопок, Space для кнопок і чекбоксів, стрілки для радіокнопок, вкладок, меню, слайдерів, Esc закриває діалоги й поп-апи. 5) Немає пасток (2.1.2). 6) Фокус не губиться: після закриття модалки чи видалення рядка він потрапляє в осмислене місце, а не на <body>. 7) Немає несподіваних змін при фокусі (3.2.1): фокус на полі не відправляє форму.
What exactly I check. 1) Reachability: every interactive element takes focus via Tab, and nothing irrelevant (decorative elements) does. 2) Order (2.4.3): focus moves logically, the way the page reads, without jumping to the footer and back. 3) Visibility (2.4.7): you can always see where focus is. 4) Activation: Enter for links and buttons, Space for buttons and checkboxes, arrow keys for radios, tabs, menus and sliders, Esc closes dialogs and popups. 5) No traps (2.1.2). 6) Focus is never lost: after closing a modal or deleting a row it lands somewhere meaningful, not on <body>. 7) No surprise changes on focus (3.2.1): focusing a field does not submit the form.
Як. Відкладаю мишу і проходжу ключовий сценарій (реєстрація, пошук, чекаут) від адресного рядка до підтвердження. Tab і Shift+Tab. Записую послідовність фокусу — її легко перетворити на автотест.
How. I put the mouse away and run the key flow (sign-up, search, checkout) from the address bar to the confirmation. Tab and Shift+Tab. I write down the focus sequence — it is easy to turn into an automated test.
Помилка. Перевіряти лише, що «Tab ходить». Важливіше, чи можна завершити сценарій повністю і чи зрозуміло, де ти зараз.
The mistake. Checking only that «Tab moves». What matters more is whether the whole flow can be completed and whether it is clear where you are.
import { test, expect } from '@playwright/test';
// роль та ім'я елемента, що зараз у фокусі
async function focused(page) {
return page.evaluate(() => {
const el = document.activeElement;
return (el.getAttribute('aria-label') || el.textContent || el.getAttribute('name') || '').trim();
});
}
test('форма підписки проходиться лише клавіатурою', async ({ page }) => {
await page.goto('/subscribe');
const order = [];
for (let i = 0; i < 5; i++) {
await page.keyboard.press('Tab');
order.push(await focused(page));
}
// очікуваний логічний порядок (WCAG 2.4.3)
expect(order).toEqual(['Перейти до вмісту', 'Головна', 'email', 'Щотижня', 'Підписатися']);
await page.getByLabel('Email').fill('qa@example.com');
await page.getByRole('button', { name: 'Підписатися' }).focus();
await page.keyboard.press('Enter'); // активація з клавіатури
await expect(page.getByRole('status')).toHaveText('Дякуємо, підписку оформлено');
});
import { test, expect } from '@playwright/test';
// name of the element that currently has focus
async function focused(page) {
return page.evaluate(() => {
const el = document.activeElement;
return (el.getAttribute('aria-label') || el.textContent || el.getAttribute('name') || '').trim();
});
}
test('subscribe form works with the keyboard only', async ({ page }) => {
await page.goto('/subscribe');
const order = [];
for (let i = 0; i < 5; i++) {
await page.keyboard.press('Tab');
order.push(await focused(page));
}
// expected logical order (WCAG 2.4.3)
expect(order).toEqual(['Skip to content', 'Home', 'email', 'Weekly', 'Subscribe']);
await page.getByLabel('Email').fill('qa@example.com');
await page.getByRole('button', { name: 'Subscribe' }).focus();
await page.keyboard.press('Enter'); // keyboard activation
await expect(page.getByRole('status')).toHaveText('Thanks, you are subscribed');
});
focused спрощено визначає «ім'я» активного елемента: aria-label, текст або атрибут name для поля. Тест фіксує реальну послідовність Tab, тож перестановка елементів у DOM чи додатковий фокусований div одразу його зламають. Активація саме через keyboard.press('Enter'), а не click(), бо клік перевіряє мишу.focused helper is a simplified way to get the active element's «name»: aria-label, text or the name attribute for a field. The test pins the real Tab sequence, so reordering elements in the DOM or an extra focusable div breaks it immediately. Activation goes through keyboard.press('Enter') and not click(), because a click tests the mouse.Навіщо. Для людини, що користується клавіатурою, індикатор фокусу — те саме, що курсор миші. Без нього вона натискає Enter наосліп. 2.4.7 Focus Visible (AA): будь-який інтерфейс, керований клавіатурою, має режим, у якому індикатор фокусу видно.
Why. For a keyboard user the focus indicator is what the mouse pointer is for everyone else. Without it they press Enter blind. 2.4.7 Focus Visible (AA): any keyboard-operable interface has a mode where the focus indicator is visible.
Найчастіший баг. outline: none або outline: 0 у глобальних стилях «бо рамка некрасива» без заміни. Друга причина — стиль фокусу, який зливається з фоном: світло-блакитна тінь на білому.
The most common bug. outline: none or outline: 0 in global styles «because the ring looks ugly», with no replacement. The second cause is a focus style that blends into the background: a pale blue glow on white.
Правильно. :focus-visible показує індикатор при навігації клавіатурою і не показує при кліку мишею — так дизайнерів влаштовує, а доступність зберігається. Індикатор помітний: суцільна обвідка 2px з контрастом щонайменше 3:1 до сусідніх кольорів (це вимога 2.4.13 Focus Appearance рівня AAA, але добрий орієнтир і для AA). Якщо фокус показано лише тінню box-shadow, додай прозорий outline: у режимі Windows High Contrast (forced colors) тіні зникають, а outline лишається.
The right way. :focus-visible shows the indicator during keyboard navigation and hides it on mouse clicks — designers are happy and accessibility is kept. The indicator is noticeable: a solid 2px outline with at least 3:1 contrast against adjacent colours (that is the 2.4.13 Focus Appearance AAA requirement, but a good guide for AA too). If focus is shown only with a box-shadow, add a transparent outline: in Windows High Contrast (forced colours) mode shadows disappear but the outline stays.
2.4.11 Focus Not Obscured (Minimum), AA, новий у 2.2. Елемент у фокусі не повинен бути повністю захований під липкою шапкою, футером, cookie-банером чи чатом. Виправлення — scroll-padding з висотою шапки або відсування банера.
2.4.11 Focus Not Obscured (Minimum), AA, new in 2.2. The focused element must not be entirely hidden under a sticky header, footer, cookie banner or chat. The fix is scroll-padding equal to the header height, or moving the banner out of the way.
Перевірка. Tab через усю сторінку: на кожному кроці бачу, де фокус. Окремо — з відкритим cookie-банером і після прокрутки під липку шапку.
Checking. Tab through the whole page: at every step I can see where focus is. Separately, with the cookie banner open and after scrolling under the sticky header.
/* ПОГАНО: фокус зник для всіх, заміни немає (провал 2.4.7) */
*:focus { outline: none; }
/* ДОБРЕ: помітне кільце лише при навігації з клавіатури */
:focus-visible {
outline: 3px solid #1a73e8; /* 4.5:1 до білого фону */
outline-offset: 2px;
}
/* тінь для дизайну + прозорий outline для режиму forced colors */
.btn:focus-visible {
box-shadow: 0 0 0 4px #1a73e8;
outline: 2px solid transparent;
}
/* 2.4.11: елемент під липкою шапкою висотою 72px не ховається */
html { scroll-padding-top: 80px; }
/* BAD: focus is gone for everyone, no replacement (fails 2.4.7) */
*:focus { outline: none; }
/* GOOD: a noticeable ring only during keyboard navigation */
:focus-visible {
outline: 3px solid #1a73e8; /* 4.5:1 against a white background */
outline-offset: 2px;
}
/* shadow for the design + transparent outline for forced colours mode */
.btn:focus-visible {
box-shadow: 0 0 0 4px #1a73e8;
outline: 2px solid transparent;
}
/* 2.4.11: an element under a 72px sticky header is not hidden */
html { scroll-padding-top: 80px; }
box-shadow там просто прибирається. scroll-padding-top змушує браузер прокручувати сфокусований елемент так, щоб над ним лишалось місце для шапки.box-shadow is simply removed. scroll-padding-top makes the browser scroll a focused element so there is room left above it for the header.Порядок за замовчуванням. Фокус іде в порядку DOM серед нативно фокусованих елементів: посилання з href, кнопки, поля, <select>, <summary>, елементи з contenteditable. 2.4.3 Focus Order (A): порядок має зберігати зміст і дію. Проблеми виникають, коли CSS (flex order, grid, position:absolute, row-reverse) переставляє елементи візуально, а DOM лишається старим: фокус стрибає по екрану.
Default order. Focus follows DOM order across natively focusable elements: links with href, buttons, fields, <select>, <summary>, elements with contenteditable. 2.4.3 Focus Order (A): the order must preserve meaning and operability. Problems arise when CSS (flex order, grid, position:absolute, row-reverse) rearranges elements visually while the DOM stays as it was: focus jumps around the screen.
tabindex=0. Додає нефокусований елемент у природний порядок. Потрібен лише для кастомних віджетів з роллю (наприклад, role=slider) або прокручуваних областей, щоб їх можна було прокрутити клавіатурою.
tabindex=0. Adds a non-focusable element to the natural order. Needed only for custom widgets with a role (such as role=slider) or scrollable areas, so they can be scrolled with the keyboard.
tabindex=-1. Елемент не потрапляє в Tab-порядок, але його можна сфокусувати скриптом через focus(). Застосування: заголовок чи контейнер, куди переносимо фокус після переходу в SPA або закриття діалогу; ціль skip link; неактивні вкладки в roving tabindex.
tabindex=-1. The element is left out of the Tab order but can be focused by script with focus(). Uses: a heading or container that receives focus after an SPA navigation or a dialog closes; a skip link target; inactive tabs in a roving tabindex.
Додатні значення (1, 2, 99). Анти-патерн. Такі елементи отримують фокус раніше за всі інші на сторінці, у порядку номерів, а потім іде решта. Один tabindex=5 у шапці ламає порядок всієї сторінки, а при додаванні нових компонентів це неможливо підтримувати. axe має правило tabindex (best-practice), що попереджає про додатні значення.
Positive values (1, 2, 99). An anti-pattern. Such elements get focus before everything else on the page, in number order, and then the rest follows. One tabindex=5 in the header breaks the order of the whole page, and it becomes impossible to maintain as components are added. axe has a tabindex rule (best-practice) that warns about positive values.
Як тестую. Проходжу Tab і порівнюю з візуальним порядком читання; шукаю в DOM tabindex з додатними значеннями; перевіряю, що tabindex=0 стоїть лише там, де є роль і обробка клавіш.
How I test. I Tab through and compare with the visual reading order; I search the DOM for tabindex with positive values; I check that tabindex=0 appears only where there is a role and key handling.
<!-- tabindex="0": кастомний слайдер у природному порядку -->
<div role="slider" tabindex="0" aria-label="Гучність"
aria-valuemin="0" aria-valuemax="100" aria-valuenow="40"></div>
<!-- tabindex="-1": ціль для focus() після переходу в SPA -->
<h1 tabindex="-1" id="page-title">Кошик</h1>
<!-- ПОГАНО: цей пошук отримає фокус першим на всій сторінці -->
<input type="search" tabindex="3" aria-label="Пошук">
<!-- ПОГАНО: візуально «Скасувати» праворуч, але в DOM перша -->
<div style="display:flex; flex-direction:row-reverse">
<button>Скасувати</button> <button>Оплатити</button>
</div>
<!-- tabindex="0": a custom slider in the natural order -->
<div role="slider" tabindex="0" aria-label="Volume"
aria-valuemin="0" aria-valuemax="100" aria-valuenow="40"></div>
<!-- tabindex="-1": target for focus() after an SPA navigation -->
<h1 tabindex="-1" id="page-title">Cart</h1>
<!-- BAD: this search gets focus first on the whole page -->
<input type="search" tabindex="3" aria-label="Search">
<!-- BAD: visually «Cancel» is on the right, but first in the DOM -->
<div style="display:flex; flex-direction:row-reverse">
<button>Cancel</button> <button>Pay</button>
</div>
tabindex=0 ще потребує обробки стрілок і оновлення aria-valuenow, інакше це порожня обіцянка. Останній приклад показує типовий баг: Tab спершу йде на праву кнопку, потім на ліву, всупереч напрямку читання.tabindex=0 still needs arrow-key handling and aria-valuenow updates, otherwise it is an empty promise. The last example shows a typical bug: Tab goes to the right button first and then the left one, against the reading direction.Що це. Skip link — перше фокусоване посилання на сторінці, на кшталт «Перейти до основного вмісту», яке веде на <main>. Зазвичай воно приховане і з'являється при фокусі з клавіатури.
What it is. A skip link is the first focusable link on a page, something like «Skip to main content», that points to <main>. It is usually hidden and appears on keyboard focus.
Навіщо. Без нього людина з клавіатурою на кожній сторінці проходить Tab-ом логотип, меню з 30 пунктів, пошук, кошик — десятки натискань до першого товару. Для людини з моторними обмеженнями кожне натискання — зусилля. Критерій 2.4.1 Bypass Blocks (A) вимагає механізм пропуску повторюваних блоків. Правильні landmark-и і заголовки теж вважаються таким механізмом для скрінрідерів, але зрячий користувач клавіатури ними скористатися не може — тому skip link лишається найкращою практикою.
Why. Without it a keyboard user on every page tabs through the logo, a 30-item menu, search and cart — dozens of presses before the first product. For someone with a motor impairment every keystroke takes effort. 2.4.1 Bypass Blocks (A) requires a way to skip repeated blocks. Proper landmarks and headings also count as such a mechanism for screen reader users, but a sighted keyboard user cannot use them — which is why a skip link remains best practice.
Як тестую. 1) Відкриваю сторінку, натискаю Tab один раз — посилання з'являється і його видно (не за межами екрана, не під шапкою). 2) Enter — сторінка прокручується до вмісту. 3) Ще раз Tab — фокус на першому елементі всередині <main>, а не знову в меню. Саме третій крок найчастіше падає: вміст прокрутився, а фокус лишився нагорі.
How I test. 1) Open the page, press Tab once — the link appears and is visible (not off-screen, not under the header). 2) Enter — the page scrolls to the content. 3) Tab again — focus is on the first element inside <main>, not back in the menu. The third step is the one that fails most often: the content scrolled but focus stayed at the top.
Типові баги. Посилання назавжди приховане через display:none — тоді воно недосяжне взагалі. Якір веде на id, якого немає. Посилання стоїть не першим, а після cookie-банера. Текст англійською на українській сторінці.
Typical bugs. The link is permanently hidden with display:none, so it can never be reached. The anchor points to an id that does not exist. The link is not first but comes after the cookie banner. The text is in English on a Ukrainian page.
<body>
<a class="skip-link" href="#main">Перейти до основного вмісту</a>
<header>...меню з 30 посилань...</header>
<main id="main" tabindex="-1">...</main>
</body>
<style>
.skip-link { position: absolute; left: 8px; top: -100px; }
.skip-link:focus { top: 8px; } /* з'являється при фокусі */
</style>
<body>
<a class="skip-link" href="#main">Skip to main content</a>
<header>...a menu of 30 links...</header>
<main id="main" tabindex="-1">...</main>
</body>
<style>
.skip-link { position: absolute; left: 8px; top: -100px; }
.skip-link:focus { top: 8px; } /* appears on focus */
</style>
test('skip link веде фокус у main (WCAG 2.4.1)', async ({ page }) => {
await page.goto('/catalog');
await page.keyboard.press('Tab');
const skip = page.getByRole('link', { name: 'Перейти до основного вмісту' });
await expect(skip).toBeFocused();
await expect(skip).toBeInViewport();
await page.keyboard.press('Enter');
await page.keyboard.press('Tab');
// наступний фокус уже всередині main, а не в меню
expect(await page.evaluate(() => document.activeElement.closest('main') !== null)).toBe(true);
});
test('skip link moves focus into main (WCAG 2.4.1)', async ({ page }) => {
await page.goto('/catalog');
await page.keyboard.press('Tab');
const skip = page.getByRole('link', { name: 'Skip to main content' });
await expect(skip).toBeFocused();
await expect(skip).toBeInViewport();
await page.keyboard.press('Enter');
await page.keyboard.press('Tab');
// the next focus is already inside main, not in the menu
expect(await page.evaluate(() => document.activeElement.closest('main') !== null)).toBe(true);
});
tabindex=-1 на <main> гарантує, що фокус справді переїде в ціль у всіх браузерах, а не лише прокрутка. toBeInViewport ловить посилання, яке отримало фокус, але лишилося за межами екрана. Останній рядок перевіряє саме той третій крок, який найчастіше ламається.tabindex=-1 on <main> makes sure focus really moves to the target in every browser, not just the scroll position. toBeInViewport catches a link that got focus but stayed off-screen. The last line checks exactly the third step that breaks most often.Що це. Keyboard trap — стан, коли фокус потрапив у компонент і вийти з нього лише клавіатурою неможливо: Tab і Shift+Tab ходять по колу, Esc нічого не робить. Людина без миші змушена перезавантажувати сторінку. 2.1.2 No Keyboard Trap (A) — один із чотирьох критеріїв, які WCAG вважає «невтручальними»: їх провал ламає всю сторінку, навіть якщо проблемна частина не потрібна для відповідності.
What it is. A keyboard trap is when focus has entered a component and there is no way out with the keyboard alone: Tab and Shift+Tab cycle, Esc does nothing. A person without a mouse has to reload the page. 2.1.2 No Keyboard Trap (A) is one of the four criteria WCAG calls «non-interference»: failing them breaks the whole page, even if the faulty part is not relied on for conformance.
Де трапляється. Вбудовані iframe (карти, відеоплеєри, платіжні форми третіх сторін, капча), текстові редактори, де Tab вставляє символ табуляції, календарі й кастомні дропдауни зі скриптом «повернути фокус у поле», Flash-подібні вбудовування, скрипти, що роблять focus() на полі при втраті фокусу.
Where it happens. Embedded iframes (maps, video players, third-party payment forms, captchas), text editors where Tab inserts a tab character, date pickers and custom dropdowns with a «put focus back into the field» script, plugin-style embeds, scripts that call focus() on a field when it loses focus.
Навмисне утримання. Модальне вікно навмисно тримає фокус у собі: поки воно відкрите, фон неактивний, і Tab ходить по колу всередині — це правильна поведінка з APG. Різниця в наявності виходу: Esc закриває діалог, є кнопка «Закрити» чи «Скасувати», і після закриття фокус повертається на кнопку, що відкрила діалог. Редактор коду теж може перехоплювати Tab, якщо користувачу повідомлено нестандартний спосіб виходу (наприклад, Esc, потім Tab), — WCAG це дозволяє.
Intentional containment. A modal dialog deliberately keeps focus inside: while it is open, the background is inactive and Tab cycles within — that is the correct APG behaviour. The difference is the exit: Esc closes the dialog, there is a Close or Cancel button, and after closing focus returns to the button that opened it. A code editor may also capture Tab if the user is told the non-standard way out (for example, Esc then Tab) — WCAG allows that.
Як тестую. Заходжу Tab-ом у кожен вбудований компонент і намагаюся вийти вперед і назад. Для діалогу — перевіряю, що вихід існує і фокус повертається.
How I test. I Tab into every embedded component and try to leave forwards and backwards. For a dialog I check that an exit exists and focus comes back.
test('віджет карти не утримує фокус (WCAG 2.1.2)', async ({ page }) => {
await page.goto('/stores');
const map = page.locator('#store-map');
await map.locator('button').first().focus(); // зайшли всередину
let left = false;
for (let i = 0; i < 40 && !left; i++) { // вперед: до 40 Tab
await page.keyboard.press('Tab');
left = await map.evaluate((m) => !m.contains(document.activeElement));
}
expect(left, 'Tab не виводить фокус з карти').toBe(true);
await map.locator('button').first().focus();
await page.keyboard.press('Shift+Tab'); // назад
expect(await map.evaluate((m) => !m.contains(document.activeElement))).toBe(true);
});
test('map widget does not trap focus (WCAG 2.1.2)', async ({ page }) => {
await page.goto('/stores');
const map = page.locator('#store-map');
await map.locator('button').first().focus(); // we are inside
let left = false;
for (let i = 0; i < 40 && !left; i++) { // forwards: up to 40 Tabs
await page.keyboard.press('Tab');
left = await map.evaluate((m) => !m.contains(document.activeElement));
}
expect(left, 'Tab does not move focus out of the map').toBe(true);
await map.locator('button').first().focus();
await page.keyboard.press('Shift+Tab'); // backwards
expect(await map.evaluate((m) => !m.contains(document.activeElement))).toBe(true);
});
contains не бачить внутрішніх елементів, тому там перевіряю, що фокус повернувся в основний документ, або роблю це вручну.contains cannot see inner elements, so there I check that focus returned to the main document, or do it by hand.Вимоги. 1) Роль і ім'я: role=dialog (або alertdialog для критичних підтверджень), aria-modal=true, ім'я через aria-labelledby на заголовок. 2) При відкритті фокус переходить у діалог: на перший інтерактивний елемент, на заголовок або на найменш руйнівну дію («Скасувати», а не «Видалити»). 3) Tab і Shift+Tab циклічно ходять лише всередині. 4) Esc закриває. 5) Фон неактивний: не фокусується, не читається скрінрідером, не прокручується. 6) Після закриття фокус повертається на елемент, що відкрив діалог, або в логічне місце, якщо той зник.
Requirements. 1) Role and name: role=dialog (or alertdialog for critical confirmations), aria-modal=true, a name via aria-labelledby pointing to the heading. 2) On open, focus moves into the dialog: to the first interactive element, the heading, or the least destructive action («Cancel», not «Delete»). 3) Tab and Shift+Tab cycle inside only. 4) Esc closes it. 5) The background is inert: not focusable, not read by the screen reader, not scrollable. 6) On close, focus returns to the element that opened the dialog, or somewhere logical if that element is gone.
Нативний <dialog>. Метод showModal() дає більшість цього без коду: верхній шар, неактивний фон, Esc, роль dialog. Сучасні браузери також повертають фокус на попередній елемент після закриття, але це варто перевірити тестом. Метод show() відкриває немодальний діалог — без цих гарантій.
Native <dialog>. The showModal() method gives most of this without code: the top layer, an inert background, Esc, the dialog role. Modern browsers also return focus to the previous element on close, but it is worth a test. The show() method opens a non-modal dialog without these guarantees.
Типові баги. Фокус лишається на кнопці під затемненням, і Tab ходить по фону. Діалог без імені — скрінрідер каже просто «діалог». Після закриття фокус падає на <body>, і людина починає зверху сторінки. Хрестик без імені. Діалог, що відкривається при завантаженні, без жодного оголошення.
Typical bugs. Focus stays on the button under the overlay, and Tab walks the background. A dialog with no name — the screen reader just says «dialog». After closing, focus drops to <body> and the person starts from the top of the page. A close cross with no name. A dialog that opens on page load with no announcement at all.
Як тестую. Клавіатурою: відкрити Enter-ом, перевірити, де фокус, пройти коло Tab-ом, закрити Esc, перевірити повернення фокусу. NVDA: при відкритті звучить ім'я діалогу. Та сама послідовність легко автоматизується.
How I test. With the keyboard: open with Enter, check where focus is, Tab around the loop, close with Esc, check focus comes back. With NVDA: the dialog name is announced on open. The same sequence is easy to automate.
<button id="delete-btn" type="button">Видалити акаунт</button>
<dialog id="confirm" aria-labelledby="confirm-title">
<h2 id="confirm-title">Видалити акаунт назавжди?</h2>
<p>Дані неможливо буде відновити.</p>
<button type="button" autofocus>Скасувати</button>
<button type="button">Видалити</button>
</dialog>
<!-- JS: button.onclick = () => dialog.showModal(); -->
<button id="delete-btn" type="button">Delete account</button>
<dialog id="confirm" aria-labelledby="confirm-title">
<h2 id="confirm-title">Delete your account permanently?</h2>
<p>Your data cannot be restored.</p>
<button type="button" autofocus>Cancel</button>
<button type="button">Delete</button>
</dialog>
<!-- JS: button.onclick = () => dialog.showModal(); -->
test('діалог підтвердження: фокус, цикл, Esc, повернення', async ({ page }) => {
await page.goto('/settings');
const opener = page.getByRole('button', { name: 'Видалити акаунт' });
await opener.focus();
await page.keyboard.press('Enter');
const dialog = page.getByRole('dialog', { name: 'Видалити акаунт назавжди?' });
await expect(dialog).toBeVisible();
await expect(dialog.getByRole('button', { name: 'Скасувати' })).toBeFocused();
for (let i = 0; i < 3; i++) { // цикл лишається всередині
await page.keyboard.press('Tab');
expect(await dialog.evaluate((d) => d.contains(document.activeElement))).toBe(true);
}
await page.keyboard.press('Escape');
await expect(dialog).toBeHidden();
await expect(opener).toBeFocused(); // фокус повернувся
});
test('confirm dialog: focus, loop, Esc, return', async ({ page }) => {
await page.goto('/settings');
const opener = page.getByRole('button', { name: 'Delete account' });
await opener.focus();
await page.keyboard.press('Enter');
const dialog = page.getByRole('dialog', { name: 'Delete your account permanently?' });
await expect(dialog).toBeVisible();
await expect(dialog.getByRole('button', { name: 'Cancel' })).toBeFocused();
for (let i = 0; i < 3; i++) { // the loop stays inside
await page.keyboard.press('Tab');
expect(await dialog.evaluate((d) => d.contains(document.activeElement))).toBe(true);
}
await page.keyboard.press('Escape');
await expect(dialog).toBeHidden();
await expect(opener).toBeFocused(); // focus came back
});
autofocus у діалозі визначає, куди потрапить фокус при showModal(), — тут на безпечну «Скасувати». Тест знаходить діалог за роллю та ім'ям, тож заодно перевіряє aria-labelledby. Три Tab-и з перевіркою contains доводять, що фокус не тікає на фон.autofocus inside the dialog decides where focus lands on showModal() — here, on the safe «Cancel». The test finds the dialog by role and name, so it also checks aria-labelledby. Three Tabs with a contains check prove focus does not escape to the background.Проблема. Перетягування вимагає точного утримання вказівника і руху — це важко людям з тремором, з церебральним паралічем, користувачам головної миші, трекболу чи eye tracking. Незрячі не бачать, куди перетягувати.
The problem. Dragging requires holding the pointer precisely while moving — hard for people with tremors, cerebral palsy, head mouse, trackball or eye-tracking users. Blind users cannot see where to drop.
Два різні критерії. 2.1.1 Keyboard (A) вимагає, щоб усе робилося з клавіатури: наприклад, Space «бере» картку, стрілки переміщують, Space «кладе», Esc скасовує, а live-регіон оголошує «Картку переміщено на позицію 3 з 5». 2.5.7 Dragging Movements (AA, новий у 2.2) вимагає альтернативу для одного вказівника без перетягування: кнопки «вгору/вниз», меню «Перемістити в…», тап по картці, потім тап по колонці. Клавіатурне рішення не закриває 2.5.7: людина з головною мишею чи тачскріном клавіатурою не користується. Потрібні обидва.
Two different criteria. 2.1.1 Keyboard (A) requires everything to work from the keyboard: for example, Space «picks up» a card, arrows move it, Space «drops» it, Esc cancels, and a live region announces «Card moved to position 3 of 5». 2.5.7 Dragging Movements (AA, new in 2.2) requires a single-pointer alternative without dragging: up/down buttons, a «Move to…» menu, tap the card then tap the column. A keyboard solution does not satisfy 2.5.7: a person with a head mouse or a touch screen is not using a keyboard. Both are needed.
Трейд-офи. Кнопки «вгору/вниз» на кожній картці — найпростіше і найнадійніше, але візуально шумно; можна показувати їх у меню «…». Бібліотеки (наприклад, dnd-kit) дають клавіатурний сенсор і оголошення, але альтернативу для вказівника доведеться зробити самостійно. Вивантаження файлів: зона перетягування завжди дублюється кнопкою «Обрати файл».
Trade-offs. Up/down buttons on each card are the simplest and most robust, but visually noisy; they can live in a «…» menu. Libraries (such as dnd-kit) provide a keyboard sensor and announcements, but the pointer alternative is on you. File upload: a drop zone is always paired with a «Choose file» button.
Як тестую. Клавіатурою переміщую елемент і слухаю оголошення в NVDA. Мишею та на телефоні виконую ту саму дію без перетягування. Перевіряю, що порядок у DOM і в API змінився, а не лише візуально. Автотест будую на альтернативі, а не на dragTo.
How I test. With the keyboard I move an item and listen to the NVDA announcements. With a mouse and on a phone I do the same action without dragging. I check the order changed in the DOM and in the API, not just visually. The automated test drives the alternative, not dragTo.
<ol id="priorities">
<li>
<span id="t1">Оновити тарифи</span>
<!-- 1. перетягування мишею: ручка для drag -->
<button type="button" aria-describedby="t1" aria-roledescription="ручка перетягування">⠿</button>
<!-- 2. один вказівник без drag (2.5.7) і клавіатура (2.1.1) -->
<button type="button" aria-label="Вище: Оновити тарифи">↑</button>
<button type="button" aria-label="Нижче: Оновити тарифи">↓</button>
</li>
</ol>
<!-- 3. оголошення результату будь-якого способу -->
<div role="status" id="dnd-live"></div>
<ol id="priorities">
<li>
<span id="t1">Update pricing</span>
<!-- 1. mouse dragging: a drag handle -->
<button type="button" aria-describedby="t1" aria-roledescription="drag handle">⠿</button>
<!-- 2. single pointer without drag (2.5.7) and keyboard (2.1.1) -->
<button type="button" aria-label="Move up: Update pricing">↑</button>
<button type="button" aria-label="Move down: Update pricing">↓</button>
</li>
</ol>
<!-- 3. announcing the result of any method -->
<div role="status" id="dnd-live"></div>
test('пріоритет можна перемістити без перетягування (WCAG 2.5.7)', async ({ page }) => {
await page.goto('/board');
await page.getByRole('button', { name: 'Нижче: Оновити тарифи' }).click();
await expect(page.locator('#priorities li').nth(1)).toContainText('Оновити тарифи');
await expect(page.getByRole('status')).toHaveText('Оновити тарифи: позиція 2 з 4');
});
test('a priority can be moved without dragging (WCAG 2.5.7)', async ({ page }) => {
await page.goto('/board');
await page.getByRole('button', { name: 'Move down: Update pricing' }).click();
await expect(page.locator('#priorities li').nth(1)).toContainText('Update pricing');
await expect(page.getByRole('status')).toHaveText('Update pricing: position 2 of 4');
});
role=status повідомляє скрінрідеру результат незалежно від способу переміщення.role=status tells the screen reader the result whichever method was used.Зв'язок. Нативний спосіб — <label for=id>, де for збігається з id поля, або поле вкладене всередину <label>. Тоді поле отримує accessible name (скрінрідер читає «Email, поле редагування»), а клік по тексту label ставить фокус у поле чи перемикає чекбокс — більша зона кліку допомагає людям з тремором. Для групи радіокнопок чи чекбоксів — <fieldset> з <legend>: скрінрідер скаже «Спосіб доставки, група, Кур'єр, радіокнопка, 1 з 3».
Linking. The native way is <label for=id>, with for matching the field's id, or the field nested inside the <label>. The field then gets an accessible name (the screen reader says «Email, edit text»), and clicking the label text focuses the field or toggles the checkbox — a bigger click area helps people with tremors. For a group of radios or checkboxes use <fieldset> with <legend>: the screen reader says «Delivery method, grouping, Courier, radio button, 1 of 3».
Чому підказка всередині поля — не label. Атрибут, що показує сірий текст-підказку в порожньому полі, зникає, щойно людина почала друкувати, — і вона вже не пам'ятає, що це було за поле. Це навантаження на пам'ять для людей з когнітивними труднощами. Такий текст зазвичай низькоконтрастний (провал 1.4.3). Браузер використовує його як ім'я лише в крайньому випадку, а автоперекладачі і старі допоміжні технології його часто ігнорують. Візуально поле з підказкою схоже на вже заповнене. Правильно — видимий label над полем, а приклад формату — окремим текстом, пов'язаним через aria-describedby.
Why hint text inside the field is not a label. The attribute that shows grey hint text in an empty field disappears as soon as the person starts typing — and they no longer remember what the field was. That is a memory load for people with cognitive difficulties. This text is usually low contrast (a 1.4.3 failure). The browser uses it as the name only as a last resort, and automatic translators and older assistive technology often ignore it. Visually, a field with hint text looks already filled in. The right way is a visible label above the field, with a format example as separate text linked via aria-describedby.
Типові баги. for посилається на неіснуючий чи дубльований id (часто в компонентах, відрендерених двічі). Текст «Email» поруч із полем, але не пов'язаний. aria-label замість видимого label. Label «Поле 1».
Typical bugs. for pointing to a missing or duplicated id (often in components rendered twice). The word «Email» next to the field but not linked. An aria-label instead of a visible label. A label saying «Field 1».
Перевірка. axe label; вручну — клік по тексту label і прослуховування в NVDA; у тестах — getByLabel.
Checking. axe label; by hand, click the label text and listen in NVDA; in tests, getByLabel.
<!-- ПОГАНО: «Телефон» лише візуально поруч, у поля немає імені -->
<div>Телефон</div>
<input type="tel">
<!-- ДОБРЕ: видимий label, формат окремо через aria-describedby -->
<label for="phone">Телефон</label>
<input id="phone" type="tel" autocomplete="tel" aria-describedby="phone-hint">
<p id="phone-hint">Формат: +380 67 123 45 67</p>
<!-- ДОБРЕ: група радіокнопок з назвою -->
<fieldset>
<legend>Спосіб доставки</legend>
<label><input type="radio" name="ship" value="courier"> Кур'єр</label>
<label><input type="radio" name="ship" value="post"> Нова пошта</label>
</fieldset>
<!-- BAD: «Phone» is only visually nearby, the field has no name -->
<div>Phone</div>
<input type="tel">
<!-- GOOD: visible label, format separately via aria-describedby -->
<label for="phone">Phone</label>
<input id="phone" type="tel" autocomplete="tel" aria-describedby="phone-hint">
<p id="phone-hint">Format: +1 555 010 0199</p>
<!-- GOOD: a named group of radio buttons -->
<fieldset>
<legend>Delivery method</legend>
<label><input type="radio" name="ship" value="courier"> Courier</label>
<label><input type="radio" name="ship" value="post"> Post office</label>
</fieldset>
getByLabel('Телефон') нічого не знайде, і axe покаже порушення label. У другому ім'я — «Телефон», опис — формат номера. Вкладений <input> у <label> у fieldset працює без for і id.getByLabel('Phone') finds nothing and axe reports a label violation. In the second the name is «Phone» and the description is the number format. An <input> nested inside a <label> in the fieldset works without for and id.Обов'язкові поля. Позначка має бути і візуальною, і програмною. Візуально: зірочка з поясненням на початку форми («Поля з * обов'язкові») або слово «(обов'язково)» в label. Лише червоний колір не підходить (1.4.1). Програмно: атрибут required — скрінрідер скаже «обов'язкове»; для кастомних контролів — aria-required=true. Зірочку часто ховають від скрінрідера (aria-hidden), якщо стан уже оголошується, щоб не чути «зірочка, обов'язкове». Якщо обов'язкові майже всі поля, зручніше позначати необов'язкові.
Required fields. The marking has to be both visual and programmatic. Visually: an asterisk explained at the start of the form («Fields marked * are required») or the word «(required)» in the label. Red colour alone is not enough (1.4.1). Programmatically: the required attribute — the screen reader says «required»; for custom controls, aria-required=true. The asterisk is often hidden from the screen reader (aria-hidden) when the state is already announced, to avoid «star, required». If almost every field is required, it is easier to mark the optional ones.
Autocomplete і 1.3.5 Identify Input Purpose (AA). Поля, що збирають дані про самого користувача, повинні мати значення autocomplete зі списку HTML: name, email, tel, street-address, postal-code, bday, cc-number, username, new-password тощо. Навіщо: браузер заповнить поля сам — менше набору для людей з моторними обмеженнями і менше навантаження на пам'ять для людей з когнітивними; також розширення можуть показати біля поля знайому іконку.
Autocomplete and 1.3.5 Identify Input Purpose (AA). Fields that collect information about the user must carry an autocomplete value from the HTML list: name, email, tel, street-address, postal-code, bday, cc-number, username, new-password and so on. Why: the browser fills them in — less typing for people with motor impairments and less memory load for people with cognitive ones; extensions can also show a familiar icon next to the field.
Типові баги. autocomplete=off на всій формі «з безпеки». Вигадані значення (autocomplete=phone замість tel) — axe ловить правилом autocomplete-valid. Поле дати народження, розбите на три select без autocomplete. Нативна валідація required з англомовним спливаючим повідомленням на українській сторінці.
Typical bugs. autocomplete=off on the whole form «for security». Made-up values (autocomplete=phone instead of tel) — axe catches them with autocomplete-valid. A date of birth split into three selects without autocomplete. Native required validation with an English popup on a Ukrainian page.
Нюанс. 1.3.5 стосується даних про користувача, а не всіх полів: поле «Ім'я одержувача подарунка» не зобов'язане мати autocomplete=name.
Nuance. 1.3.5 covers data about the user, not every field: a «Gift recipient name» field does not have to use autocomplete=name.
<p>Поля, позначені <span aria-hidden="true">*</span> зірочкою, обов'язкові.</p>
<label for="fullname">Ім'я та прізвище <span aria-hidden="true">*</span></label>
<input id="fullname" required autocomplete="name">
<label for="email">Email <span aria-hidden="true">*</span></label>
<input id="email" type="email" required autocomplete="email">
<label for="zip">Поштовий індекс (необов'язково)</label>
<input id="zip" autocomplete="postal-code" inputmode="numeric">
<p>Fields marked with an <span aria-hidden="true">*</span> asterisk are required.</p>
<label for="fullname">Full name <span aria-hidden="true">*</span></label>
<input id="fullname" required autocomplete="name">
<label for="email">Email <span aria-hidden="true">*</span></label>
<input id="email" type="email" required autocomplete="email">
<label for="zip">Postcode (optional)</label>
<input id="zip" autocomplete="postal-code" inputmode="numeric">
const expected = { 'Ім\'я та прізвище': 'name', 'Email': 'email', 'Поштовий індекс': 'postal-code' };
for (const [label, token] of Object.entries(expected)) {
await expect(page.getByLabel(label)).toHaveAttribute('autocomplete', token);
}
await expect(page.getByLabel('Email')).toHaveJSProperty('required', true);
const expected = { 'Full name': 'name', 'Email': 'email', 'Postcode': 'postal-code' };
for (const [label, token] of Object.entries(expected)) {
await expect(page.getByLabel(label)).toHaveAttribute('autocomplete', token);
}
await expect(page.getByLabel('Email')).toHaveJSProperty('required', true);
required вже дає оголошення «обов'язкове». getByLabel шукає за частковим збігом, тож «Поштовий індекс» знайде і label з приміткою «(необов'язково)». Мапа label → токен легко розширюється на всю форму чекауту.required attribute already produces the «required» announcement. getByLabel matches partially, so «Postcode» also finds the label with the «(optional)» note. The label → token map is easy to extend to the whole checkout form.Критерії. 3.3.1 Error Identification (A): помилку виявлено автоматично — поле позначено, а помилку описано текстом. 3.3.3 Error Suggestion (AA): якщо відомо, як виправити, підкажи («Дата у форматі ДД.ММ.РРРР», а не «Невірне значення»). 3.3.4 Error Prevention (AA) для юридичних і фінансових дій: можливість перевірити, скасувати чи підтвердити перед відправкою. 4.1.3 Status Messages (AA): оголошення без переносу фокусу, де це доречно.
Criteria. 3.3.1 Error Identification (A): an error detected automatically is identified — the field is marked and the error is described in text. 3.3.3 Error Suggestion (AA): if the fix is known, suggest it («Date in DD.MM.YYYY format», not «Invalid value»). 3.3.4 Error Prevention (AA) for legal and financial actions: the ability to review, correct or confirm before submitting. 4.1.3 Status Messages (AA): announcements without moving focus where appropriate.
Робочий патерн. Після відправки з помилками: 1) над формою — зведення помилок із заголовком і посиланнями на кожне поле, фокус переходить на зведення (або воно має role=alert); 2) біля кожного поля — текст помилки, пов'язаний через aria-describedby, і aria-invalid=true на полі; 3) значення, які людина ввела, збережені. Так скрінрідер оголосить проблему одразу, а на полі ще раз прочитає «недійсні дані» разом із текстом.
The working pattern. After a submit with errors: 1) above the form, an error summary with a heading and links to each field, and focus moves to the summary (or it has role=alert); 2) next to each field, the error text linked through aria-describedby, and aria-invalid=true on the field; 3) whatever the person typed is kept. That way the screen reader announces the problem at once and reads «invalid entry» with the text again on the field.
Типові баги. Помилка лише червоною рамкою (1.4.1). Текст помилки з'являється, але ніхто його не оголошує, і незряча людина не розуміє, чому форма «не відправляється». Валідація на кожне натискання клавіші з role=alert — скрінрідер перериває введення щоразу. Фокус після відправки лишається на кнопці. Помилка зникає через 3 секунди. Форма очищається.
Typical bugs. The error is only a red border (1.4.1). The error text appears but nothing announces it, so a blind person does not understand why the form «will not submit». Validation on every keystroke with role=alert — the screen reader interrupts typing every time. Focus stays on the button after submitting. The error disappears after 3 seconds. The form is cleared.
Як тестую. Відправляю порожню і некоректну форму клавіатурою з NVDA: що оголошено, де фокус, чи можна дійти до поля з посилання у зведенні, чи зрозуміло, як виправити.
How I test. I submit an empty and an invalid form with the keyboard and NVDA running: what is announced, where focus is, whether a summary link takes me to the field, whether it is clear how to fix it.
<div id="error-summary" tabindex="-1" role="alert" aria-labelledby="err-h">
<h2 id="err-h">Виправте 2 помилки</h2>
<ul>
<li><a href="#card">Номер картки має містити 16 цифр</a></li>
<li><a href="#exp">Вкажіть строк дії у форматі ММ/РР</a></li>
</ul>
</div>
<label for="card">Номер картки</label>
<input id="card" autocomplete="cc-number" inputmode="numeric"
aria-invalid="true" aria-describedby="card-err">
<p id="card-err">Помилка: номер картки має містити 16 цифр, введено 15.</p>
<div id="error-summary" tabindex="-1" role="alert" aria-labelledby="err-h">
<h2 id="err-h">Fix 2 errors</h2>
<ul>
<li><a href="#card">Card number must have 16 digits</a></li>
<li><a href="#exp">Enter the expiry date as MM/YY</a></li>
</ul>
</div>
<label for="card">Card number</label>
<input id="card" autocomplete="cc-number" inputmode="numeric"
aria-invalid="true" aria-describedby="card-err">
<p id="card-err">Error: card number must have 16 digits, you entered 15.</p>
test('помилки оплати оголошуються і ведуть до поля (WCAG 3.3.1)', async ({ page }) => {
await page.goto('/checkout/payment');
await page.getByLabel('Номер картки').fill('411111111111111');
await page.getByRole('button', { name: 'Оплатити' }).press('Enter');
const summary = page.getByRole('alert');
await expect(summary).toContainText('Виправте 2 помилки');
await summary.getByRole('link', { name: /16 цифр/ }).press('Enter');
const card = page.getByLabel('Номер картки');
await expect(card).toBeFocused();
await expect(card).toHaveAttribute('aria-invalid', 'true');
await expect(card).toHaveAccessibleDescription(/введено 15/);
await expect(card).toHaveValue('411111111111111'); // введене не втрачено
});
test('payment errors are announced and lead to the field (WCAG 3.3.1)', async ({ page }) => {
await page.goto('/checkout/payment');
await page.getByLabel('Card number').fill('411111111111111');
await page.getByRole('button', { name: 'Pay' }).press('Enter');
const summary = page.getByRole('alert');
await expect(summary).toContainText('Fix 2 errors');
await summary.getByRole('link', { name: /16 digits/ }).press('Enter');
const card = page.getByLabel('Card number');
await expect(card).toBeFocused();
await expect(card).toHaveAttribute('aria-invalid', 'true');
await expect(card).toHaveAccessibleDescription(/entered 15/);
await expect(card).toHaveValue('411111111111111'); // input is not lost
});
Що це. Live region — область сторінки, зміни в якій скрінрідер оголошує сам, навіть якщо фокус деінде. Без неї незряча людина не дізнається, що товар додано в кошик, що фільтр знайшов 12 результатів чи що збереження завершилось. Це 4.1.3 Status Messages (AA): статусні повідомлення програмно визначені, щоб їх можна було оголосити без отримання фокусу.
What it is. A live region is an area of the page whose changes the screen reader announces on its own, even when focus is elsewhere. Without it a blind person never learns that the item went into the cart, that the filter found 12 results, or that saving finished. This is 4.1.3 Status Messages (AA): status messages are programmatically determined so they can be announced without receiving focus.
Варіанти. aria-live=polite — дочекатися паузи і прочитати (більшість випадків). aria-live=assertive — перервати поточне мовлення (лише для термінового). role=status = polite + aria-atomic=true: для «Збережено», «Знайдено 12 товарів», «Додано в кошик». role=alert = assertive: для помилок і попереджень, що потребують уваги зараз, — «З'єднання втрачено». role=log — для чату, де додаються нові рядки. aria-atomic вирішує, читати всю область чи лише змінений вузол.
Options. aria-live=polite — wait for a pause, then read (most cases). aria-live=assertive — interrupt current speech (urgent only). role=status = polite + aria-atomic=true: for «Saved», «12 products found», «Added to cart». role=alert = assertive: for errors and warnings that need attention now — «Connection lost». role=log — for a chat where new lines are appended. aria-atomic decides whether to read the whole region or only the changed node.
Головна пастка. Регіон має існувати в DOM до зміни вмісту. Якщо контейнер з role=status вставляється разом з текстом (типовий тост), багато скрінрідерів нічого не скажуть. Правильно: порожній контейнер у розмітці з самого початку, потім скрипт кладе в нього текст.
The main trap. The region must exist in the DOM before the content changes. If the container with role=status is inserted together with its text (a typical toast), many screen readers say nothing. The right way: an empty container in the markup from the start, then the script puts text into it.
Інші помилки. aria-live на великому блоці зі списком товарів — скрінрідер зачитує весь список при кожному оновленні. Assertive для всього. Тост, що зникає за 2 секунди — зрячі з повільним читанням не встигають (2.2.1). Однаковий текст двічі поспіль може не оголоситися вдруге.
Other mistakes. aria-live on a large block holding the product list — the screen reader reads the whole list on every update. Assertive for everything. A toast that disappears after 2 seconds — sighted slow readers cannot catch it (2.2.1). The same text twice in a row may not be announced the second time.
Як тестую. Лише зі скрінрідером: NVDA з увімкненим Speech Viewer показує, що саме і коли прозвучало. Автотест перевіряє, що текст потрапив у role=status.
How I test. Only with a screen reader: NVDA with Speech Viewer on shows exactly what was said and when. An automated test checks that the text landed in role=status.
<!-- ПОГАНО: контейнер вставляється разом з текстом, оголошення може не бути -->
<script>
document.body.insertAdjacentHTML('beforeend', '<div role="status">Додано в кошик</div>');
</script>
<!-- ДОБРЕ: порожній регіон є в DOM від початку -->
<div id="cart-status" role="status"></div>
<div id="net-alert" role="alert"></div>
<script>
// пізніше, після дії користувача
document.getElementById('cart-status').textContent = 'Навушники додано в кошик. Товарів: 3';
// для термінового
document.getElementById('net-alert').textContent = 'З\'єднання втрачено. Зміни не збережено.';
</script>
<!-- BAD: the container is inserted with its text, it may not be announced -->
<script>
document.body.insertAdjacentHTML('beforeend', '<div role="status">Added to cart</div>');
</script>
<!-- GOOD: the empty region is in the DOM from the start -->
<div id="cart-status" role="status"></div>
<div id="net-alert" role="alert"></div>
<script>
// later, after a user action
document.getElementById('cart-status').textContent = 'Headphones added to cart. Items: 3';
// for something urgent
document.getElementById('net-alert').textContent = 'Connection lost. Changes not saved.';
</script>
await page.getByRole('button', { name: 'Додати в кошик: Навушники' }).click();
await expect(page.getByRole('status')).toHaveText('Навушники додано в кошик. Товарів: 3');
// фокус лишився на кнопці: оголошення не повинно його забирати
await expect(page.getByRole('button', { name: 'Додати в кошик: Навушники' })).toBeFocused();
await page.getByRole('button', { name: 'Add to cart: Headphones' }).click();
await expect(page.getByRole('status')).toHaveText('Headphones added to cart. Items: 3');
// focus stayed on the button: an announcement must not take it
await expect(page.getByRole('button', { name: 'Add to cart: Headphones' })).toBeFocused();
role=status і role=alert порожні в початковій розмітці, скрипт лише змінює textContent. Тест перевіряє обидві половини 4.1.3: повідомлення програмно визначене, і фокус при цьому не переміщувався. Що саме озвучить скрінрідер, автотест не скаже — це перевіряю руками.role=status and role=alert regions are empty in the initial markup, and the script only changes textContent. The test checks both halves of 4.1.3: the message is programmatically determined, and focus did not move. What the screen reader actually says, an automated test cannot tell — that I check by hand.Чому SPA окремий випадок. У класичному сайті перехід за посиланням перезавантажує сторінку: браузер оновлює заголовок вкладки, скидає фокус на початок, скрінрідер оголошує нову сторінку. У SPA JavaScript просто замінює вміст — скрінрідер мовчить, фокус лишається на посиланні, якого вже немає (і падає на <body>), а document.title часто не змінюється (2.4.2 Page Titled).
Why an SPA is special. On a classic site following a link reloads the page: the browser updates the tab title, resets focus to the top, the screen reader announces the new page. In an SPA JavaScript just swaps the content — the screen reader stays silent, focus stays on a link that no longer exists (and falls to <body>), and document.title often does not change (2.4.2 Page Titled).
Що має відбуватися при зміні маршруту. 1) Оновлено document.title («Кошик — Shop»). 2) Фокус перенесено на <h1> нової сторінки (з tabindex=-1) або на контейнер вмісту — або новий заголовок оголошено через live region; вибір узгоджують з командою, головне — однаково на всіх маршрутах. 3) Прокрутка на початок. 4) Кнопка «Назад» повертає і вміст, і розумний фокус.
What should happen on a route change. 1) document.title is updated («Cart — Shop»). 2) Focus moves to the new page's <h1> (with tabindex=-1) or to the content container — or the new title is announced through a live region; the team agrees on the choice, and what matters is doing it the same way on every route. 3) Scroll resets to the top. 4) The Back button restores both the content and a sensible focus.
Завантаження і динаміка. Під час завантаження — aria-busy=true на області і оголошення «Завантаження…» та «Знайдено 20 товарів» у role=status. Нескінченна прокрутка робить футер недосяжним з клавіатури — потрібна кнопка «Показати ще» з переносом фокусу на перший новий елемент. Перемальовування компонента (зміна key у React) знищує вузол у фокусі — фокус губиться; перевіряю після кожної дії, що змінює список: видалення рядка, фільтр, пагінація.
Loading and dynamic content. While loading — aria-busy=true on the region and «Loading…» and «20 products found» announcements in a role=status. Infinite scroll makes the footer unreachable from the keyboard — a «Show more» button is needed, with focus moved to the first new item. Re-rendering a component (a key change in React) destroys the focused node and focus is lost; I check after every action that changes a list: deleting a row, filtering, paging.
Трейд-оф. Перенос фокусу на <h1> надійний і пояснює контекст, але ховає навігацію «позаду». Оголошення через live region не змінює фокус, але залежить від скрінрідера. У тест-плані я фіксую обраний патерн і перевіряю саме його автотестом на кількох маршрутах.
Trade-off. Moving focus to the <h1> is reliable and explains the context, but puts the navigation «behind» the user. Announcing via a live region leaves focus alone but depends on the screen reader. In the test plan I record the chosen pattern and check exactly that with an automated test on several routes.
import { test, expect } from '@playwright/test';
test('перехід у SPA оновлює title і переносить фокус (2.4.2, 2.4.3)', async ({ page }) => {
await page.goto('/catalog');
await page.getByRole('link', { name: 'Кошик' }).press('Enter');
await expect(page).toHaveURL(/\/cart$/);
await expect(page).toHaveTitle('Кошик — Shop');
await expect(page.getByRole('heading', { level: 1, name: 'Кошик' })).toBeFocused();
});
test('після видалення товару фокус не падає на body', async ({ page }) => {
await page.goto('/cart');
await page.getByRole('button', { name: 'Видалити: Навушники' }).press('Enter');
await expect(page.getByRole('status')).toHaveText(/Навушники видалено/);
const tag = await page.evaluate(() => document.activeElement.tagName);
expect(tag).not.toBe('BODY');
});
import { test, expect } from '@playwright/test';
test('SPA navigation updates the title and moves focus (2.4.2, 2.4.3)', async ({ page }) => {
await page.goto('/catalog');
await page.getByRole('link', { name: 'Cart' }).press('Enter');
await expect(page).toHaveURL(/\/cart$/);
await expect(page).toHaveTitle('Cart — Shop');
await expect(page.getByRole('heading', { level: 1, name: 'Cart' })).toBeFocused();
});
test('focus does not fall to body after removing an item', async ({ page }) => {
await page.goto('/cart');
await page.getByRole('button', { name: 'Remove: Headphones' }).press('Enter');
await expect(page.getByRole('status')).toHaveText(/Headphones removed/);
const tag = await page.evaluate(() => document.activeElement.tagName);
expect(tag).not.toBe('BODY');
});
<h1>. Другий ловить найпідступніший баг SPA — втрату фокусу після того, як вузол у фокусі зник. press('Enter') замість click() відтворює поведінку клавіатурного користувача.<h1>. The second catches the sneakiest SPA bug — focus loss after the focused node disappears. press('Enter') instead of click() reproduces keyboard user behaviour.Навіщо. Людям зі скрінрідером, з моторними чи когнітивними обмеженнями, з дислексією потрібно в рази більше часу на форму. Жорсткий таймер у 5 хвилин, після якого все зникає, для них означає неможливість завершити дію.
Why. People using screen readers, people with motor or cognitive impairments, people with dyslexia may need many times longer to complete a form. A hard 5-minute timer after which everything vanishes means they simply cannot finish.
2.2.1 Timing Adjustable (A). Для кожного обмеження часу, яке встановлює контент, виконується хоча б одне: людина може вимкнути ліміт; або змінити його щонайменше в 10 разів; або її попереджають до закінчення і дають щонайменше 20 секунд продовжити простою дією (натиснути кнопку), і так щонайменше 10 разів. Винятки: реальні події (аукціон), коли ліміт суттєвий для дії (іспит на час), і ліміти від 20 годин. Пов'язані критерії рівня AAA: 2.2.5 — після повторного входу дані не втрачаються; 2.2.6 — попередити про тривалість неактивності, що веде до втрати даних.
2.2.1 Timing Adjustable (A). For every time limit set by the content, at least one holds: the user can turn the limit off; or adjust it to at least 10 times the default; or they are warned before it expires and given at least 20 seconds to extend it with a simple action (pressing a button), at least 10 times. Exceptions: real-time events (an auction), when the limit is essential (a timed exam), and limits of 20 hours or more. Related AAA criteria: 2.2.5 — no data loss after re-authenticating; 2.2.6 — warn about the inactivity duration that causes data loss.
Як це виглядає правильно. За 2 хвилини до завершення — діалог «Сесія завершиться через 2 хвилини» з кнопками «Продовжити» і «Вийти»; фокус переходить у діалог, текст оголошується; відлік не оголошується щосекунди. Після таймауту введені дані зберігаються, а після входу людина повертається туди, де була.
What good looks like. Two minutes before expiry — a «Your session ends in 2 minutes» dialog with «Stay signed in» and «Sign out» buttons; focus moves into the dialog and the text is announced; the countdown is not announced every second. After a timeout the typed data is kept, and after signing in the person returns to where they were.
Як тестую. Чекати 15 хвилин не треба: у Playwright є page.clock для керування часом сторінки, а бекенду можна зменшити таймаут у тестовому середовищі. Перевіряю, що попередження з'являється, доступне з клавіатури й оголошується, а «Продовжити» справді продовжує сесію на сервері.
How I test. There is no need to wait 15 minutes: Playwright has page.clock to control page time, and the backend timeout can be shortened in the test environment. I check that the warning appears, is keyboard accessible and announced, and that «Stay signed in» really extends the session on the server.
test('попередження про кінець сесії можна продовжити (WCAG 2.2.1)', async ({ page }) => {
await page.clock.install(); // керуємо таймерами сторінки
await page.goto('/checkout/address');
await page.getByLabel('Місто').fill('Львів');
await page.clock.fastForward('13:00'); // 13 хвилин неактивності
const warning = page.getByRole('alertdialog', { name: /Сесія завершиться/ });
await expect(warning).toBeVisible();
await expect(warning.getByRole('button', { name: 'Продовжити' })).toBeFocused();
await page.keyboard.press('Enter'); // проста дія продовжує сесію
await expect(warning).toBeHidden();
await expect(page.getByLabel('Місто')).toHaveValue('Львів'); // дані не втрачено
});
test('session end warning can be extended (WCAG 2.2.1)', async ({ page }) => {
await page.clock.install(); // we control the page timers
await page.goto('/checkout/address');
await page.getByLabel('City').fill('Lviv');
await page.clock.fastForward('13:00'); // 13 minutes of inactivity
const warning = page.getByRole('alertdialog', { name: /Your session ends/ });
await expect(warning).toBeVisible();
await expect(warning.getByRole('button', { name: 'Stay signed in' })).toBeFocused();
await page.keyboard.press('Enter'); // a simple action extends it
await expect(warning).toBeHidden();
await expect(page.getByLabel('City')).toHaveValue('Lviv'); // data not lost
});
clock.install() має бути викликаний до goto, щоб перехопити таймери сторінки з самого початку. fastForward('13:00') прокручує час на 13 хвилин і запускає всі таймери, що мали спрацювати. alertdialog — роль діалогу, який вимагає негайної реакції; він оголошується при появі.clock.install() has to be called before goto to take over the page timers from the start. fastForward('13:00') advances time by 13 minutes and fires every timer that was due. alertdialog is the role for a dialog that needs an immediate response; it is announced when it appears.Вимоги. 1.4.3 Contrast (Minimum), AA: звичайний текст — щонайменше 4.5:1 до фону, великий текст — 3:1. Великий — це від 18pt (24 CSS px) звичайного накреслення або від 14pt (приблизно 18.66 CSS px) жирного. 1.4.6 Contrast (Enhanced), AAA: 7:1 і 4.5:1. Винятки: неактивні елементи, декоративний текст, логотипи. Коефіцієнт може бути від 1:1 (білий на білому) до 21:1 (чорний на білому).
Requirements. 1.4.3 Contrast (Minimum), AA: normal text needs at least 4.5:1 against its background, large text 3:1. Large means 18pt (24 CSS px) regular or 14pt (about 18.66 CSS px) bold and up. 1.4.6 Contrast (Enhanced), AAA: 7:1 and 4.5:1. Exceptions: inactive elements, decorative text, logos. The ratio ranges from 1:1 (white on white) to 21:1 (black on white).
Формула. Для кожного кольору рахують відносну яскравість L: канали sRGB переводять у лінійні (значення до 0.04045 ділять на 12.92, інші — ((c + 0.055) / 1.055) у степені 2.4) і зважують: L = 0.2126·R + 0.7152·G + 0.0722·B. Коефіцієнт = (L світлішого + 0.05) / (L темнішого + 0.05). Зелений канал важить найбільше, тому жовтий на білому майже нечитабельний.
The formula. For each colour compute relative luminance L: convert the sRGB channels to linear (values up to 0.04045 are divided by 12.92, the rest become ((c + 0.055) / 1.055) to the power 2.4) and weight them: L = 0.2126·R + 0.7152·G + 0.0722·B. Ratio = (L of the lighter + 0.05) / (L of the darker + 0.05). Green carries the most weight, which is why yellow on white is nearly unreadable.
Порогові значення, які варто пам'ятати. #767676 на білому дає 4.54:1 — найсвітліший сірий, що проходить AA. #777777 — вже 4.48:1, провал. Популярний #999999 дає лише 2.85:1.
Thresholds worth remembering. #767676 on white gives 4.54:1 — the lightest grey that passes AA. #777777 is already 4.48:1, a fail. The popular #999999 gives only 2.85:1.
Пастки. Округлення не дозволене: 4.48 — це провал, а не «майже 4.5». Текст на зображенні чи градієнті перевіряють у найгіршій точці. Прозорість (opacity, rgba) змінює фактичний колір. Стани hover, focus і disabled теж мають свій контраст. axe рахує контраст автоматично, але на фоні-картинці чи під накладеними елементами повертає результат «потребує перевірки», а не порушення.
Traps. No rounding is allowed: 4.48 is a fail, not «nearly 4.5». Text on an image or gradient is checked at its worst point. Transparency (opacity, rgba) changes the effective colour. Hover, focus and disabled states have their own contrast too. axe computes contrast automatically, but over a background image or under overlapping elements it returns «needs review» rather than a violation.
Інструменти. DevTools color picker (показує коефіцієнт і лінії AA/AAA), Colour Contrast Analyser від TPGi, WebAIM Contrast Checker.
Tools. The DevTools colour picker (shows the ratio and AA/AAA lines), TPGi Colour Contrast Analyser, WebAIM Contrast Checker.
// відносна яскравість кольору за WCAG
function luminance(hex) {
const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255)
.map((c) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)));
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function contrast(a, b) {
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
}
contrast('#767676', '#ffffff'); // 4.54 -> AA для звичайного тексту: так
contrast('#777777', '#ffffff'); // 4.48 -> AA: ні (округлювати не можна)
contrast('#999999', '#ffffff'); // 2.85 -> провал навіть для великого тексту
// вручну для #767676: 0x76 = 118 -> 0.4627 -> лінійне 0.1812
// L = 0.1812 (сірий: усі канали однакові), білий L = 1.0
// (1.0 + 0.05) / (0.1812 + 0.05) = 1.05 / 0.2312 = 4.54
// relative luminance of a colour per WCAG
function luminance(hex) {
const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255)
.map((c) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)));
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function contrast(a, b) {
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
}
contrast('#767676', '#ffffff'); // 4.54 -> AA for normal text: yes
contrast('#777777', '#ffffff'); // 4.48 -> AA: no (no rounding allowed)
contrast('#999999', '#ffffff'); // 2.85 -> fails even for large text
// by hand for #767676: 0x76 = 118 -> 0.4627 -> linear 0.1812
// L = 0.1812 (grey: all channels equal), white L = 1.0
// (1.0 + 0.05) / (0.1812 + 0.05) = 1.05 / 0.2312 = 4.54
Що це. 1.4.11 Non-text Contrast (AA): візуальна інформація, потрібна, щоб розпізнати елемент керування чи його стан, і значущі графічні об'єкти мають контраст щонайменше 3:1 до сусідніх кольорів.
What it is. 1.4.11 Non-text Contrast (AA): visual information needed to identify a user interface component or its state, and meaningful graphical objects, have at least 3:1 contrast against adjacent colours.
Що охоплює. 1) Межі полів і кнопок, якщо без них не зрозуміло, що це поле: світло-сіра рамка #cccccc на білому дає 1.6:1 — людина зі слабким зором бачить форму як порожню сторінку з підписами. 2) Стани: позначений чекбокс, вибрана радіокнопка, активна вкладка, індикатор фокусу, перемикач увімк/вимк. 3) Іконки, що несуть сенс без тексту: лупа пошуку, хрестик закриття. 4) Частини графіків, потрібні для розуміння: лінії, сегменти діаграми, точки.
What it covers. 1) Borders of fields and buttons when you cannot tell it is a field without them: a light grey #cccccc border on white gives 1.6:1 — a person with low vision sees the form as an empty page with captions. 2) States: a checked checkbox, a selected radio, the active tab, the focus indicator, an on/off toggle. 3) Icons that carry meaning without text: a search magnifier, a close cross. 4) Chart parts needed to understand it: lines, pie segments, points.
Що не охоплює. Неактивні (disabled) елементи; елементи, вигляд яких визначає браузер і автор його не змінював; декоративну графіку; логотипи; графіки, де ті самі дані є поруч у тексті чи таблиці. Кнопка з текстом на кольоровому фоні може не мати контрастної межі — її розпізнають за текстом, який має пройти 1.4.3.
What it does not cover. Disabled elements; elements whose appearance is set by the browser and not changed by the author; decorative graphics; logos; charts whose data is also available as text or a table nearby. A text button on a coloured background may have no contrasting boundary — it is identified by its text, which must pass 1.4.3.
Типові провали. Поле з лише нижньою світлою лінією. Чекбокс, у якому стан «вибрано» відрізняється відтінком синього. Лінії двох серій графіка, розрізнені лише кольором (ще й 1.4.1). Індикатор фокусу — світла тінь.
Typical failures. A field with only a faint bottom line. A checkbox whose checked state differs by a shade of blue. Two chart series told apart only by colour (a 1.4.1 failure too). A focus indicator that is a faint glow.
Як тестую. Піпеткою в Colour Contrast Analyser міряю межу поля, іконку і кожен стан відносно фону. axe цього не перевіряє: його правило color-contrast стосується лише тексту.
How I test. With the Colour Contrast Analyser eyedropper I measure the field border, the icon and each state against the background. axe does not check this: its color-contrast rule covers text only.
/* ПРОВАЛ 1.4.11: межа поля #cccccc на #ffffff = 1.61:1 */
input[type=text] { border: 1px solid #cccccc; }
/* ОК: #8a8a8a на #ffffff = 3.45:1, поле видно */
input[type=text] { border: 1px solid #8a8a8a; }
/* ПРОВАЛ: вибраний і невибраний чекбокс розрізняються відтінком */
.check { background: #dbe7ff; }
.check[aria-checked=true] { background: #c7d9ff; }
/* ОК: вибраний стан має контрастну заливку і галочку */
.check[aria-checked=true] { background: #1a73e8; } /* 4.5:1 до білого */
.check[aria-checked=true]::after { content: '✓'; color: #ffffff; }
/* FAILS 1.4.11: field border #cccccc on #ffffff = 1.61:1 */
input[type=text] { border: 1px solid #cccccc; }
/* OK: #8a8a8a on #ffffff = 3.45:1, the field is visible */
input[type=text] { border: 1px solid #8a8a8a; }
/* FAILS: checked and unchecked differ by a shade */
.check { background: #dbe7ff; }
.check[aria-checked=true] { background: #c7d9ff; }
/* OK: the checked state has a contrasting fill and a tick */
.check[aria-checked=true] { background: #1a73e8; } /* 4.5:1 to white */
.check[aria-checked=true]::after { content: '✓'; color: #ffffff; }
Проблема. Близько 8 % чоловіків і 0.5 % жінок мають порушення кольоросприйняття, найчастіше червоно-зеленого. Для них зелений бейдж «Оплачено» і червоний «Прострочено» однакові. Скрінрідер колір не озвучує взагалі. На сонці чи монохромному дисплеї відтінки теж губляться. Критерій 1.4.1 Use of Color (A): колір не може бути єдиним візуальним способом передати інформацію, вказати дію, попросити реакцію чи розрізнити елементи.
The problem. About 8 % of men and 0.5 % of women have a colour vision deficiency, most often red-green. To them a green «Paid» badge and a red «Overdue» badge look the same. A screen reader does not announce colour at all. In sunlight or on a monochrome display shades get lost too. Criterion 1.4.1 Use of Color (A): colour cannot be the only visual means of conveying information, indicating an action, prompting a response or distinguishing an element.
Де трапляється. Статуси в таблицях, помилки в полях (лише червона рамка), обов'язкові поля (лише червона зірочка без пояснення), лінії графіків і легенда, дні в календарі (зайняті — сірі), посилання в тексті без підкреслення, «правильні» і «неправильні» відповіді в тесті.
Where it happens. Statuses in tables, field errors (a red border only), required fields (a red asterisk with no explanation), chart lines and legends, calendar days (booked ones in grey), links in text without an underline, «right» and «wrong» answers in a quiz.
Як виправити. Додати другий канал: текст («Прострочено»), іконку з різною формою, візерунок чи підпис на графіку, підкреслення посилання. Для посилань без підкреслення WCAG дозволяє варіант з контрастом 3:1 до навколишнього тексту плюс інший візуальний сигнал при наведенні й фокусі, але підкреслення надійніше.
How to fix it. Add a second channel: text («Overdue»), an icon with a different shape, a pattern or label on a chart, an underline on the link. For links without an underline WCAG allows 3:1 contrast against the surrounding text plus another visual cue on hover and focus, but an underline is more robust.
Як тестую. Режим відтінків сірого або симуляція протанопії/дейтеранопії в Chrome DevTools (Rendering → Emulate vision deficiencies) і питання: «чи можу я все ще прочитати статус?». Скрінрідер: чи чую статус словами. Автоматично це не ловиться, хіба що скриншот у відтінках сірого для рев'ю.
How I test. Greyscale mode or protanopia/deuteranopia simulation in Chrome DevTools (Rendering → Emulate vision deficiencies), asking «can I still read the status?». A screen reader: do I hear the status in words. This is not caught automatically, except for a greyscale screenshot to review.
<!-- ПОГАНО: зміст лише в кольорі кружечка -->
<td><span class="dot dot-green"></span></td>
<td><span class="dot dot-red"></span></td>
<!-- ДОБРЕ: текст і різна форма іконки, колір лише підсилює -->
<td><span class="badge badge-ok"><svg aria-hidden="true">✓</svg> Оплачено</span></td>
<td><span class="badge badge-late"><svg aria-hidden="true">!</svg> Прострочено</span></td>
// скриншот у відтінках сірого для рев'ю 1.4.1
await page.goto('/invoices');
await page.addStyleTag({ content: 'html { filter: grayscale(1); }' });
await page.screenshot({ path: 'invoices-grayscale.png', fullPage: true });
<!-- BAD: the meaning is only in the dot colour -->
<td><span class="dot dot-green"></span></td>
<td><span class="dot dot-red"></span></td>
<!-- GOOD: text and a different icon shape, colour only reinforces -->
<td><span class="badge badge-ok"><svg aria-hidden="true">✓</svg> Paid</span></td>
<td><span class="badge badge-late"><svg aria-hidden="true">!</svg> Overdue</span></td>
// greyscale screenshot for a 1.4.1 review
await page.goto('/invoices');
await page.addStyleTag({ content: 'html { filter: grayscale(1); }' });
await page.screenshot({ path: 'invoices-grayscale.png', fullPage: true });
grayscale(1) — простий спосіб подивитися на сторінку «без кольору» в автоматизованому прогоні і приклеїти скриншот до рев'ю дизайну.grayscale(1) CSS filter is a simple way to see the page «without colour» in an automated run and attach the screenshot to a design review.Кого зачіпає. Людей з вестибулярними розладами: паралакс, масштабування на весь екран, прокрутка-«стрибок» і анімовані переходи між сторінками викликають нудоту й запаморочення. Людей з СДУГ і когнітивними особливостями: каруселі й анімовані банери відволікають від задачі. Людей з фоточутливою епілепсією: спалахи можуть спричинити напад.
Who it affects. People with vestibular disorders: parallax, full-screen zooms, scroll-jacking and animated page transitions cause nausea and dizziness. People with ADHD and cognitive differences: carousels and animated banners pull attention from the task. People with photosensitive epilepsy: flashing can trigger a seizure.
Критерії. 2.2.2 Pause, Stop, Hide (A): рух, мерехтіння чи автооновлення, що стартує само, триває понад 5 секунд і показується поруч з іншим контентом, можна призупинити, зупинити чи приховати — карусель без кнопки паузи провалює. 2.3.1 Three Flashes or Below Threshold (A): не більше трьох спалахів за секунду. 2.3.3 Animation from Interactions (AAA): рух, спричинений взаємодією, можна вимкнути, якщо він не суттєвий.
Criteria. 2.2.2 Pause, Stop, Hide (A): motion, blinking or auto-updating that starts by itself, lasts over 5 seconds and runs alongside other content can be paused, stopped or hidden — a carousel with no pause button fails. 2.3.1 Three Flashes or Below Threshold (A): no more than three flashes per second. 2.3.3 Animation from Interactions (AAA): motion triggered by interaction can be turned off unless it is essential.
prefers-reduced-motion. Медіазапит, що відображає налаштування ОС: «Зменшити рух» у macOS/iOS, «Показувати анімацію» у Windows, «Вимкнути анімацію» в Android. Сайт має реагувати: прибрати паралакс, автопрокрутку каруселі, великі переміщення; залишити прості зміни прозорості. Це не «вимкнути всі переходи» — зникнення індикатора завантаження теж погано.
prefers-reduced-motion. A media query reflecting the OS setting: «Reduce motion» on macOS/iOS, «Show animations» on Windows, «Remove animations» on Android. The site should respond: drop parallax, carousel auto-advance and big movements; keep simple opacity changes. It is not «switch off every transition» — a loading indicator disappearing is bad too.
Як тестую. Вмикаю налаштування ОС або емулюю медіа-функцію в DevTools (Rendering → prefers-reduced-motion) чи в Playwright через emulateMedia, і перевіряю, що каруселі не крутяться самі, паралакс вимкнений, а тривалість анімацій близька до нуля. Каруселі без reduced motion перевіряю на кнопку паузи.
How I test. I turn on the OS setting or emulate the media feature in DevTools (Rendering → prefers-reduced-motion) or in Playwright via emulateMedia, and check that carousels do not rotate by themselves, parallax is off and animation durations are close to zero. Without reduced motion, I check carousels for a pause button.
/* повний рух за замовчуванням */
.hero { animation: slide-in 600ms ease-out; }
.parallax { transform: translateY(calc(var(--scroll) * -0.3px)); }
/* користувач попросив менше руху в налаштуваннях ОС */
@media (prefers-reduced-motion: reduce) {
.hero { animation: fade-in 150ms linear; } /* лише прозорість */
.parallax { transform: none; }
html { scroll-behavior: auto; }
}
/* full motion by default */
.hero { animation: slide-in 600ms ease-out; }
.parallax { transform: translateY(calc(var(--scroll) * -0.3px)); }
/* the user asked for less motion in the OS settings */
@media (prefers-reduced-motion: reduce) {
.hero { animation: fade-in 150ms linear; } /* opacity only */
.parallax { transform: none; }
html { scroll-behavior: auto; }
}
test('карусель не крутиться сама при reduced motion', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto('/');
const current = page.getByRole('group', { name: /Слайд 1 з/ });
await expect(current).toBeVisible();
await page.waitForTimeout(6000); // довше за типовий інтервал автоперемикання
await expect(current).toBeVisible(); // той самий слайд
await expect(page.locator('.parallax')).toHaveCSS('transform', 'none');
});
test('carousel does not auto-rotate with reduced motion', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto('/');
const current = page.getByRole('group', { name: /Slide 1 of/ });
await expect(current).toBeVisible();
await page.waitForTimeout(6000); // longer than a typical auto-advance interval
await expect(current).toBeVisible(); // still the same slide
await expect(page.locator('.parallax')).toHaveCSS('transform', 'none');
});
emulateMedia({ reducedMotion: 'reduce' }) вмикає ту саму гілку CSS, що й налаштування ОС. waitForTimeout тут свідомий: ми перевіряємо, що за 6 секунд нічого не сталося; для швидшого тесту можна керувати часом через page.clock.emulateMedia({ reducedMotion: 'reduce' }) turns on the same CSS branch as the OS setting. The waitForTimeout is deliberate here: we check that nothing happened over 6 seconds; for a faster test, control time with page.clock.Три критерії. 1.4.4 Resize Text (AA): текст збільшується до 200 % без втрати вмісту чи функцій. 1.4.10 Reflow (AA): при ширині 320 CSS px (це 1280 px на 400 % масштабу) вміст читається без горизонтальної прокрутки — лише вертикальна; винятки — таблиці даних, карти, схеми, редактори. 1.4.12 Text Spacing (AA): якщо користувач задасть міжрядковий інтервал 1.5, відступ після абзацу 2, міжлітерний 0.12 і міжслівний 0.16 від розміру шрифту, нічого не обрізається і не накладається.
Three criteria. 1.4.4 Resize Text (AA): text scales up to 200 % without loss of content or function. 1.4.10 Reflow (AA): at a width of 320 CSS px (1280 px at 400 % zoom) content reads without horizontal scrolling — vertical only; exceptions are data tables, maps, diagrams, editors. 1.4.12 Text Spacing (AA): if the user sets line height to 1.5, paragraph spacing to 2, letter spacing to 0.12 and word spacing to 0.16 times the font size, nothing is clipped or overlaps.
Кому це потрібно. Людям зі слабким зором, які збільшують масштаб браузера, а не використовують екранну лупу; людям з дислексією, які застосовують власні стилі з більшими інтервалами.
Who needs it. Low-vision users who zoom the browser rather than use a screen magnifier; people with dyslexia who apply their own styles with wider spacing.
Як тестую. Масштаб 200 % і 400 % (Ctrl/Cmd +) на ширині вікна 1280 px, або одразу ширина 320 px у DevTools. Шукаю: горизонтальну прокрутку сторінки, обрізані кнопки й тексти з фіксованою висотою, липку шапку, що займає пів екрана, модалку, яку неможливо прокрутити до кнопки «Зберегти», меню без бургера. Для text spacing — букмарклет або ін'єкція CSS з чотирма значеннями.
How I test. Zoom to 200 % and 400 % (Ctrl/Cmd +) with a 1280 px window, or set the width straight to 320 px in DevTools. I look for: horizontal page scroll, clipped buttons and fixed-height text boxes, a sticky header eating half the screen, a modal that cannot be scrolled down to its «Save» button, a menu with no hamburger. For text spacing — a bookmarklet or injected CSS with the four values.
Типові баги. height у пікселях на кнопці з текстом, overflow:hidden на картках, white-space:nowrap, <meta viewport> з maximum-scale=1 чи user-scalable=no — забороняє pinch-zoom на мобільному (axe meta-viewport).
Typical bugs. height in pixels on a button with text, overflow:hidden on cards, white-space:nowrap, a <meta viewport> with maximum-scale=1 or user-scalable=no — it blocks pinch zoom on mobile (axe meta-viewport).
test('чекаут без горизонтального скролу на 320 px (WCAG 1.4.10)', async ({ page }) => {
await page.setViewportSize({ width: 320, height: 640 });
await page.goto('/checkout');
const { scrollWidth, clientWidth } = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
}));
expect(scrollWidth).toBeLessThanOrEqual(clientWidth);
await expect(page.getByRole('button', { name: 'Оплатити' })).toBeInViewport({ ratio: 1 });
});
test('text spacing не обрізає текст (WCAG 1.4.12)', async ({ page }) => {
await page.goto('/checkout');
await page.addStyleTag({ content:
'* { line-height: 1.5 !important; letter-spacing: 0.12em !important;' +
' word-spacing: 0.16em !important; } p { margin-bottom: 2em !important; }' });
// шукаємо елементи, де текст вилазить за межі й обрізається
const clipped = await page.evaluate(() => [...document.querySelectorAll('button, a, label, p')]
.filter((el) => el.scrollHeight > el.clientHeight + 1 && getComputedStyle(el).overflow === 'hidden')
.map((el) => el.textContent.trim()));
expect(clipped).toEqual([]);
});
test('checkout has no horizontal scroll at 320 px (WCAG 1.4.10)', async ({ page }) => {
await page.setViewportSize({ width: 320, height: 640 });
await page.goto('/checkout');
const { scrollWidth, clientWidth } = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
}));
expect(scrollWidth).toBeLessThanOrEqual(clientWidth);
await expect(page.getByRole('button', { name: 'Pay' })).toBeInViewport({ ratio: 1 });
});
test('text spacing does not clip text (WCAG 1.4.12)', async ({ page }) => {
await page.goto('/checkout');
await page.addStyleTag({ content:
'* { line-height: 1.5 !important; letter-spacing: 0.12em !important;' +
' word-spacing: 0.16em !important; } p { margin-bottom: 2em !important; }' });
// find elements whose text overflows and gets clipped
const clipped = await page.evaluate(() => [...document.querySelectorAll('button, a, label, p')]
.filter((el) => el.scrollHeight > el.clientHeight + 1 && getComputedStyle(el).overflow === 'hidden')
.map((el) => el.textContent.trim()));
expect(clipped).toEqual([]);
});
overflow:hidden, у яких вміст став вищим за контейнер. page.evaluate повертає лише серіалізовані значення, тому з елемента беремо два числа.overflow:hidden whose content grew taller than the box. page.evaluate returns only serialisable values, so we take two numbers from the element.Чому бар'єр. Незрячий користувач чує сторінку через синтезатор мовлення. Якщо на сторінці одразу заграє відео зі звуком, воно перекриває голос скрінрідера, і людина не може знайти, де кнопка паузи, бо не чує, що читається. Людям з когнітивними труднощами і сенсорною чутливістю несподіваний звук заважає зосередитися. Плюс звичайний дискомфорт у відкритому офісі.
Why it is a barrier. A blind user hears the page through a speech synthesiser. If a video with sound starts playing immediately, it drowns out the screen reader voice, and the person cannot find the pause button because they cannot hear what is being read. For people with cognitive difficulties and sensory sensitivity unexpected sound makes it hard to focus. Plus the ordinary discomfort in an open-plan office.
Критерії. 1.4.2 Audio Control (A): якщо аудіо на сторінці відтворюється автоматично довше 3 секунд, має бути механізм паузи чи зупинки або регулювання гучності, незалежне від системної гучності. Це один з «невтручальних» критеріїв: його провал ламає відповідність усієї сторінки. 2.2.2 Pause, Stop, Hide (A): беззвучне фонове відео, що рухається довше 5 секунд, теж потребує паузи.
Criteria. 1.4.2 Audio Control (A): if audio on a page plays automatically for more than 3 seconds, there must be a way to pause or stop it, or to control its volume independently of the system volume. It is one of the «non-interference» criteria: failing it breaks conformance of the whole page. 2.2.2 Pause, Stop, Hide (A): a silent background video that moves for more than 5 seconds also needs a pause.
Найкраща практика. Не запускати звук автоматично взагалі. Фонове відео — без звуку, з видимою кнопкою паузи, і зупиняється при prefers-reduced-motion. Кнопка паузи — перший фокусований елемент після відкриття, з доступним ім'ям.
Best practice. Do not start sound automatically at all. Background video is muted, has a visible pause button and stops under prefers-reduced-motion. The pause button is the first focusable element after opening, with an accessible name.
Як тестую. Відкриваю сторінку з увімкненим NVDA: чи чую я скрінрідер. Перевіряю атрибути autoplay і muted. Шукаю кнопку паузи з клавіатури. Браузери часто блокують автозапуск зі звуком, але не завжди (наприклад, після взаємодії з сайтом), тому поведінку в тестовому браузері не можна вважати гарантією.
How I test. I open the page with NVDA running: can I hear the screen reader. I check the autoplay and muted attributes. I look for the pause button with the keyboard. Browsers often block autoplay with sound, but not always (for example after interacting with the site), so behaviour in a test browser is no guarantee.
<!-- ПРОВАЛ 1.4.2: звук стартує сам, керування немає -->
<video src="promo.mp4" autoplay></video>
<!-- ОК: беззвучний фон, видима пауза, звук лише за вибором -->
<video id="bg" src="promo.mp4" autoplay muted loop playsinline aria-hidden="true"></video>
<button type="button" aria-pressed="false" id="bg-pause">Пауза фонового відео</button>
const videos = await page.locator('video[autoplay]').evaluateAll((list) =>
list.map((v) => ({ src: v.currentSrc, muted: v.muted })));
// кожне автовідтворюване відео має бути без звуку (WCAG 1.4.2)
expect(videos.filter((v) => !v.muted)).toEqual([]);
await expect(page.getByRole('button', { name: /Пауза/ })).toBeVisible();
<!-- FAILS 1.4.2: sound starts by itself, no controls -->
<video src="promo.mp4" autoplay></video>
<!-- OK: silent background, visible pause, sound only by choice -->
<video id="bg" src="promo.mp4" autoplay muted loop playsinline aria-hidden="true"></video>
<button type="button" aria-pressed="false" id="bg-pause">Pause background video</button>
const videos = await page.locator('video[autoplay]').evaluateAll((list) =>
list.map((v) => ({ src: v.currentSrc, muted: v.muted })));
// every autoplaying video must be muted (WCAG 1.4.2)
expect(videos.filter((v) => !v.muted)).toEqual([]);
await expect(page.getByRole('button', { name: /Pause/ })).toBeVisible();
aria-hidden, а кнопка паузи — окремий видимий елемент з aria-pressed для стану. Тест читає властивість muted, а не атрибут, бо скрипт міг увімкнути звук уже після завантаження.aria-hidden, and the pause button is a separate visible element with aria-pressed for its state. The test reads the muted property, not the attribute, because a script might have unmuted it after load.Субтитри (captions). Синхронізований текст мовлення і значущих звуків: хто говорить, [сміх], [дзвінок телефону], [тривожна музика]. Для глухих і людей з порушенням слуху, а також для перегляду без звуку. 1.2.2 Captions (Prerecorded), A і 1.2.4 Captions (Live), AA. Субтитри-переклад (subtitles) без звуків і без позначення мовців цих критеріїв не закривають.
Captions. Synchronised text of speech and meaningful sounds: who is speaking, [laughter], [phone ringing], [tense music]. For Deaf and hard-of-hearing people, and for watching with the sound off. 1.2.2 Captions (Prerecorded), A and 1.2.4 Captions (Live), AA. Translation subtitles without sounds or speaker labels do not satisfy these criteria.
Транскрипт. Повний текст усього аудіо і, для відео, важливих візуальних подій, розміщений поруч з медіа. Потрібен людям зі сліпоглухотою, що читають брайлівським дисплеєм, тим, хто читає швидше, ніж слухає, людям з когнітивними особливостями; ще й індексується пошуком. Для подкасту (лише аудіо) транскрипт — вимога 1.2.1 (A).
Transcript. The full text of all audio and, for video, the important visual events, placed next to the media. Needed by deafblind people reading on a braille display, by people who read faster than they listen, and by people with cognitive differences; search engines index it too. For a podcast (audio only) a transcript is a 1.2.1 (A) requirement.
Аудіоопис. Додаткова озвучка того, що відбувається на екрані і не зрозуміло зі звуку: «На слайді графік, продажі ростуть». Для незрячих. 1.2.3 (A) дозволяє аудіоопис або текстову альтернативу медіа, 1.2.5 (AA) вимагає саме аудіоопис для записаного відео. Якщо все важливе проговорюється в кадрі, окремий опис не потрібен — тому доповідачу краще описувати слайди вголос.
Audio description. Extra narration of what happens on screen and is not clear from the sound: «The slide shows a chart, sales are rising». For blind people. 1.2.3 (A) allows either audio description or a text alternative for the media, and 1.2.5 (AA) requires audio description itself for prerecorded video. If everything important is spoken in the video, no separate description is needed — which is why presenters should describe their slides out loud.
Що тестую. Наявність доріжки субтитрів, точність (автогенеровані субтитри з помилками в термінах — дефект), синхронізація, позначення звуків і мовців, контраст субтитрів, керування плеєром з клавіатури, кнопка субтитрів з ім'ям.
What I test. That a caption track exists, its accuracy (auto-generated captions that garble terms are a defect), synchronisation, sound and speaker labels, caption contrast, keyboard control of the player, a named captions button.
<video controls>
<source src="/media/onboarding.mp4" type="video/mp4">
<track kind="captions" src="/media/onboarding.uk.vtt" srclang="uk" label="Українська" default>
<track kind="descriptions" src="/media/onboarding.desc.uk.vtt" srclang="uk" label="Аудіоопис">
</video>
<details>
<summary>Транскрипт відео</summary>
<p>[Музика] Олена: Вітаю! Покажу, як створити перший проєкт...</p>
</details>
<!-- onboarding.uk.vtt -->
WEBVTT
00:00:01.000 --> 00:00:03.500
[спокійна музика]
00:00:04.000 --> 00:00:07.200
ОЛЕНА: Вітаю! Покажу, як створити перший проєкт.
<video controls>
<source src="/media/onboarding.mp4" type="video/mp4">
<track kind="captions" src="/media/onboarding.en.vtt" srclang="en" label="English" default>
<track kind="descriptions" src="/media/onboarding.desc.en.vtt" srclang="en" label="Audio description">
</video>
<details>
<summary>Video transcript</summary>
<p>[Music] Olena: Hi! Let me show you how to create your first project...</p>
</details>
<!-- onboarding.en.vtt -->
WEBVTT
00:00:01.000 --> 00:00:03.500
[calm music]
00:00:04.000 --> 00:00:07.200
OLENA: Hi! Let me show you how to create your first project.
kind=captions позначає саме субтитри для глухих, з описом звуків. Доріжку kind=descriptions браузери зазвичай не озвучують самі — на практиці аудіоопис частіше роблять окремою версією відео. Транскрипт у <details> не займає місця, але доступний усім, у тому числі на брайлівському дисплеї.kind=captions marks captions for Deaf users, with sounds described. Browsers usually do not voice a kind=descriptions track themselves — in practice audio description is more often a separate version of the video. A transcript in <details> takes no space but is available to everyone, including on a braille display.Що це. Скрінрідер — програма, що перетворює інтерфейс на мовлення або брайль. Він читає не пікселі, а дерево доступності: роль, ім'я, стан, значення кожного елемента. Користувач керує ним з клавіатури (десктоп) чи жестами (мобільні) і рухається по заголовках, посиланнях, полях, орієнтирах, а не лише зверху вниз. Досвідчені користувачі слухають мовлення на дуже високій швидкості.
What it is. A screen reader is software that turns the interface into speech or braille. It reads the accessibility tree rather than pixels: the role, name, state and value of each element. The user drives it from the keyboard (desktop) or with gestures (mobile) and moves by headings, links, fields and landmarks, not just top to bottom. Experienced users listen at very high speech rates.
Основні. NVDA — безкоштовний, Windows, з відкритим кодом. JAWS — комерційний, Windows, поширений у корпоративному середовищі. VoiceOver — вбудований у macOS та iOS. TalkBack — вбудований в Android. Narrator — вбудований у Windows. За опитуваннями WebAIM на десктопі лідирують NVDA і JAWS, а на мобільних — VoiceOver на iOS.
The main ones. NVDA — free, Windows, open source. JAWS — commercial, Windows, common in corporate settings. VoiceOver — built into macOS and iOS. TalkBack — built into Android. Narrator — built into Windows. In WebAIM surveys NVDA and JAWS lead on desktop, and VoiceOver on iOS leads on mobile.
Комбінації. Скрінрідери оптимізовані під конкретні браузери, тому тестують пари: NVDA + Chrome або Firefox, JAWS + Chrome, VoiceOver + Safari на macOS та iOS, TalkBack + Chrome на Android. VoiceOver з Chrome на Mac — не репрезентативна пара.
Combinations. Screen readers are tuned to specific browsers, so pairs are tested: NVDA + Chrome or Firefox, JAWS + Chrome, VoiceOver + Safari on macOS and iOS, TalkBack + Chrome on Android. VoiceOver with Chrome on a Mac is not a representative pair.
Як обрати мінімум. Дивлюсь аналітику продукту (ОС і браузери) і беру 2–3 пари: зазвичай NVDA + Chrome на кожен реліз критичних сценаріїв, VoiceOver + Safari на iOS для мобільного вебу, а JAWS — для B2B чи держсектору, де він стандарт.
Choosing a minimum. I look at product analytics (OS and browsers) and pick 2–3 pairs: usually NVDA + Chrome on every release for critical flows, VoiceOver + Safari on iOS for mobile web, and JAWS for B2B or public sector, where it is the standard.
Помилка. Вважати, що якщо працює в одному скрінрідері, працює скрізь. Підтримка ARIA відрізняється, і різниця між NVDA і VoiceOver — нормальна причина окремого бага.
The mistake. Assuming that if it works in one screen reader, it works everywhere. ARIA support differs, and a difference between NVDA and VoiceOver is a legitimate reason for a separate bug.
# Пара Коли Сценарії
NVDA + Chrome (Windows) кожен реліз вхід, пошук, кошик, оплата
VoiceOver + Safari (iOS) кожен реліз мобільний чекаут, меню, модалки
TalkBack + Chrome (Android) раз на спринт мобільний чекаут
VoiceOver + Safari (macOS) нові компоненти дизайн-система, віджети
JAWS + Chrome (Windows) перед великим релізом B2B-кабінет, звіти, таблиці
# Фіксуй версії: «NVDA 2025.x, Chrome 14x» — поведінка змінюється між релізами
# Pair When Flows
NVDA + Chrome (Windows) every release sign-in, search, cart, payment
VoiceOver + Safari (iOS) every release mobile checkout, menu, modals
TalkBack + Chrome (Android) once per sprint mobile checkout
VoiceOver + Safari (macOS) new components design system, widgets
JAWS + Chrome (Windows) before a major release B2B dashboard, reports, tables
# Record versions: «NVDA 2025.x, Chrome 14x» — behaviour changes between releases
Режими. У браузері NVDA і JAWS мають режим перегляду (browse/virtual cursor): стрілки читають текст, а літери — швидкі клавіші навігації. У полях вводу вони переходять у режим фокусу (forms mode), де літери вводяться як текст. NVDA перемикає режими вручну через NVDA+Space. Типовий баг, який тут видно: кастомний віджет, що не перемикає режим, і людина не може в ньому друкувати або керувати стрілками.
Modes. In a browser NVDA and JAWS have a browse mode (virtual cursor): arrows read text and letters are quick navigation keys. In input fields they switch to focus mode (forms mode), where letters are typed as text. NVDA toggles modes manually with NVDA+Space. A typical bug you see here: a custom widget that does not switch modes, so the person cannot type in it or use the arrow keys.
NVDA (клавіша NVDA — Insert). Ctrl — зупинити мовлення. H / Shift+H — наступний / попередній заголовок, 1–6 — заголовок рівня. D — орієнтир. K — посилання, B — кнопка, F — поле форми, T — таблиця, L — список, G — графіка. Ctrl+Alt+стрілки — рух по клітинках таблиці. NVDA+F7 — список елементів (заголовки, посилання, орієнтири). NVDA+↓ — читати все. Speech Viewer (меню NVDA → Інструменти) показує текстом усе, що сказано, — зручно для скриншотів у баг.
NVDA (the NVDA key is Insert). Ctrl stops speech. H / Shift+H — next / previous heading, 1–6 — heading of that level. D — landmark. K — link, B — button, F — form field, T — table, L — list, G — graphic. Ctrl+Alt+arrows — move through table cells. NVDA+F7 — elements list (headings, links, landmarks). NVDA+Down arrow — say all. Speech Viewer (NVDA menu → Tools) shows everything spoken as text — handy for screenshots in a bug.
JAWS (клавіша JAWS — Insert). H, T, F, B — як у NVDA; R — наступний регіон/орієнтир. Insert+F6 — список заголовків, Insert+F7 — список посилань, Insert+F5 — список полів форми. Enter на полі — режим форм.
JAWS (the JAWS key is Insert). H, T, F, B work as in NVDA; R — next region/landmark. Insert+F6 — headings list, Insert+F7 — links list, Insert+F5 — form fields list. Enter on a field — forms mode.
Сесія. 1) Заголовок вкладки (NVDA+T). 2) Список заголовків — чи складається зміст. 3) Орієнтири (D). 4) Tab по сценарію: що оголошується на кожному елементі — роль, ім'я, стан. 5) Дія з динамікою (додати в кошик) — чи є оголошення. 6) Помилка форми — чи чути її.
A session. 1) Tab title (NVDA+T). 2) Heading list — does it read as a table of contents. 3) Landmarks (D). 4) Tab through the flow: what is announced on each element — role, name, state. 5) A dynamic action (add to cart) — is there an announcement. 6) A form error — can you hear it.
Дія NVDA JAWS
Зупинити мовлення Ctrl Ctrl
Наступний заголовок H (1-6 рівень) H (1-6 рівень)
Список заголовків Insert+F7 Insert+F6
Наступний орієнтир D R
Наступне поле / кнопка F / B F / B
Таблиця, рух по клітинках T, Ctrl+Alt+стр. T, Ctrl+Alt+стр.
Список посилань Insert+F7 Insert+F7
Режим перегляду/фокусу Insert+Space Enter / Num Plus
Читати все Insert+стр. вниз Insert+стр. вниз
Заголовок вікна Insert+T Insert+T
Лог мовлення для бага Speech Viewer (запис екрана зі звуком)
Action NVDA JAWS
Stop speech Ctrl Ctrl
Next heading H (1-6 level) H (1-6 level)
Headings list Insert+F7 Insert+F6
Next landmark D R
Next field / button F / B F / B
Table, move through cells T, Ctrl+Alt+arr. T, Ctrl+Alt+arr.
Links list Insert+F7 Insert+F7
Browse / focus mode Insert+Space Enter / Num Plus
Say all Insert+Down arrow Insert+Down arrow
Window title Insert+T Insert+T
Speech log for a bug Speech Viewer (screen recording with sound)
VoiceOver на macOS. Увімкнення — Cmd+F5. Модифікатор VO = Control+Option. VO+→ / VO+← — наступний / попередній елемент. VO+Space — активувати. VO+U — ротор: списки заголовків, посилань, орієнтирів, полів, між якими перемикаються стрілками. VO+Cmd+H — наступний заголовок. VO+Shift+↓ — зайти в групу, VO+Shift+↑ — вийти. Тестувати з Safari.
VoiceOver on macOS. Toggle with Cmd+F5. The VO modifier is Control+Option. VO+Right / VO+Left — next / previous element. VO+Space — activate. VO+U — the rotor: lists of headings, links, landmarks and form controls, switched with the arrow keys. VO+Cmd+H — next heading. VO+Shift+Down — interact with a group, VO+Shift+Up — stop interacting. Test with Safari.
VoiceOver на iOS. Увімкнення — Налаштування → Доступність → VoiceOver або потрійне натискання бічної кнопки, якщо налаштовано швидку команду (варто зробити до початку, бо з увімкненим VoiceOver керувати телефоном складніше). Свайп вправо / вліво — наступний / попередній елемент. Подвійний тап — активувати. Ротор — обертання двома пальцями, потім свайп вгору/вниз рухає по вибраному типу (заголовки, посилання). Свайп двома пальцями вгору — читати все. Прокрутка — три пальці. Жест «Z» двома пальцями — назад / закрити.
VoiceOver on iOS. Turn on via Settings → Accessibility → VoiceOver, or triple-click the side button if the shortcut is set up (worth doing first, because the phone is harder to operate once VoiceOver is on). Swipe right / left — next / previous element. Double tap — activate. The rotor — rotate two fingers, then swipe up/down to move by the chosen type (headings, links). Two-finger swipe up — read all. Scrolling — three fingers. A two-finger «Z» scrub — back / dismiss.
TalkBack на Android. Увімкнення — налаштування доступності або утримання обох клавіш гучності кілька секунд, якщо ярлик увімкнено. Свайп вправо / вліво — наступний / попередній елемент, подвійний тап — активувати, прокрутка — два пальці. Меню TalkBack і керування деталізацією читання (заголовки, посилання, символи) відкриваються жестами, які відрізняються між версіями, тож перевір їх у підручнику TalkBack на своєму пристрої.
TalkBack on Android. Turn on in accessibility settings, or hold both volume keys for a few seconds if the shortcut is enabled. Swipe right / left — next / previous element, double tap — activate, scroll — two fingers. The TalkBack menu and reading granularity controls (headings, links, characters) are opened with gestures that differ between versions, so check them in the TalkBack tutorial on your device.
Що перевіряю. Порядок свайпів логічний. У кожного елемента зрозуміле ім'я і роль. Декоративне пропускається. Модалка не дає свайпнути на фон. Кастомні жести застосунку мають альтернативу.
What I check. The swipe order is logical. Every element has a clear name and role. Decorative items are skipped. A modal does not let you swipe to the background. The app's custom gestures have an alternative.
Підготовка
[ ] швидка команда увімкнення налаштована ДО старту
[ ] швидкість мовлення знижена, мовлення не вимкнене «шторкою»
Проходження екрану (свайп вправо від початку до кінця)
[ ] порядок читання = візуальний порядок
[ ] кожна кнопка: «Назва, кнопка», а не «кнопка» чи ім'я файлу
[ ] стан перемикачів звучить: «увімкнено» / «вимкнено»
[ ] декоративні іконки пропускаються
Ротор / деталізація
[ ] заголовки знаходяться, їх порядок складається у зміст
Дії
[ ] подвійний тап активує; помилка форми оголошується
[ ] модалка: свайпом не вийти на фон, закриття повертає фокус
[ ] жест «назад» (Z двома пальцями / системний) закриває модалку
Preparation
[ ] the toggle shortcut is set up BEFORE starting
[ ] speech rate lowered, speech not muted by screen curtain
Walk the screen (swipe right from start to end)
[ ] reading order = visual order
[ ] every button: «Name, button», not «button» or a file name
[ ] toggle state is spoken: «on» / «off»
[ ] decorative icons are skipped
Rotor / granularity
[ ] headings are found and read as a table of contents
Actions
[ ] double tap activates; a form error is announced
[ ] modal: cannot swipe out to the background, closing returns focus
[ ] the back gesture (two-finger Z / system) dismisses the modal
Розмір цілей. WCAG 2.5.8 Target Size (Minimum), AA: щонайменше 24×24 CSS px або достатній відступ, щоб коло діаметром 24 px навколо цілі не перетинало інших; винятки — посилання в тексті, елементи, розмір яких визначає браузер, еквівалентна більша ціль поруч. 2.5.5 Target Size (Enhanced), AAA — 44×44. Платформні настанови суворіші: Apple — 44×44 pt, Material Design — 48×48 dp. Маленькі хрестики закриття і тісні іконки в тулбарі — найчастіший провал; страждають люди з тремором, літні, будь-хто в транспорті.
Target size. WCAG 2.5.8 Target Size (Minimum), AA: at least 24×24 CSS px, or enough spacing that a 24 px circle around the target does not overlap others; exceptions are links in text, browser-sized elements and an equivalent larger target nearby. 2.5.5 Target Size (Enhanced), AAA is 44×44. Platform guidelines are stricter: Apple says 44×44 pt, Material Design 48×48 dp. Small close crosses and cramped toolbar icons are the most common failure; people with tremors, older people and anyone on a bumpy bus suffer.
Інші мобільні критерії. 1.3.4 Orientation (AA): не блокувати портретну чи альбомну орієнтацію без потреби (планшет на візку закріплений горизонтально). 2.5.1 Pointer Gestures (A): жести кількома пальцями чи по траєкторії (свайп карусель, pinch) мають альтернативу одним дотиком. 2.5.4 Motion Actuation (A): «потрусити, щоб скасувати» має кнопку-альтернативу і вимикається. 1.4.4 і системне збільшення шрифту: iOS Dynamic Type, Android font scale 200 % не повинні обрізати тексти.
Other mobile criteria. 1.3.4 Orientation (AA): do not lock portrait or landscape without need (a tablet mounted sideways on a wheelchair). 2.5.1 Pointer Gestures (A): multi-finger or path-based gestures (swipe carousels, pinch) have a single-tap alternative. 2.5.4 Motion Actuation (A): «shake to undo» has a button alternative and can be turned off. 1.4.4 and system font scaling: iOS Dynamic Type and Android font scale at 200 % must not clip text.
Нативні застосунки. Ім'я в iOS — accessibilityLabel, роль — traits; в Android — contentDescription, importantForAccessibility. Інструменти: Xcode Accessibility Inspector, Android Accessibility Scanner, перевірки в Espresso; в Appium доступне ім'я часто і є локатором accessibility id — зручна синергія автотестів і a11y.
Native apps. On iOS the name is accessibilityLabel and the role is traits; on Android it is contentDescription and importantForAccessibility. Tools: Xcode Accessibility Inspector, Android Accessibility Scanner, Espresso checks; in Appium the accessible name is often the accessibility id locator — a nice synergy between automation and a11y.
Мобільний веб. Перевірка на реальному пристрої з VoiceOver і TalkBack, плюс автоматичний пошук малих цілей на емуляції телефона.
Mobile web. Checks on a real device with VoiceOver and TalkBack, plus an automated hunt for small targets on phone emulation.
import { test, expect, devices } from '@playwright/test';
test.use({ ...devices['iPhone 13'] });
test('цілі дотику не менші за 24x24 (WCAG 2.5.8)', async ({ page }) => {
await page.goto('/product/42');
const small = await page.evaluate(() => {
const sel = 'a, button, input, select, textarea, [role=button], [role=link], [tabindex]';
return [...document.querySelectorAll(sel)]
.filter((el) => el.offsetParent !== null && !el.closest('p')) // посилання в тексті — виняток
.map((el) => ({ el, r: el.getBoundingClientRect() }))
.filter(({ r }) => r.width < 24 || r.height < 24)
.map(({ el, r }) => (el.getAttribute('aria-label') || el.textContent.trim()) +
' ' + Math.round(r.width) + 'x' + Math.round(r.height));
});
// кандидати на провал: далі вручну перевірити виняток за відступом
expect(small).toEqual([]);
});
import { test, expect, devices } from '@playwright/test';
test.use({ ...devices['iPhone 13'] });
test('touch targets are at least 24x24 (WCAG 2.5.8)', async ({ page }) => {
await page.goto('/product/42');
const small = await page.evaluate(() => {
const sel = 'a, button, input, select, textarea, [role=button], [role=link], [tabindex]';
return [...document.querySelectorAll(sel)]
.filter((el) => el.offsetParent !== null && !el.closest('p')) // links in text are exempt
.map((el) => ({ el, r: el.getBoundingClientRect() }))
.filter(({ r }) => r.width < 24 || r.height < 24)
.map(({ el, r }) => (el.getAttribute('aria-label') || el.textContent.trim()) +
' ' + Math.round(r.width) + 'x' + Math.round(r.height));
});
// failure candidates: then check the spacing exception by hand
expect(small).toEqual([]);
});
offsetParent відсікає приховані), пропускає посилання в абзацах і повертає ім'я та розмір тих, що менші за 24 px. Це список кандидатів: виняток за відступом автоматично не перевіряється. axe має правило target-size з тегом wcag22aa, яке враховує і відступи.offsetParent filters out hidden ones), skips links inside paragraphs and returns the name and size of anything under 24 px. It is a candidate list: the spacing exception is not checked automatically. axe has a target-size rule tagged wcag22aa that also accounts for spacing.Чому це частина продукту. Рахунки, виписки, договори, квитки, звіти, які продукт генерує, — теж інтерфейс. EAA і EN 301 549 охоплюють документи, а WCAG застосовують до них через WCAG2ICT. Незрячий клієнт банку, що отримав виписку-картинку, не може її прочитати.
Why it is part of the product. Invoices, statements, contracts, tickets and reports the product generates are interface too. The EAA and EN 301 549 cover documents, and WCAG is applied to them through WCAG2ICT. A blind bank customer who receives a statement as an image cannot read it.
Що робить PDF доступним. 1) Теги (tagged PDF): структура заголовків, абзаців, списків, таблиць, як у HTML; стандарт — PDF/UA (ISO 14289). 2) Логічний порядок читання, що не залежить від розташування блоків на сторінці. 3) Alt-текст для зображень і графіків, декоративне позначено як артефакт. 4) Таблиці з заголовками. 5) Мова документа і назва, що показується в заголовку вікна замість імені файлу. 6) Справжній текст, а не скан (без OCR скан — це одна велика картинка). 7) Контраст і кольори за тими ж правилами. 8) Поля форм з назвами і правильним порядком Tab. 9) Закладки для довгих документів.
What makes a PDF accessible. 1) Tags (tagged PDF): a structure of headings, paragraphs, lists and tables, as in HTML; the standard is PDF/UA (ISO 14289). 2) A logical reading order independent of block positions on the page. 3) Alt text for images and charts, with decoration marked as artefacts. 4) Tables with headers. 5) The document language, and a title shown in the window title bar instead of the file name. 6) Real text, not a scan (without OCR a scan is one big image). 7) Contrast and colour under the same rules. 8) Form fields with names and a correct Tab order. 9) Bookmarks for long documents.
Як тестую. PAC (PDF Accessibility Checker) — безкоштовна перевірка PDF/UA і WCAG. Acrobat Pro — Accessibility Checker і панель порядку читання. NVDA — чи читається документ осмислено. Для згенерованих документів перевіряю шаблон і генератор: одне виправлення в шаблоні лікує тисячі файлів, тож автоматична перевірка тегів у CI дешевша за ручну перевірку кожного файлу.
How I test. PAC (PDF Accessibility Checker) — a free PDF/UA and WCAG check. Acrobat Pro — the Accessibility Checker and the reading order panel. NVDA — does the document read in a meaningful way. For generated documents I test the template and the generator: one fix in the template heals thousands of files, so an automated tag check in CI is cheaper than checking every file by hand.
Альтернатива. Якщо PDF неможливо зробити доступним швидко, можна дати доступну HTML-версію того самого вмісту.
Alternative. If a PDF cannot be made accessible quickly, an accessible HTML version of the same content is an option.
import pikepdf
def check_pdf(path):
problems = []
with pikepdf.open(path) as pdf:
root = pdf.Root
mark = root.get('/MarkInfo')
if mark is None or not mark.get('/Marked', False):
problems.append('PDF не позначено як tagged (/MarkInfo /Marked)')
if '/StructTreeRoot' not in root:
problems.append('немає дерева структури (/StructTreeRoot)')
if '/Lang' not in root:
problems.append('не вказано мову документа (/Lang)')
prefs = root.get('/ViewerPreferences')
if prefs is None or not prefs.get('/DisplayDocTitle', False):
problems.append('у заголовку вікна буде ім\'я файлу, а не назва')
return problems
# приклад: assert check_pdf('out/invoice-1042.pdf') == []
import pikepdf
def check_pdf(path):
problems = []
with pikepdf.open(path) as pdf:
root = pdf.Root
mark = root.get('/MarkInfo')
if mark is None or not mark.get('/Marked', False):
problems.append('PDF is not marked as tagged (/MarkInfo /Marked)')
if '/StructTreeRoot' not in root:
problems.append('no structure tree (/StructTreeRoot)')
if '/Lang' not in root:
problems.append('document language is not set (/Lang)')
prefs = root.get('/ViewerPreferences')
if prefs is None or not prefs.get('/DisplayDocTitle', False):
problems.append('the window title will show the file name, not the title')
return problems
# example: assert check_pdf('out/invoice-1042.pdf') == []
Що це. VPAT (Voluntary Product Accessibility Template) — шаблон від ITI (Information Technology Industry Council) для опису відповідності продукту стандартам доступності. Заповнений VPAT називається ACR (Accessibility Conformance Report). Редакції шаблону: WCAG, 508 (США), EU (EN 301 549) та INT, що поєднує всі. Замовники в держсекторі, освіті й великому бізнесі вимагають ACR у тендерах і закупівлях.
What it is. A VPAT (Voluntary Product Accessibility Template) is a template from ITI (the Information Technology Industry Council) for describing how a product conforms to accessibility standards. A completed VPAT is called an ACR (Accessibility Conformance Report). Template editions: WCAG, 508 (US), EU (EN 301 549) and INT, which combines all of them. Customers in the public sector, education and big business require an ACR in tenders and procurement.
Структура. Для кожного критерію: рівень відповідності — Supports, Partially Supports, Does Not Support, Not Applicable (і Not Evaluated лише для AAA) — та «Remarks and Explanations». Плюс опис продукту, версія, дата, методи оцінки.
Structure. For each criterion: a conformance level — Supports, Partially Supports, Does Not Support, Not Applicable (and Not Evaluated for AAA only) — plus «Remarks and Explanations». Also a product description, version, date and evaluation methods.
Роль QA. 1) План аудиту: які сценарії й сторінки репрезентують продукт, які інструменти й пари скрінрідерів. 2) Докази по кожному критерію: результати axe, ручні перевірки, записи сесій. 3) Чесні формулювання: «Partially Supports — у редакторі звітів таблиці не мають заголовків рядків; виправлення в Q3». 4) Оновлення ACR з кожним великим релізом і регресійний контроль, щоб звіт не застарів.
QA's role. 1) The audit plan: which flows and pages represent the product, which tools and screen reader pairs. 2) Evidence per criterion: axe results, manual checks, session recordings. 3) Honest wording: «Partially Supports — in the report editor, tables have no row headers; fix planned for Q3». 4) Updating the ACR with every major release, plus regression control so the report does not go stale.
Ризики. ACR — юридично значущий документ, і «Supports» скрізь без тестування — це введення в оману, яке може обернутися позовом чи втратою контракту. Звіт від незалежного аудитора має більшу вагу, але внутрішній QA все одно дає йому дані і перевіряє виправлення.
Risks. An ACR is a legally significant document, and «Supports» everywhere without testing is misrepresentation that can lead to a lawsuit or a lost contract. A report from an independent auditor carries more weight, but internal QA still feeds it data and verifies the fixes.
Типова помилка. Писати Not Applicable там, де функції просто не тестували, — наприклад, для 1.2.x, коли в продукті є навчальні відео.
The typical mistake. Writing Not Applicable where a feature simply was not tested — for example for 1.2.x when the product has training videos.
Продукт: Valtive Billing Web, версія 4.12 Дата: 2026-09-15
Методи: axe-core 4.x у CI, ручний аудит 14 сценаріїв,
NVDA + Chrome, VoiceOver + Safari (iOS), масштаб 400 %
Критерій Рівень Примітки
1.1.1 Non-text Content (A) Supports -
1.3.1 Info and Relationships (A) Partially Supports Звіт «Витрати»: у таблиці немає
заголовків рядків. Виправлення: 4.13
1.4.3 Contrast (Minimum) (AA) Supports -
2.1.1 Keyboard (A) Partially Supports Сортування віджетів на дашборді
лише перетягуванням. Обхід: меню
«Перемістити». Виправлення: 4.14
2.5.8 Target Size (Minimum) (AA) Supports -
1.2.2 Captions (Prerecorded) (A) Does Not Support Навчальні відео без субтитрів
Product: Valtive Billing Web, version 4.12 Date: 2026-09-15
Methods: axe-core 4.x in CI, manual audit of 14 flows,
NVDA + Chrome, VoiceOver + Safari (iOS), zoom 400 %
Criterion Level Remarks
1.1.1 Non-text Content (A) Supports -
1.3.1 Info and Relationships (A) Partially Supports «Expenses» report: the table has no
row headers. Fix: 4.13
1.4.3 Contrast (Minimum) (AA) Supports -
2.1.1 Keyboard (A) Partially Supports Dashboard widgets can be reordered
only by dragging. Workaround: «Move»
menu. Fix: 4.14
2.5.8 Target Size (Minimum) (AA) Supports -
1.2.2 Captions (Prerecorded) (A) Does Not Support Training videos have no captions
Що це. axe-core — рушій правил доступності з відкритим кодом від компанії Deque, написаний на JavaScript. Він запускається всередині сторінки, аналізує DOM і обчислені стилі та перевіряє близько сотні правил, прив'язаних до критеріїв WCAG і best practices: color-contrast, image-alt, label, button-name, aria-valid-attr, html-has-lang, target-size тощо.
What it is. axe-core is an open-source accessibility rules engine from Deque, written in JavaScript. It runs inside the page, analyses the DOM and computed styles, and checks around a hundred rules mapped to WCAG criteria and best practices: color-contrast, image-alt, label, button-name, aria-valid-attr, html-has-lang, target-size and so on.
Результат. Чотири групи: violations (порушення), passes, incomplete (потребує ручної перевірки — наприклад, контраст тексту на картинці) і inapplicable. Кожне порушення має id правила, impact (minor, moderate, serious, critical), теги (wcag2aa, wcag22aa, best-practice), посилання на пояснення helpUrl і список вузлів з CSS-селектором і HTML. Deque позиціює axe як інструмент без хибних спрацювань: усе сумнівне йде в incomplete, а не у violations.
Output. Four groups: violations, passes, incomplete (needs manual review — for example text contrast over an image) and inapplicable. Every violation has a rule id, an impact (minor, moderate, serious, critical), tags (wcag2aa, wcag22aa, best-practice), a helpUrl explanation link and a list of nodes with a CSS selector and HTML. Deque positions axe as a tool with no false positives: anything uncertain goes to incomplete rather than violations.
Де запускати. Розширення браузера axe DevTools — для ручного аудиту. Автотести: @axe-core/playwright, cypress-axe, @axe-core/webdriverjs і бібліотеки для Selenium на Java, jest-axe для компонентів. Storybook addon-a11y запускає axe для кожної історії. Lighthouse використовує набір правил axe для своєї оцінки доступності.
Where to run it. The axe DevTools browser extension — for manual audits. Automated tests: @axe-core/playwright, cypress-axe, @axe-core/webdriverjs and Java libraries for Selenium, jest-axe for components. The Storybook addon-a11y runs axe for every story. Lighthouse uses a set of axe rules for its accessibility score.
Межі. axe бачить лише поточний стан сторінки: закрите меню, модалку чи стан помилки треба спершу відкрити. І він не оцінює сенс: alt=«зображення» формально є, а корисності нуль.
Limits. axe only sees the current page state: a closed menu, a modal or an error state has to be opened first. And it does not judge meaning: alt=«image» is formally there but useless.
// results.violations[0] після new AxeBuilder({ page }).analyze()
{
id: 'button-name',
impact: 'critical',
tags: ['cat.name-role-value', 'wcag2a', 'wcag412', 'section508'],
help: 'Buttons must have discernible text',
helpUrl: 'https://dequeuniversity.com/rules/axe/4.10/button-name',
nodes: [{
target: ['.cart-summary > button.icon-close'],
html: '<button class="icon-close"><svg>...</svg></button>',
failureSummary: 'Fix any of the following: Element does not have inner text ...'
}]
}
// читаємо так: критичне порушення 4.1.2 (wcag412), кнопка закриття в підсумку кошика без імені
// results.violations[0] after new AxeBuilder({ page }).analyze()
{
id: 'button-name',
impact: 'critical',
tags: ['cat.name-role-value', 'wcag2a', 'wcag412', 'section508'],
help: 'Buttons must have discernible text',
helpUrl: 'https://dequeuniversity.com/rules/axe/4.10/button-name',
nodes: [{
target: ['.cart-summary > button.icon-close'],
html: '<button class="icon-close"><svg>...</svg></button>',
failureSummary: 'Fix any of the following: Element does not have inner text ...'
}]
}
// read it as: a critical 4.1.2 violation (wcag412), the close button in the cart summary has no name
wcag412 — номер критерію без крапок, wcag2a — рівень і версія, де він з'явився. target дає селектор для відтворення в DevTools, а helpUrl веде до пояснення з прикладами виправлення — його зручно вставляти в баг-репорт.wcag412 tag is the criterion number without dots, wcag2a is the level and the version where it appeared. target gives a selector to reproduce it in DevTools, and helpUrl leads to an explanation with fix examples — handy to paste into a bug report.Цифри. Лише частину критеріїв WCAG можна перевірити автоматично повністю; зазвичай називають 30–40 % критеріїв, які машина покриває хоча б частково. Експеримент британської GDS з сторінкою, на якій навмисно зробили 142 бар'єри, показав, що найкращий інструмент знайшов близько 40 % з них. Deque, рахуючи не критерії, а кількість реальних порушень в аудитах, оцінює частку автоматики в 57 % — бо поширені проблеми на кшталт контрасту дуже часті. Обидві цифри правдиві, але висновок один: без ручної перевірки не обійтися.
The numbers. Only some WCAG criteria can be fully checked automatically; the usual figure is 30–40 % of criteria that a machine covers at least partly. The UK GDS experiment with a page carrying 142 deliberate barriers found that the best tool caught about 40 % of them. Deque, counting real violations in audits rather than criteria, puts automation's share at 57 % — because common issues such as contrast are extremely frequent. Both numbers are true, and the conclusion is the same: you cannot skip manual testing.
Що машина не бачить. Чи alt осмислений. Чи логічний порядок фокусу. Чи видно фокус (частково). Чи можна пройти сценарій клавіатурою. Чи є пастка. Чи оголошуються динамічні зміни. Чи зрозумілі тексти помилок. Чи відповідають ролі поведінці віджета. Субтитри і їх точність. Label in Name — лише частково. Стан, що з'являється після дії.
What machines miss. Whether alt is meaningful. Whether focus order is logical. Whether focus is visible (partly). Whether the flow can be completed by keyboard. Whether there is a trap. Whether dynamic changes are announced. Whether error texts are clear. Whether roles match widget behaviour. Captions and their accuracy. Label in Name — only partly. State that appears after an action.
Інструменти. axe-core — рушій для автотестів і розширення, мінімум хибних спрацювань. Lighthouse (у Chrome DevTools і як CLI) — оцінка 0–100 на базі підмножини правил axe; 100 балів не означає доступність. WAVE від WebAIM — розширення, що малює іконки просто на сторінці, зручне для навчання і показу дизайнерам. pa11y — CLI і pa11y-ci для списку URL, з рушіями HTML_CodeSniffer або axe; зручний для швидкого сканування сайту за sitemap.
The tools. axe-core — the engine for automated tests and the extension, with minimal false positives. Lighthouse (in Chrome DevTools and as a CLI) — a 0–100 score based on a subset of axe rules; a score of 100 does not mean accessible. WAVE from WebAIM — an extension that draws icons right on the page, good for learning and showing designers. pa11y — a CLI plus pa11y-ci for a list of URLs, with the HTML_CodeSniffer or axe runner; handy for a quick site-wide scan from a sitemap.
Висновок. Автоматика — сітка безпеки від регресій, а не доказ відповідності.
Takeaway. Automation is a safety net against regressions, not proof of conformance.
# Lighthouse: лише категорія доступності, звіт у JSON
npx lighthouse https://staging.shop.test/cart --only-categories=accessibility \
--output=json --output-path=./lighthouse-a11y.json --chrome-flags="--headless"
# pa11y з рушієм axe і рівнем WCAG AA для однієї сторінки
npx pa11y --runner axe --standard WCAG2AA https://staging.shop.test/cart
# pa11y-ci по всіх URL із sitemap, падає, якщо є помилки
npx pa11y-ci --sitemap https://staging.shop.test/sitemap.xml
# жоден з них не пройде за вас сценарій клавіатурою і не послухає NVDA
# Lighthouse: accessibility category only, JSON report
npx lighthouse https://staging.shop.test/cart --only-categories=accessibility \
--output=json --output-path=./lighthouse-a11y.json --chrome-flags="--headless"
# pa11y with the axe runner and WCAG AA level for a single page
npx pa11y --runner axe --standard WCAG2AA https://staging.shop.test/cart
# pa11y-ci over every URL in the sitemap, fails when there are errors
npx pa11y-ci --sitemap https://staging.shop.test/sitemap.xml
# none of them will walk the flow by keyboard or listen with NVDA for you
Основа. Пакет @axe-core/playwright експортує клас AxeBuilder. Створюєш його з { page }, налаштовуєш ланцюжком і викликаєш analyze(), який повертає ті самі результати axe. Перевірка — expect(results.violations).toEqual([]): у разі падіння звіт Playwright покаже весь масив порушень.
The basics. The @axe-core/playwright package exports the AxeBuilder class. You create it with { page }, configure it by chaining and call analyze(), which returns the usual axe results. The check is expect(results.violations).toEqual([]): on failure the Playwright report shows the whole violations array.
Налаштування. withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa']) — обмежити правила критеріями WCAG A і AA усіх версій (best-practice окремо, якщо команда домовилась). include('#checkout') — сканувати лише частину сторінки, наприклад компонент, за який відповідає команда. exclude('#third-party-chat') — виключити фрагмент, який ви не контролюєте. withRules([...]) — запустити лише конкретні правила. options — передати сирі опції axe.
Configuration. withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa']) limits rules to WCAG A and AA criteria of every version (best-practice separately, if the team agrees). include('#checkout') scans only part of the page, such as the component your team owns. exclude('#third-party-chat') leaves out a fragment you do not control. withRules([...]) runs only specific rules. options passes raw axe options.
Стани. axe перевіряє лише поточний DOM, тож сканувати треба після дій: відкрито меню, показано модалку, відображено помилки валідації. Якщо сторінка анімує появу, спершу дочекайся стабільного стану через expect(...).toBeVisible().
States. axe checks only the current DOM, so scan after actions: menu opened, modal shown, validation errors rendered. If the page animates in, wait for a stable state first with expect(...).toBeVisible().
Для зручності. Документація Playwright радить виносити налаштований AxeBuilder у фікстуру, щоб теги й виключення були однаковими в усіх тестах, і прикріплювати JSON результатів до звіту через testInfo.attach. Тоді в HTML-звіті є повний список вузлів навіть для тесту, що впав на CI.
For convenience. The Playwright docs suggest putting a configured AxeBuilder into a fixture so tags and exclusions are the same in every test, and attaching the results JSON to the report with testInfo.attach. Then the HTML report has the full node list even for a test that failed on CI.
Помилка. Сканувати лише головну сторінку одразу після goto — це найменш цікавий стан продукту.
The mistake. Scanning only the home page right after goto — the least interesting state of the product.
// fixtures.js — однакові налаштування axe для всіх тестів
import { test as base, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
export const test = base.extend({
makeAxeBuilder: async ({ page }, use) => {
await use(() => new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'])
.exclude('#third-party-chat')); // не наш код, окремий тікет вендору
},
});
export { expect };
// checkout.a11y.spec.js
import { test, expect } from './fixtures.js';
test('оплата без порушень WCAG AA, зокрема зі станом помилки', async ({ page, makeAxeBuilder }, testInfo) => {
await page.goto('/checkout/payment');
await page.getByRole('button', { name: 'Оплатити' }).click(); // показати помилки
await expect(page.getByRole('alert')).toBeVisible();
const results = await makeAxeBuilder().include('#checkout').analyze();
await testInfo.attach('axe-results', {
body: JSON.stringify(results.violations, null, 2),
contentType: 'application/json',
});
expect(results.violations).toEqual([]);
});
// fixtures.js — the same axe settings for every test
import { test as base, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
export const test = base.extend({
makeAxeBuilder: async ({ page }, use) => {
await use(() => new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'])
.exclude('#third-party-chat')); // not our code, separate vendor ticket
},
});
export { expect };
// checkout.a11y.spec.js
import { test, expect } from './fixtures.js';
test('payment has no WCAG AA violations, including the error state', async ({ page, makeAxeBuilder }, testInfo) => {
await page.goto('/checkout/payment');
await page.getByRole('button', { name: 'Pay' }).click(); // show the errors
await expect(page.getByRole('alert')).toBeVisible();
const results = await makeAxeBuilder().include('#checkout').analyze();
await testInfo.attach('axe-results', {
body: JSON.stringify(results.violations, null, 2),
contentType: 'application/json',
});
expect(results.violations).toEqual([]);
});
makeAxeBuilder() створює новий AxeBuilder, до якого тест може додати свої include чи withRules. Скан іде після кліку, тож перевіряється і стан з помилками. toEqual([]) замість перевірки довжини показує в звіті зміст порушень.makeAxeBuilder() call creates a fresh AxeBuilder, to which a test can add its own include or withRules. The scan runs after the click, so the error state is checked too. toEqual([]) instead of a length check shows the violations' content in the report.Проблема. На легасі-продукті перший запуск axe дає сотні порушень. Якщо CI стане червоним назавжди, тест вимкнуть. Якщо все вимкнути через disableRules, тест нічого не ловитиме. Потрібна середина.
The problem. On a legacy product the first axe run returns hundreds of violations. If CI goes red forever, the test gets switched off. If everything is switched off with disableRules, the test catches nothing. You need a middle ground.
Коли disableRules прийнятний. 1) Хибне спрацювання, підтверджене вручну, — з посиланням на issue в axe-core чи внутрішній тікет. 2) Правило, яке перевіряється іншим, точнішим способом (наприклад, контраст дизайн-токенів перевіряють окремим тестом). 3) Тимчасово, під конкретний тікет з датою, — і вимикати для конкретного тесту, а не глобально. Неприйнятно: «щоб CI позеленів», глобально, без коментаря і терміну.
When disableRules is acceptable. 1) A false positive confirmed by hand — with a link to an axe-core issue or an internal ticket. 2) A rule covered by another, more precise method (for example design token contrast checked by a separate test). 3) Temporarily, tied to a specific ticket with a date — and disabled for a specific test, not globally. Not acceptable: «to make CI green», globally, with no comment and no deadline.
Коли exclude прийнятний. Сторонній вміст, який ви не контролюєте (чат, карта, рекламний iframe): виключити і завести окремий процес з вендором, бо для користувача це все одно ваш продукт. Не прийнятно виключати свій компонент, бо він «ще не готовий».
When exclude is acceptable. Third-party content you do not control (chat, map, ad iframe): exclude it and run a separate process with the vendor, because for the user it is still your product. It is not acceptable to exclude your own component because it is «not ready yet».
Базова лінія замість вимикання. Документація Playwright пропонує знімок «відбитків» порушень: список rule id + селектор зберігається у snapshot, і тест падає лише при появі нового порушення. Відомі порушення видно в файлі, їх кількість зменшується з виправленнями, а нові регресії не проходять. Альтернатива — пороги за impact: не допускати нових critical і serious.
A baseline instead of disabling. The Playwright docs suggest snapshotting violation «fingerprints»: a list of rule id + selector is stored in a snapshot, and the test fails only when a new violation appears. Known violations are visible in the file, their number shrinks as fixes land, and new regressions do not get through. An alternative is impact thresholds: allow no new critical and serious issues.
Процес. Щомісяця переглядаю базову лінію і список вимкнених правил: що виправлено, що прострочено. Кожне вимкнення — з власником.
Process. Every month I review the baseline and the disabled rules list: what got fixed, what is overdue. Every disable has an owner.
// відбиток порушення: правило + селектори вузлів, без мінливого HTML
function violationFingerprints(results) {
return JSON.stringify(results.violations.map((v) => ({
rule: v.id,
targets: v.nodes.map((n) => n.target),
})), null, 2);
}
test('каталог: нових порушень немає відносно базової лінії', async ({ page, makeAxeBuilder }) => {
await page.goto('/catalog');
const results = await makeAxeBuilder()
// A11Y-412: хибне спрацювання color-contrast на тексті поверх відео, до 2026-12-01
.disableRules(['color-contrast'])
.include('#hero-video')
.analyze();
// critical і serious не пропускаємо взагалі, навіть відомі
const blocking = results.violations.filter((v) => ['critical', 'serious'].includes(v.impact));
expect(blocking).toEqual([]);
expect(violationFingerprints(results)).toMatchSnapshot();
});
// a violation fingerprint: rule + node selectors, without volatile HTML
function violationFingerprints(results) {
return JSON.stringify(results.violations.map((v) => ({
rule: v.id,
targets: v.nodes.map((n) => n.target),
})), null, 2);
}
test('catalogue: no new violations against the baseline', async ({ page, makeAxeBuilder }) => {
await page.goto('/catalog');
const results = await makeAxeBuilder()
// A11Y-412: false positive color-contrast on text over video, until 2026-12-01
.disableRules(['color-contrast'])
.include('#hero-video')
.analyze();
// critical and serious never pass, not even known ones
const blocking = results.violations.filter((v) => ['critical', 'serious'].includes(v.impact));
expect(blocking).toEqual([]);
expect(violationFingerprints(results)).toMatchSnapshot();
});
#hero-video, з номером тікета і датою в коментарі. toMatchSnapshot зберігає файл з відомими порушеннями поруч з тестом: коли порушення виправлено, знімок оновлюють свідомо, і кількість рядків у ньому — метрика прогресу.#hero-video area, with a ticket number and a date in the comment. toMatchSnapshot stores a file of known violations next to the test: when one is fixed, the snapshot is updated deliberately, and its line count is a progress metric.Як працює. page.getByRole('button', { name: 'Оплатити' }) шукає елемент так, як його бачать допоміжні технології: за роллю в дереві доступності і за accessible name. За замовчуванням ім'я збігається частково і без урахування регістру; exact: true вимагає точного збігу; можна передати регулярний вираз. Є фільтри стану: checked, expanded, pressed, selected, level для заголовків.
How it works. page.getByRole('button', { name: 'Pay' }) finds an element the way assistive technology sees it: by role in the accessibility tree and by accessible name. By default the name matches partially and case-insensitively; exact: true requires an exact match; a regular expression works too. There are state filters: checked, expanded, pressed, selected, level for headings.
Плюси для тестів. Локатор стійкий до змін верстки й CSS-класів: поки кнопка лишається кнопкою з тим самим ім'ям, тест живий. Тест читається як сценарій користувача. Playwright рекомендує такі локатори як пріоритетні.
Benefits for tests. The locator survives layout and CSS class changes: as long as the button stays a button with the same name, the test lives. The test reads like a user scenario. Playwright recommends these locators as the first choice.
Плюси для доступності. Якщо локатор за роллю не знаходить елемент, це часто баг доступності: div замість кнопки, поле без label, кнопка-іконка без імені. Команда, що пише тести через getByRole і getByLabel, мимоволі тримає семантику в порядку. Додаткові перевірки — toHaveAccessibleName, toHaveAccessibleDescription, toHaveRole.
Benefits for accessibility. If a role locator cannot find an element, that is often an accessibility bug: a div instead of a button, a field with no label, an icon button with no name. A team that writes tests with getByRole and getByLabel keeps semantics tidy almost by accident. Extra assertions: toHaveAccessibleName, toHaveAccessibleDescription, toHaveRole.
Межі. getByRole знайде і <div role=button> без обробки клавіатури — він перевіряє дерево доступності, а не поведінку. Він не перевіряє контраст, фокус і порядок читання. І якщо тест клацає через click(), він не доводить, що елемент працює з клавіатури. Тож це корисний побічний ефект, а не заміна a11y-тестування.
Limits. getByRole will also find a <div role=button> with no keyboard handling — it checks the accessibility tree, not behaviour. It does not check contrast, focus or reading order. And a test that clicks via click() proves nothing about keyboard operation. So it is a useful side effect, not a replacement for a11y testing.
Помилка. Обходити «незручну» відсутність ролі через locator('.btn-pay') замість того, щоб завести баг.
The mistake. Working around an «inconvenient» missing role with locator('.btn-pay') instead of filing a bug.
// крихко і сліпо до доступності: пройде навіть для div без імені
await page.locator('.checkout .btn-primary').click();
// як бачить користувач скрінрідера: роль + ім'я
await page.getByRole('button', { name: 'Оплатити 1 250 грн' }).click();
await page.getByRole('checkbox', { name: 'Погоджуюсь з умовами' }).check();
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Оплата');
await expect(page.getByRole('button', { name: 'Кошик', expanded: false })).toBeVisible();
// явні a11y-перевірки для кнопки-іконки
const close = page.locator('[data-test=close-cart]');
await expect(close).toHaveRole('button');
await expect(close).toHaveAccessibleName('Закрити кошик');
// brittle and blind to accessibility: passes even for a nameless div
await page.locator('.checkout .btn-primary').click();
// how a screen reader user sees it: role + name
await page.getByRole('button', { name: 'Pay $125.00' }).click();
await page.getByRole('checkbox', { name: 'I agree to the terms' }).check();
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Payment');
await expect(page.getByRole('button', { name: 'Cart', expanded: false })).toBeVisible();
// explicit a11y assertions for an icon button
const close = page.locator('[data-test=close-cart]');
await expect(close).toHaveRole('button');
await expect(close).toHaveAccessibleName('Close cart');
expanded: false знаходить кнопку лише тоді, коли в неї правильний стан aria-expanded — побічно перевіряє ARIA. Останній блок показує патерн «знайти за тестовим атрибутом, перевірити роль і ім'я явно»: корисно, коли ім'я кнопки саме і є предметом тесту.expanded: false filter only finds the button when its aria-expanded state is right — an indirect ARIA check. The last block shows the «find by test attribute, assert role and name explicitly» pattern: useful when the button's name is itself what the test is about.Рівні. 1) Редактор і лінт: eslint-plugin-jsx-a11y (чи аналоги для Vue і Angular) ловить відсутній alt, клік без клавіатури, невалідні ARIA ще до коміту. 2) Компонентні тести: axe на кожному компоненті дизайн-системи в усіх станах — Storybook з addon-a11y і test-runner, або Playwright/Vitest у браузерному режимі. Виправлення тут дешеве і лікує всі сторінки, де компонент використано. jest-axe у jsdom корисний, але jsdom не рахує стилі, тож контраст там не перевіряється. 3) E2E: axe у ключових сценаріях і станах, плюс клавіатурні тести для критичних шляхів. 4) Регулярний скан: pa11y-ci чи Lighthouse по всіх сторінках за sitemap раз на ніч — для тренду, без блокування PR.
Layers. 1) Editor and lint: eslint-plugin-jsx-a11y (or equivalents for Vue and Angular) catches a missing alt, a click without keyboard support and invalid ARIA before the commit. 2) Component tests: axe on every design-system component in all its states — Storybook with addon-a11y and the test runner, or Playwright/Vitest in browser mode. Fixing here is cheap and heals every page that uses the component. jest-axe in jsdom is useful, but jsdom does not compute styles, so contrast is not checked there. 3) E2E: axe in key flows and states, plus keyboard tests for critical paths. 4) A regular scan: pa11y-ci or Lighthouse over every sitemap page nightly — for the trend, without blocking PRs.
Щоб не було шуму. Блокувати PR лише за новими порушеннями (базова лінія) і лише за WCAG A/AA з impact serious і critical; решту — у звіт. Одна фікстура з однаковими тегами для всієї команди. Результати — артефактом у PR, з helpUrl, щоб розробник бачив, що і як виправити. Власник у кожного винятку. Тести стабільні: скан після очікування стабільного стану, без анімацій.
Avoiding noise. Block a PR only on new violations (a baseline) and only for WCAG A/AA with serious and critical impact; everything else goes into the report. One fixture with the same tags for the whole team. Results as a PR artefact, with helpUrl, so the developer sees what to fix and how. Every exception has an owner. Tests are stable: scan after waiting for a stable state, with animations off.
Метрики. Кількість порушень за правилами в часі, частка компонентів дизайн-системи з зеленими a11y-тестами, кількість сценаріїв з ручним аудитом за квартал.
Metrics. Violations per rule over time, the share of design-system components with green a11y tests, the number of flows manually audited per quarter.
Межа. Навіть ідеальний CI не замінює ручний аудит перед великими релізами.
The limit. Even a perfect CI does not replace a manual audit before major releases.
name: accessibility
on:
pull_request:
schedule:
- cron: '0 2 * * *' # щоночі о 02:00 UTC — повний скан
jobs:
a11y-e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: npx playwright install --with-deps chromium
# лише тести з тегом @a11y: axe у ключових станах + клавіатура
- run: npx playwright test --grep @a11y --project=chromium
- uses: actions/upload-artifact@v4
if: always() # звіт потрібен саме тоді, коли впало
with:
name: a11y-report
path: playwright-report/
site-scan:
if: github.event_name == 'schedule'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# не блокує PR: результат іде в тренд і тікети
- run: npx pa11y-ci --sitemap https://staging.shop.test/sitemap.xml --json > pa11y.json || true
- uses: actions/upload-artifact@v4
with:
name: pa11y-nightly
path: pa11y.json
name: accessibility
on:
pull_request:
schedule:
- cron: '0 2 * * *' # nightly at 02:00 UTC — full scan
jobs:
a11y-e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: npx playwright install --with-deps chromium
# only tests tagged @a11y: axe in key states + keyboard
- run: npx playwright test --grep @a11y --project=chromium
- uses: actions/upload-artifact@v4
if: always() # the report matters exactly when it failed
with:
name: a11y-report
path: playwright-report/
site-scan:
if: github.event_name == 'schedule'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# does not block PRs: the result feeds the trend and tickets
- run: npx pa11y-ci --sitemap https://staging.shop.test/sitemap.xml --json > pa11y.json || true
- uses: actions/upload-artifact@v4
with:
name: pa11y-nightly
path: pa11y.json
a11y-e2e на кожен PR блокує злиття за новими порушеннями, site-scan вночі дає широку картину і свідомо не падає (|| true). if: always() вивантажує звіт і для червоного прогону. Тег @a11y у назві тесту дозволяє запускати ці тести окремо.a11y-e2e on every PR blocks merging on new violations, and site-scan at night gives the wide picture and deliberately never fails (|| true). if: always() uploads the report for a red run too. The @a11y tag in the test title lets these tests run separately.Навіщо чекліст. Ручна перевірка покриває те, чого не бачить axe, але без структури вона залежить від настрою тестувальника. Чекліст робить її повторюваною: двоє людей отримають схожі результати, а нова людина в команді швидко почне перевіряти.
Why a checklist. A manual check covers what axe cannot see, but without structure it depends on the tester's mood. A checklist makes it repeatable: two people get similar results, and a newcomer to the team can start checking quickly.
Порядок. Від дешевого до дорогого: спершу автоматичний скан (щоб не витрачати час на очевидне), далі клавіатура, далі масштаб і візуальні перевірки, наприкінці скрінрідер. Кожен пункт посилається на критерій WCAG, тож знахідку одразу можна оформити.
Order. From cheap to expensive: first an automated scan (so you do not spend time on the obvious), then keyboard, then zoom and visual checks, and finally a screen reader. Every item points to a WCAG criterion, so a finding can be filed immediately.
Тривалість. На одну типову сторінку чи крок сценарію досвідченому тестувальнику потрібно 30–60 хвилин. Для регресії достатньо «швидкого» підмножини: Tab-прохід, масштаб 400 %, прослуховування ключових дій. Повний прохід — для нових функцій і перед аудитом.
Duration. For one typical page or flow step an experienced tester needs 30–60 minutes. For regression a «quick» subset is enough: a Tab pass, 400 % zoom, listening to key actions. The full pass is for new features and before an audit.
Нюанс. Чекліст — не весь аудит: він не замінює тестування з реальними користувачами з інвалідністю, які знаходять проблеми зручності, яких немає в жодному критерії.
Nuance. A checklist is not the whole audit: it does not replace testing with real users with disabilities, who find usability problems that no criterion describes.
Як використовую. Копія чекліста на кожну сторінку в тест-менеджменті, статус pass/fail/n.a. по пунктах, посилання на баги. Так результат ручної перевірки стає даними для ACR.
How I use it. A copy of the checklist per page in test management, a pass/fail/n.a. status per item, links to bugs. That turns the manual check into data for an ACR.
0. Автоматика
[ ] axe DevTools / WAVE: порушення заведені або пояснені
1. Клавіатура
[ ] усе інтерактивне досяжне Tab / Shift+Tab 2.1.1
[ ] порядок фокусу логічний 2.4.3
[ ] фокус завжди видно, не ховається під шапкою 2.4.7, 2.4.11
[ ] немає пасток; Esc закриває поп-апи 2.1.2
[ ] skip link працює 2.4.1
2. Масштаб і візуальне
[ ] 200 % тексту і 400 % (320 px) без горизонт. скролу 1.4.4, 1.4.10
[ ] text spacing нічого не обрізає 1.4.12
[ ] контраст тексту 4.5:1, елементів і станів 3:1 1.4.3, 1.4.11
[ ] нічого не передано лише кольором 1.4.1
[ ] reduced motion поважається, у каруселі є пауза 2.2.2
3. Структура і вміст
[ ] title сторінки, lang, один h1, логічні заголовки 2.4.2, 3.1.1, 1.3.1
[ ] alt осмислені, декор з alt="" 1.1.1
[ ] поля з label, помилки текстом і з підказкою 3.3.1, 3.3.2, 3.3.3
4. Скрінрідер (NVDA + Chrome)
[ ] у кнопок і посилань зрозумілі імена 4.1.2, 2.4.4
[ ] стани оголошуються (expanded, checked, invalid) 4.1.2
[ ] статуси й помилки оголошуються без фокусу 4.1.3
[ ] модалка: фокус усередині, повернення після закриття
0. Automation
[ ] axe DevTools / WAVE: violations filed or explained
1. Keyboard
[ ] everything interactive reachable with Tab / Shift+Tab 2.1.1
[ ] focus order is logical 2.4.3
[ ] focus always visible, never hidden under the header 2.4.7, 2.4.11
[ ] no traps; Esc closes popups 2.1.2
[ ] skip link works 2.4.1
2. Zoom and visual
[ ] 200 % text and 400 % (320 px) with no sideways scroll 1.4.4, 1.4.10
[ ] text spacing clips nothing 1.4.12
[ ] text contrast 4.5:1, components and states 3:1 1.4.3, 1.4.11
[ ] nothing conveyed by colour alone 1.4.1
[ ] reduced motion respected, carousel has a pause 2.2.2
3. Structure and content
[ ] page title, lang, one h1, logical headings 2.4.2, 3.1.1, 1.3.1
[ ] alt texts meaningful, decoration with alt="" 1.1.1
[ ] fields labelled, errors in text with a suggestion 3.3.1, 3.3.2, 3.3.3
4. Screen reader (NVDA + Chrome)
[ ] buttons and links have clear names 4.1.2, 2.4.4
[ ] states announced (expanded, checked, invalid) 4.1.2
[ ] statuses and errors announced without focus 4.1.3
[ ] modal: focus inside, returns after closing
Що відрізняє a11y-баг. Розробник часто не може «побачити» проблему, бо сам не користується клавіатурою чи скрінрідером. Тому репорт має пояснити, хто і як постраждає, і дати точний спосіб відтворення з тим самим інструментом.
What makes an a11y bug different. A developer often cannot «see» the problem, because they do not use a keyboard or a screen reader themselves. So the report has to explain who is affected and how, and give an exact way to reproduce it with the same tool.
Обов'язкові частини. 1) Заголовок з місцем і суттю, можна з критерієм: «Чекаут, оплата: кнопка закриття без доступного імені (WCAG 4.1.2)». 2) Середовище: ОС, браузер, допоміжна технологія з версіями (NVDA 2025.x + Chrome 14x), масштаб, налаштування. 3) Кроки: клавіатурою, а не мишею. 4) Фактичний результат: що саме озвучено (цитата зі Speech Viewer), куди потрапив фокус. 5) Очікуваний результат з посиланням на критерій WCAG і рівень. 6) Вплив на користувача: хто і чи блокує завершення сценарію. 7) Докази: скриншот, відео зі звуком скрінрідера, id правила axe і селектор. 8) Підказка виправлення, якщо очевидна, — без нав'язування реалізації.
Mandatory parts. 1) A title with the location and the essence, optionally with the criterion: «Checkout, payment: close button has no accessible name (WCAG 4.1.2)». 2) Environment: OS, browser, assistive technology with versions (NVDA 2025.x + Chrome 14x), zoom, settings. 3) Steps: by keyboard, not mouse. 4) Actual result: exactly what was announced (a quote from Speech Viewer), where focus went. 5) Expected result with a link to the WCAG criterion and level. 6) User impact: who is affected and whether it blocks completing the flow. 7) Evidence: a screenshot, a video with the screen reader audio, the axe rule id and selector. 8) A fix hint if obvious — without dictating the implementation.
Серйозність. Визначається впливом, а не рівнем A/AA: неможливість оплатити клавіатурою — блокер, навіть якщо це «лише» один критерій. Low — незручність з обхідним шляхом.
Severity. It comes from impact, not from level A/AA: being unable to pay with the keyboard is a blocker, even if it is «only» one criterion. Low is an inconvenience with a workaround.
Масштаб. Якщо проблема в компоненті дизайн-системи, одного бага на компонент достатньо, з переліком сторінок, — а не сорок однакових.
Scope. If the problem lives in a design-system component, one bug per component is enough, with a list of pages — not forty identical ones.
Помилки. «Не працює зі скрінрідером» без деталей. Відсутність версій. Посилання на правило axe без пояснення, що відчуває людина.
Mistakes. «Does not work with a screen reader» with no details. Missing versions. An axe rule link without explaining what the person experiences.
Заголовок: [A11y] Оплата: після помилки фокус лишається на «Оплатити»,
помилка не оголошується (WCAG 3.3.1 A, 4.1.3 AA)
Середовище: Windows 11, Chrome 14x, NVDA 2025.x, масштаб 100 %
Кроки:
1. /checkout/payment, лише клавіатура
2. Tab до «Номер картки», ввести 15 цифр
3. Tab до «Оплатити», Enter
Фактично: NVDA каже лише «Оплатити, кнопка». Фокус лишається на кнопці.
Червоний текст під полем з'являється, але не оголошується (Speech Viewer
у вкладенні). Поле не має aria-invalid.
Очікувано: помилку оголошено (зведення з role=alert або перенос фокусу),
поле позначене aria-invalid і пов'язане з текстом помилки.
WCAG 3.3.1 Error Identification (A), 4.1.3 Status Messages (AA)
Вплив: незрячий користувач не дізнається, чому оплата не проходить, і не може
завершити покупку. Серйозність: Blocker.
Докази: video-nvda-payment.mp4 (зі звуком), speech-viewer.png
Підказка: патерн зведення помилок, див. картку про помилки форм
Масштаб: компонент PaymentForm, також /subscriptions/renew
Title: [A11y] Payment: after an error focus stays on «Pay»,
the error is not announced (WCAG 3.3.1 A, 4.1.3 AA)
Environment: Windows 11, Chrome 14x, NVDA 2025.x, zoom 100 %
Steps:
1. /checkout/payment, keyboard only
2. Tab to «Card number», type 15 digits
3. Tab to «Pay», Enter
Actual: NVDA only says «Pay, button». Focus stays on the button.
Red text appears under the field but is not announced (Speech Viewer
attached). The field has no aria-invalid.
Expected: the error is announced (a summary with role=alert or a focus move),
the field is marked aria-invalid and linked to the error text.
WCAG 3.3.1 Error Identification (A), 4.1.3 Status Messages (AA)
Impact: a blind user never learns why the payment fails and cannot
complete the purchase. Severity: Blocker.
Evidence: video-nvda-payment.mp4 (with audio), speech-viewer.png
Hint: error summary pattern, see the form errors card
Scope: PaymentForm component, also /subscriptions/renew
Мета QA-команди. Не «знайти всі баги доступності», а зробити так, щоб продукт ставав доступнішим з кожним релізом і нові проблеми не з'являлися. Якість включає доступність так само, як безпеку чи продуктивність.
The QA team's goal. Not «find every accessibility bug», but make the product more accessible with each release and stop new problems appearing. Quality includes accessibility just as it includes security or performance.
Крок 1: зупинити кровотечу. Нові проблеми дешевше не допустити, ніж виправити. axe у CI з базовою лінією (нові порушення блокують PR), lint для фреймворку, a11y-критерії в Definition of Done і в критеріях приймання історій («форма проходиться клавіатурою, помилки оголошуються»), дизайн-рев'ю на контраст і стани фокусу.
Step 1: stop the bleeding. Preventing new issues is cheaper than fixing them. axe in CI with a baseline (new violations block PRs), framework lint, a11y criteria in the Definition of Done and in story acceptance criteria («the form works by keyboard, errors are announced»), design review for contrast and focus states.
Крок 2: зрозуміти масштаб. Аудит не всього сайту, а 5–10 ключових сценаріїв (реєстрація, пошук, покупка, особистий кабінет): автоматика плюс ручний прохід за чеклістом. Результат — список проблем за кореневими причинами, а не за сторінками.
Step 2: understand the scale. Audit not the whole site but 5–10 key flows (sign-up, search, purchase, account area): automation plus a manual checklist pass. The output is a list of issues by root cause, not by page.
Крок 3: виправляти там, де ефект найбільший. Компоненти дизайн-системи (одна кнопка-іконка — сотні сторінок), дизайн-токени кольорів, спільний layout (skip link, landmarks, lang). Потім блокери в ключових сценаріях.
Step 3: fix where the effect is biggest. Design-system components (one icon button covers hundreds of pages), colour design tokens, the shared layout (skip link, landmarks, lang). Then blockers in key flows.
Крок 4: люди. Коротке навчання для розробників і дизайнерів (30 хвилин з NVDA переконують краще за документ), a11y-чемпіони в командах, публічна метрика прогресу. Раз на квартал — тестування з користувачами з інвалідністю.
Step 4: people. Short training for developers and designers (30 minutes with NVDA convinces better than any document), a11y champions in teams, a public progress metric. Once a quarter, testing with users with disabilities.
Трейд-оф. Повний рефакторинг «під WCAG» ніхто не профінансує; поступовий план з помітними результатами за 1–2 квартали — профінансують.
Trade-off. Nobody will fund a full «WCAG rewrite»; an incremental plan with visible results in 1–2 quarters will get funded.
Тиждень 1-2 Стоп новим проблемам
- axe у CI з базовою лінією, блок лише на нових serious/critical
- eslint-plugin-jsx-a11y на весь репозиторій (warn -> error для нового коду)
- DoD: «клавіатура + axe + назва/роль у нових компонентах»
Тиждень 3-4 Аудит 8 ключових сценаріїв (авто + ручний чекліст + NVDA)
- результат: 23 кореневі причини замість 410 вузлів
Місяць 2 Дизайн-система: Button, IconButton, Modal, Input, Select, Tabs
- токени кольорів під 4.5:1 / 3:1, стиль :focus-visible
Місяць 3 Блокери в чекауті й реєстрації, skip link, landmarks, lang
Квартал 2 Решта ключових сценаріїв, ACR для продажів, тест з користувачами
Метрики порушення axe за правилами (тренд), % компонентів із зеленими
a11y-тестами, % ключових сценаріїв, що пройшли ручний аудит
Week 1-2 Stop new problems
- axe in CI with a baseline, block only on new serious/critical
- eslint-plugin-jsx-a11y on the whole repo (warn -> error for new code)
- DoD: «keyboard + axe + name/role in new components»
Week 3-4 Audit 8 key flows (automated + manual checklist + NVDA)
- output: 23 root causes instead of 410 nodes
Month 2 Design system: Button, IconButton, Modal, Input, Select, Tabs
- colour tokens to 4.5:1 / 3:1, a :focus-visible style
Month 3 Blockers in checkout and sign-up, skip link, landmarks, lang
Quarter 2 Remaining key flows, an ACR for sales, testing with users
Metrics axe violations per rule (trend), % of components with green
a11y tests, % of key flows that passed a manual audit
Спершу згрупувати. 300 — це зазвичай кількість вузлів, а не проблем. Групую за id правила, потім за компонентом (спільний префікс селектора, клас компонента). Типова картина: 120 вузлів color-contrast від одного сірого токена, 80 button-name від однієї кнопки-іконки, 40 label у фільтрах каталогу. Після групування лишається 15–25 кореневих причин.
Group first. 300 is usually a count of nodes, not problems. I group by rule id, then by component (a shared selector prefix, a component class). A typical picture: 120 color-contrast nodes from one grey token, 80 button-name from one icon button, 40 label in the catalogue filters. After grouping, 15–25 root causes are left.
Перевірити. Відсіюю дублікати між сторінками, підтверджую вручну кілька вузлів з кожного правила, переглядаю incomplete — там можуть бути справжні проблеми з контрастом на картинках.
Verify. I remove duplicates across pages, confirm a few nodes of each rule by hand, and review incomplete — real contrast problems over images can hide there.
Критерії пріоритету. 1) Вплив на користувача: чи блокує завершення сценарію (кнопка без імені в оплаті — блокер, контраст футера — ні). Impact з axe — хороша підказка, але не остаточна. 2) Критичність сценарію: чекаут, логін, реєстрація вище за блог. 3) Рівень: A і AA вище за best-practice. 4) Охоплення: скільки сторінок лікує одне виправлення. 5) Вартість: заміна токена кольору — година роботи на 120 вузлів. Юридичний ризик додає вагу публічним сторінкам і документам.
Priority criteria. 1) Impact on the user: does it block completing a flow (a nameless button in payment is a blocker, footer contrast is not). axe's impact is a good hint but not final. 2) Flow criticality: checkout, sign-in and sign-up rank above the blog. 3) Level: A and AA rank above best-practice. 4) Reach: how many pages one fix heals. 5) Cost: swapping a colour token is an hour of work for 120 nodes. Legal risk adds weight to public pages and documents.
Результат. Матриця «вплив × зусилля»: спершу блокери в ключових сценаріях і дешеві масові виправлення (токени, спільні компоненти), потім решта A/AA, потім best-practice. Кожна коренева причина — окремий тікет з переліком сторінок.
Result. An «impact × effort» matrix: first blockers in key flows and cheap mass fixes (tokens, shared components), then the remaining A/AA, then best-practice. Each root cause is a separate ticket with a list of pages.
І не забути. 300 порушень від сканера — лише автоматична частина; ручний прохід тих самих сценаріїв майже напевно додасть проблем з клавіатурою і фокусом, які теж треба зважити.
And remember. 300 violations from a scanner are only the automated part; a manual pass over the same flows will almost certainly add keyboard and focus problems, which need weighing too.
// node group-axe.mjs axe-results/*.json — зводить сотні вузлів до кореневих причин
import { readFileSync } from 'node:fs';
const groups = new Map();
for (const file of process.argv.slice(2)) {
const { url, violations } = JSON.parse(readFileSync(file, 'utf8'));
for (const v of violations) {
for (const node of v.nodes) {
// компонент = перший клас у селекторі, наприклад .icon-btn або .filter-panel
const component = (String(node.target[0]).match(/\.[\w-]+/) || ['(без класу)'])[0];
const key = v.id + ' | ' + component;
const g = groups.get(key) || { rule: v.id, impact: v.impact, component, nodes: 0, pages: new Set() };
g.nodes += 1;
g.pages.add(url);
groups.set(key, g);
}
}
}
const weight = { critical: 4, serious: 3, moderate: 2, minor: 1 };
[...groups.values()]
.sort((a, b) => weight[b.impact] - weight[a.impact] || b.nodes - a.nodes)
.forEach((g) => console.log(g.impact.padEnd(9), g.rule.padEnd(22), g.component.padEnd(18),
'вузлів:', g.nodes, 'сторінок:', g.pages.size));
// node group-axe.mjs axe-results/*.json — folds hundreds of nodes into root causes
import { readFileSync } from 'node:fs';
const groups = new Map();
for (const file of process.argv.slice(2)) {
const { url, violations } = JSON.parse(readFileSync(file, 'utf8'));
for (const v of violations) {
for (const node of v.nodes) {
// component = the first class in the selector, e.g. .icon-btn or .filter-panel
const component = (String(node.target[0]).match(/\.[\w-]+/) || ['(no class)'])[0];
const key = v.id + ' | ' + component;
const g = groups.get(key) || { rule: v.id, impact: v.impact, component, nodes: 0, pages: new Set() };
g.nodes += 1;
g.pages.add(url);
groups.set(key, g);
}
}
}
const weight = { critical: 4, serious: 3, moderate: 2, minor: 1 };
[...groups.values()]
.sort((a, b) => weight[b.impact] - weight[a.impact] || b.nodes - a.nodes)
.forEach((g) => console.log(g.impact.padEnd(9), g.rule.padEnd(22), g.component.padEnd(18),
'nodes:', g.nodes, 'pages:', g.pages.size));
url і violations), групує вузли за парою «правило + компонент» і сортує за impact, а всередині — за кількістю вузлів. Вивід одразу показує кандидатів на масове виправлення. Евристика «перший клас у селекторі» груба: для точності краще додати в компоненти атрибут data-component.url and violations fields), groups nodes by the «rule + component» pair and sorts by impact, then by node count. The output immediately shows candidates for a mass fix. The «first class in the selector» heuristic is rough: for accuracy, add a data-component attribute to components.Обсяг. Увесь шлях, а не окремі сторінки: кошик → вхід або гостьове оформлення → адреса → доставка → оплата (часто iframe платіжного провайдера і 3-D Secure) → підтвердження → лист і PDF-чек. Плюс стани: порожній кошик, промокод, помилки валідації, недоступний товар, таймаут сесії, мобільна версія. Ціль — WCAG 2.2 AA; результат — звіт з пріоритетами і план повторної перевірки.
Scope. The whole path, not separate pages: cart → sign-in or guest checkout → address → delivery → payment (often a payment provider iframe and 3-D Secure) → confirmation → email and PDF receipt. Plus states: empty cart, promo code, validation errors, an unavailable item, session timeout, the mobile version. The target is WCAG 2.2 AA; the output is a prioritised report and a retest plan.
Фокусні критерії для чекауту. Форми: label, autocomplete для адреси й картки (1.3.5), помилки текстом з підказкою (3.3.1, 3.3.3), перевірка перед оплатою (3.3.4), без повторного введення адреси (3.3.7), вхід без когнітивного тесту і з вставкою пароля (3.3.8). Клавіатура й фокус: вибір доставки, модалки, фокус після помилки. Оголошення змін суми, знижки, кількості (4.1.3). Таймаут (2.2.1). Цілі дотику на мобільному (2.5.8). Масштаб 400 % — кнопка «Оплатити» досяжна.
Focus criteria for checkout. Forms: labels, autocomplete for address and card (1.3.5), errors in text with suggestions (3.3.1, 3.3.3), review before paying (3.3.4), no retyping of the address (3.3.7), sign-in without a cognitive test and with password paste (3.3.8). Keyboard and focus: choosing delivery, modals, focus after an error. Announcing total, discount and quantity changes (4.1.3). Timeout (2.2.1). Touch targets on mobile (2.5.8). Zoom to 400 % — the «Pay» button stays reachable.
Метод. 1) axe у кожному кроці і стані (автотест проходить сценарій і сканує). 2) Ручний прохід клавіатурою за чеклістом. 3) NVDA + Chrome і VoiceOver + iOS Safari — повна покупка. 4) Масштаб і reflow. 5) Сторонні частини (iframe оплати): тестую, а невідповідність оформлюю як проблему вендора і прошу його ACR — для користувача це все одно наш чекаут.
Method. 1) axe in every step and state (an automated test walks the flow and scans). 2) A manual keyboard pass with the checklist. 3) NVDA + Chrome and VoiceOver + iOS Safari — a full purchase. 4) Zoom and reflow. 5) Third-party parts (the payment iframe): I test them, file non-conformance as a vendor issue and ask for their ACR — for the user it is still our checkout.
Результат. Список знахідок: критерій, крок, вплив, серйозність, докази. Спершу блокери, які не дають завершити покупку. Регресійні тести на виправлене.
Output. A findings list: criterion, step, impact, severity, evidence. Blockers that prevent completing a purchase come first. Regression tests for whatever gets fixed.
import { test, expect } from './fixtures.js'; // makeAxeBuilder з тегами WCAG 2.2 AA
test('чекаут: axe на кожному кроці та в станах помилок @a11y', async ({ page, makeAxeBuilder }) => {
const findings = [];
const scan = async (step) => {
const { violations } = await makeAxeBuilder().exclude('iframe[name=payment]').analyze();
findings.push(...violations.map((v) => ({ step, rule: v.id, impact: v.impact, nodes: v.nodes.length })));
};
await page.goto('/cart'); await scan('кошик');
await page.getByRole('button', { name: 'Оформити замовлення' }).click();
await page.getByRole('button', { name: 'Продовжити' }).click(); // порожня форма
await expect(page.getByRole('alert')).toBeVisible(); await scan('адреса: помилки');
await page.getByLabel('Місто').fill('Київ');
await page.getByLabel('Вулиця, будинок').fill('Хрещатик, 1');
await page.getByRole('button', { name: 'Продовжити' }).click();
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Доставка');
await scan('доставка');
await page.getByRole('radio', { name: /Кур'єр/ }).check();
await page.getByRole('button', { name: 'Продовжити' }).click();
await scan('оплата');
console.table(findings); // таблиця для звіту аудиту
expect(findings.filter((f) => ['critical', 'serious'].includes(f.impact))).toEqual([]);
});
import { test, expect } from './fixtures.js'; // makeAxeBuilder with WCAG 2.2 AA tags
test('checkout: axe on every step and in error states @a11y', async ({ page, makeAxeBuilder }) => {
const findings = [];
const scan = async (step) => {
const { violations } = await makeAxeBuilder().exclude('iframe[name=payment]').analyze();
findings.push(...violations.map((v) => ({ step, rule: v.id, impact: v.impact, nodes: v.nodes.length })));
};
await page.goto('/cart'); await scan('cart');
await page.getByRole('button', { name: 'Checkout' }).click();
await page.getByRole('button', { name: 'Continue' }).click(); // empty form
await expect(page.getByRole('alert')).toBeVisible(); await scan('address: errors');
await page.getByLabel('City').fill('Kyiv');
await page.getByLabel('Street and number').fill('Khreshchatyk 1');
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Delivery');
await scan('delivery');
await page.getByRole('radio', { name: /Courier/ }).check();
await page.getByRole('button', { name: 'Continue' }).click();
await scan('payment');
console.table(findings); // a table for the audit report
expect(findings.filter((f) => ['critical', 'serious'].includes(f.impact))).toEqual([]);
});
scan знімає результат axe після кожного кроку і в стані помилки, тож перевіряються не лише «чисті» сторінки. Платіжний iframe виключено зі скану, бо його перевіряють окремо як вендорську частину. Тест падає лише на critical і serious, а повна таблиця знахідок іде в звіт аудиту — ручну частину вона не замінює.scan helper captures axe results after each step and in the error state, so it is not only the «clean» pages that get checked. The payment iframe is excluded from the scan because it is reviewed separately as the vendor's part. The test fails only on critical and serious, and the full findings table goes into the audit report — it does not replace the manual part.