Семантика как фундамент: нативные элементы против div-супа
В обзоре трека мы договорились, что интерфейсом пользуются очень по-разному: глазами и мышью, ушами и клавиатурой, увеличенным до 400% экраном, голосовыми командами, одним переключателем. В статье «Зачем это нужно» разобрались, откуда берутся требования WCAG. Есть ещё вводная статья портала «Гайд по Accessibility» с общей рамкой и списком рекомендаций для менеджеров, дизайнеров и разработчиков — пересказывать её не будем, а копнём в один конкретный слой.
Этот слой — семантика разметки. Он самый нижний и самый дешёвый: почти всё, о чём пойдёт речь, делается не «дополнительной работой на доступность», а выбором правильного тега в момент, когда вы всё равно пишете этот кусок вёрстки. И наоборот: если фундамент кривой, поверх него приходится городить ARIA-подпорки, писать обработчики клавиатуры вручную и всё равно получать интерфейс, который работает хуже нативного.
Главная идея: разметка — это API страницы для машин
Привыкли думать, что HTML описывает, что показать. На самом деле HTML описывает, что это такое, а как показать — решает CSS. Расхождение между этими двумя ответами и есть корень большинства проблем доступности.
Возьмите строчку <div class="btn">Купить</div> со стилями кнопки. Человек, который смотрит на экран, видит кнопку: прямоугольник, контрастный фон, курсор-палец. Программа, которая читает страницу, видит контейнер общего назначения с текстом внутри. Никакой кнопки для неё не существует. Визуальный слой и смысловой слой разошлись — и все, кто получает интерфейс не через пиксели, получают его сломанным.
Кому эта разметка «отдаёт API»:
- скринридерам — NVDA, JAWS, «Экранный диктор», VoiceOver, TalkBack, Orca;
- программам голосового управления: команда «нажми Купить» в Dragon или Voice Control сработает, только если в дереве есть кнопка с таким именем;
- switch-устройствам, которые перебирают интерактивные элементы, и режиму высокой контрастности Windows, который перекрашивает элементы по их роли;
- поисковым роботам, режиму чтения браузера, автозаполнению, переводчику страниц;
- вашим же тестам:
getByRole('button', { name: 'Купить' })в Testing Library ищет ровно то, что найдёт скринридер.
Полезная формулировка: семантика — единственный канал, по которому намерение разработчика доходит до пользователя, минуя пиксели.
Как из тегов получается речь: конвейер доступности
Между вашим <button> и фразой «Купить, кнопка» в наушниках лежит несколько преобразований. Понимание этой цепочки объясняет почти все странности, которые встретятся на практике.
- Разметка. Браузер парсит HTML, применяет CSS, выполняет JS.
- DOM и CSSOM. Здесь семантика уже может пострадать:
display: noneиvisibility: hiddenубирают узел из дерева доступности целиком,display: contentsв ряде случаев убирает бокс вместе с ролью, аlist-style: noneв WebKit исторически лишает список ролиlist. - Дерево доступности. Браузер строит параллельную структуру, где каждый узел отвечает на четыре вопроса: роль, имя, состояние, свойства. Это и есть «то, что видит скринридер».
- Платформенный API. Дерево транслируется в UI Automation и IAccessible2 на Windows, NSAccessibility на macOS и iOS, AT-SPI2 на Linux,
AccessibilityNodeInfoна Android. - Ассистивная технология читает этот API и превращает узлы в речь, брайль или увеличение.
Именно поэтому доступность нельзя «включить постфактум одним пропсом»: информация должна родиться на первом шаге, иначе её неоткуда взять на пятом.
Обратите внимание: разработчик тут не написал ни строчки про клавиатуру, фокус или озвучку. Всё это уже встроено в <button>.
Четыре вопроса к каждому элементу
Роль, имя, состояние, свойства — не абстракция, а буквальный набор полей узла дерева доступности. WCAG 2.2 фиксирует их в критерии 4.1.2 Name, Role, Value (уровень A).
- Роль — что это за штука:
button,link,heading,checkbox,navigation,table. Роль определяет, как элемент объявят и каких клавиатурных соглашений от него ждут. - Имя (accessible name) — как элемент называется. Порядок вычисления по спецификации Accessible Name and Description Computation:
aria-labelledby→aria-label→ нативный источник (<label for>,alt,<caption>, текст внутри кнопки) →title. - Состояние — что происходит сейчас: нажат, развёрнут, выбран, отключён. У нативных элементов берётся из свойств DOM (
checked,disabled,open), у самописных — только из ARIA-атрибутов, которые надо не забыть обновить. - Свойства — стабильные характеристики:
required,readonly, уровень заголовка, позиция в списке, связь с описанием.
Упражнение для каждого ревью вёрстки: ткнуть в любой интерактивный элемент макета и вслух ответить на четыре вопроса. Если хоть на один нет ответа из разметки — это баг.
Что теряется, когда кнопка сделана из div
Типичный «дизайн-системный» вариант против нативного:
<!-- ПЛОХО: выглядит как кнопка, но кнопкой не является -->
<div class="btn btn--primary" onclick="addToCart(42)">Купить</div>
<!-- ХОРОШО -->
<button type="button" class="btn btn--primary" onclick="addToCart(42)">Купить</button>
Разница между ними — не «одна строчка про доступность», а восемь отдельных механизмов:
Что даёт <button> бесплатно |
Что происходит с <div onclick> |
|---|---|
Роль button в дереве доступности |
Роль generic, элемент не объявляется как управляющий |
| Попадает в порядок Tab | Не фокусируется, с клавиатуры недостижим |
Enter и Space вызывают click |
Ничего не происходит, нужен свой keydown |
Видимый фокус по умолчанию (:focus-visible) |
Нет фокуса — нет и кольца фокуса |
disabled убирает из Tab и объявляет «недоступно» |
disabled не существует, нужен aria-disabled и ручная блокировка |
Отправка формы по type="submit", в том числе по Enter в поле |
Форма не отправится |
| Корректная отрисовка в режиме высокой контрастности Windows | Фон может исчезнуть, кнопка становится невидимой |
| Попадает в список кнопок скринридера и в цели голосовых команд | Не попадает никуда |
Чтобы честно догнать нативную кнопку на div, нужен и role="button", и tabindex="0", и вот такой обработчик:
const btn = document.querySelector('.btn');
const disabled = () => btn.getAttribute('aria-disabled') === 'true';
// div не знает, что Enter и Space — это активация
btn.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault(); // иначе Space прокрутит страницу
if (!disabled()) btn.click();
});
// aria-disabled не блокирует клик мышью — блокируем вручную
btn.addEventListener('click', (e) => {
if (disabled()) { e.preventDefault(); e.stopPropagation(); return; }
addToCart(42);
});
Полтора десятка строк, три атрибута и отдельный CSS для :focus-visible — ради того, что даёт слово button из шести букв. И отличия всё равно останутся: такая «кнопка» не отправит форму и иначе себя поведёт в режиме высокой контрастности. Это и есть содержание «первого правила ARIA», к которому мы вернёмся в статье про ARIA: если задачу решает нативный элемент — берите нативный элемент.
Ссылка или кнопка
Второй по частоте случай после div-кнопки — путаница <a> и <button>:
<a href>— переход куда-то. Меняется адрес, работает средний клик, «открыть в новой вкладке», копирование ссылки, история браузера. Активируется по Enter.<button>— действие здесь и сейчас: открыть модалку, отправить форму, переключить тему. Активируется по Enter и Space.
<!-- ссылка без href: не фокусируется, роль generic, для клавиатуры её нет -->
<a onclick="openModal()">Подробнее</a>
<!-- ссылка-заглушка: скринридер объявит "ссылка", человек ждёт перехода, а его нет -->
<a href="#" onclick="openModal(); return false">Подробнее</a>
<!-- кнопка вместо навигации: ломает открытие в новой вкладке и индексацию -->
<button onclick="location.href='/pricing'">Тарифы</button>
Правильно: модалку открывает <button type="button">, на страницу тарифов ведёт <a href="/pricing">. В SPA-роутерах для этого есть готовые компоненты — <Link> в React Router и Next.js рендерят настоящий <a href>, не заменяйте их на div с обработчиком.
Дерево решений: когда вообще нужен div
div и span — законные элементы. Их задача — быть точкой приложения стилей без всякого смысла: обёртка для grid, контейнер для отступа, span для подкрашивания слова. Проблема не в существовании div, а в том, что им подменяют элементы со смыслом.
Ориентиры: карта страницы, которой пользуются каждый день
Зрячий человек за долю секунды понимает, где шапка, где основной текст, где боковая колонка. У пользователя скринридера этой «мгновенной картины» нет — вместо неё есть навигация по ориентирам (landmarks): нажал клавишу, прыгнул к следующему крупному блоку.
Соответствие тегов и ролей закреплено в HTML Accessibility API Mappings — это нормативный документ, а не фольклор. Нюансы, на которых чаще всего спотыкаются:
<header>и<footer>дают ролиbannerиcontentinfoтолько когда не вложены в<article>,<section>,<aside>,<nav>или<main>. Внутри карточки товара<footer>— простоgeneric.<section>получает рольregionтолько с доступным именем (aria-labelилиaria-labelledby). Без имени это generic-обёртка, и половина «семантической вёрстки из секций» не даёт ничего. То же самое с<form>и с<aside>, вложенным в другой секционирующий элемент.<main>должен быть один на страницу; несколько<nav>обязаны различаться именами, иначе в списке ориентиров будет четыре одинаковых «навигация».<search>— новый элемент (2023 год), даёт рольsearch; раньше писали<div role="search">.
<body>
<header>
<a class="logo" href="/">Курсы</a>
<search>
<label for="q">Поиск по сайту</label>
<input id="q" name="q" type="search">
<button type="submit">Найти</button>
</search>
</header>
<nav aria-label="Основное меню"> ... </nav>
<main>
<h1>Семантика как фундамент</h1>
<article> ... </article>
<section aria-labelledby="comments-title">
<h2 id="comments-title">Комментарии</h2>
</section>
</main>
<aside aria-label="Похожие статьи"> ... </aside>
<footer>
<nav aria-label="Дополнительные ссылки"> ... </nav>
<p>© 2026</p>
</footer>
</body>
Правило покрытия: весь видимый контент должен лежать внутри какого-нибудь ориентира. Текст, оказавшийся между </header> и <main>, для навигации по регионам просто не существует — до него можно добраться только последовательным чтением.
Skip-link («Перейти к основному содержимому») — прямое следствие этой структуры и требование критерия 2.4.1 Bypass Blocks. Подробно про него — в статье про клавиатуру и фокус.
Заголовки: оглавление, которым реально пользуются
По данным опросов пользователей скринридеров WebAIM, навигация по заголовкам — самый популярный способ сориентироваться на длинной странице: её называют основной примерно две трети респондентов. В NVDA это клавиши H и 1–6, в JAWS — та же H, в VoiceOver — ротор. Список всех заголовков открывается одним сочетанием (в NVDA — Insert + F7). Отсюда практические правила:
- Заголовок — уровень в структуре, а не размер шрифта.
<h4>, выбранный «потому что h2 слишком крупный», ломает оглавление всем, кто им пользуется. Размер задаётся классом:<h2 class="text-sm">, а не сменой тега. - Не пропускайте уровни вниз. После
<h2>может идти<h2>или<h3>, но не<h4>— прыжок через уровень читается как «здесь что-то потерялось». Вверх прыгать можно и нужно. - Один
<h1>на страницу — заголовок самой страницы, обычно внутри<main>. Логотип в шапке заголовком не является. - Визуально скрытый заголовок — нормальный приём. Если блок понятен зрячему по расположению, но нуждается в названии для навигации, добавьте заголовок и спрячьте его визуально (не через
display: none— он уберёт узел из дерева доступности).
/* Класс "visually hidden": элемент есть для скринридера, но не занимает места */
.visually-hidden {
position: absolute; width: 1px; height: 1px;
margin: -1px; padding: 0; border: 0;
overflow: hidden; clip-path: inset(50%); white-space: nowrap;
}
Проверка за 30 секунд. Отключите CSS (в Firefox: Вид → Стиль страницы → Без стиля) или выполните в консоли:
// Печатает оглавление страницы с отступами по уровням
document.querySelectorAll('h1,h2,h3,h4,h5,h6').forEach((h) => {
const level = Number(h.tagName[1]);
console.log(' '.repeat(level - 1) + level + ' · ' + h.textContent.trim().slice(0, 70));
});
Если вывод читается как разумный план документа — структура в порядке. Если это набор случайных уровней, читатель, который пользуется только этим планом, потеряется.
Остальная семантика, которая работает на вас
Списки. <ul> и <ol> дают не только маркеры, но и объявление «список из 7 элементов, элемент 1 из 7» — человек сразу знает объём. Известная ловушка WebKit: list-style: none убирает роль списка в Safari и iOS, лечится явной ролью. Меню сайта — почти всегда список ссылок внутри <nav>, а не набор <div> подряд.
<ul class="nav-list" role="list"><!-- role возвращает семантику, снятую list-style: none -->
<li><a href="/go">Go</a></li>
<li><a href="/rust">Rust</a></li>
</ul>
Таблицы. Таблица данных без <th> — просто сетка текста. Скринридер объявляет заголовок столбца при переходе между ячейками, но только если знает, где заголовки. <caption> даёт таблице имя, scope связывает ячейки с заголовками. Таблица для раскладки (в письмах это до сих пор встречается) должна быть помечена role="presentation".
<table>
<caption>Поддержка ARIA в скринридерах</caption>
<thead>
<tr><th scope="col">Атрибут</th><th scope="col">NVDA</th><th scope="col">VoiceOver</th></tr>
</thead>
<tbody>
<tr><th scope="row">aria-expanded</th><td>да</td><td>да</td></tr>
</tbody>
</table>
Изображения. Атрибут alt — не «описание картинки», а текстовая замена: что бы вы сказали вслух вместо неё.
<!-- Информативная: описываем смысл, а не пиксели -->
<img src="/chart.png" alt="Доля мобильного трафика выросла с 34% до 61% за два года">
<!-- Декоративная: пустой alt, чтобы скринридер её пропустил -->
<img src="/divider.svg" alt="">
<!-- Картинка внутри ссылки: alt заменяет текст ссылки -->
<a href="/cart"><img src="/cart.svg" alt="Корзина, 3 товара"></a>
Отсутствующий alt заставляет скринридер читать имя файла (IMG_20240712_final_v3.png) — это хуже, чем ничего. Пустой alt="" — осознанное «здесь ничего важного». Разница принципиальная. Для встроенных SVG используйте role="img" и <title> внутри: именно так сделаны схемы в этой статье.
Раскрывающиеся блоки. <details> и <summary> дают готовый disclosure-виджет с клавиатурой, состоянием и, что приятно, находимостью через Ctrl + F в современных браузерах:
Самописный аккордеон на div придётся снабжать aria-expanded, aria-controls, обработчиками клавиш и синхронизацией состояния — четыре места, где можно ошибиться, вместо нуля. Подробнее — в статье про доступные компоненты.
Язык документа. Один атрибут <html lang="ru"> решает, будет ли страница вообще произносима: без него скринридер прочитает русский текст английским синтезатором и получится бессмысленный набор звуков. Критерий 3.1.1 Language of Page, уровень A. Для вставок на другом языке — <span lang="en">continuous delivery</span>.
Карта семантики
Полный справочник элементов с их ролями и ограничениями — на MDN и в ARIA in HTML, где для каждого тега указано, какие ARIA-роли ему разрешено переопределять, а какие нет.
Семантика в компонентных фреймворках
Div-суп — не глупость предшественников, а наследие эпохи: в HTML 4.01 никаких header и nav просто не было, ARIA появилась как заплатка поверх уже написанного, и только к 2014 году HTML5 стал Рекомендацией W3C. Второй источник — компонентные фреймворки: когда UI собирается из <Card>, <Row>, <Stack>, легко потерять из виду, во что они разворачиваются. Дизайн-система, у которой <Button> рендерит <div>, тиражирует одну ошибку на сотни экранов.
Масштаб виден по ежегодному отчёту WebAIM Million: автоматически обнаружимые нарушения WCAG находятся примерно на 95% главных страниц из топ-миллиона, и лидируют там ровно семантические промахи — отсутствующий alt, пустые ссылки и кнопки, поля без подписей, незаданный lang.
React: не прячьте тег
Плохой компонент навязывает div. Хороший — прокидывает элемент наружу и не мешает передать пропсы:
import type { ComponentPropsWithoutRef, ElementType, ReactNode } from 'react';
type ButtonProps<T extends ElementType> = { as?: T; children: ReactNode }
& Omit<ComponentPropsWithoutRef<T>, 'as' | 'children'>;
// Полиморфный компонент: ссылка остаётся ссылкой, кнопка — кнопкой
export function Button<T extends ElementType = 'button'>({ as, children, ...rest }: ButtonProps<T>) {
const Tag = as ?? 'button';
// У нативной button тип по умолчанию submit — внутри формы это неприятный сюрприз
const typeProp = Tag === 'button' ? { type: 'button' as const } : {};
return <Tag className="btn" {...typeProp} {...rest}>{children}</Tag>;
}
// <Button onClick={addToCart}>Купить</Button>
// <Button as="a" href="/pricing">Тарифы</Button>
Две ловушки, о которых стоит помнить отдельно. Первая: type по умолчанию у <button> — submit, поэтому кнопка «Показать пароль» внутри формы без type="button" отправит форму. Вторая: <div> внутри <p> невалиден — браузер закроет параграф раньше, и структура поедет; компоненты, которые рендерят блочные обёртки, нельзя вставлять в текстовые. Об устройстве React-компонентов подробнее — в треке фронтенда, «Основы React»; базовая семантика HTML разобрана там же в статье «Семантика HTML».
Web Components: роль через ElementInternals
Кастомный элемент по умолчанию не имеет ни роли, ни фокусируемости. Современный способ их выдать — ElementInternals:
class ToggleSwitch extends HTMLElement {
static formAssociated = true; // участвует в отправке формы
#i;
constructor() {
super();
this.#i = this.attachInternals();
this.#i.role = 'switch'; // роль в дереве доступности
this.#i.ariaChecked = 'false'; // состояние
}
connectedCallback() {
if (!this.hasAttribute('tabindex')) this.setAttribute('tabindex', '0');
this.addEventListener('click', () => this.toggle());
this.addEventListener('keydown', (e) => {
if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); this.toggle(); }
});
}
toggle() {
const next = this.#i.ariaChecked !== 'true';
this.#i.ariaChecked = String(next);
this.#i.setFormValue(next ? 'on' : null);
this.dispatchEvent(new Event('change', { bubbles: true }));
}
}
customElements.define('toggle-switch', ToggleSwitch);
Соотношение сил видно и здесь: два десятка строк на то, что <input type="checkbox" role="switch"> делает из коробки. Свой элемент оправдан, только когда нативного аналога действительно нет.
Практика: проверить семантику за пять минут
Полноценный аудит — тема статьи про тестирование и процесс. Базовую проверку можно сделать прямо сейчас, ничего не устанавливая.
Минута 1. Только клавиатура. Уберите руку с мыши: Tab вперёд, Shift + Tab назад. Всё интерактивное должно быть достижимо, фокус — виден, порядок — совпадать с визуальным. Если по «кнопке» нельзя пройти табом, это div.
Минута 2. Консольный экспресс-аудит. Вставьте в DevTools:
const q = (sel) => document.querySelectorAll(sel).length;
console.table({
'кликабельные div и span': q('div[onclick], span[onclick], div[role=button], span[role=button]'),
'ссылки без href': q('a:not([href])'),
'ссылки-заглушки href="#"': q('a[href="#"]'),
'картинки без alt': q('img:not([alt])'),
'поля без подписи': [...document.querySelectorAll('input:not([type=hidden]),select,textarea')]
.filter((el) => !el.labels?.length && !el.getAttribute('aria-label')
&& !el.getAttribute('aria-labelledby')).length,
'h1 на странице': q('h1'),
'есть main': q('main, [role=main]'),
'lang у html': document.documentElement.lang || 'НЕ ЗАДАН',
});
Это не заменяет аудит, но за десять секунд показывает, есть ли системная проблема.
Минута 3. Дерево доступности. Firefox DevTools → вкладка «Специальные возможности» → включить. Сверните дерево до верхнего уровня: должны быть видны banner, navigation, main, complementary, contentinfo. Если вместо них цепочка section и generic, ориентиров нет. В Chrome то же самое — в Elements → панель Accessibility → «Full accessibility tree».
Минута 4. Оглавление. Выполните скрипт из раздела про заголовки и прочитайте вывод: он должен читаться как план статьи.
Минута 5. Послушать. Включите скринридер и пройдите один короткий сценарий. На Windows — бесплатный NVDA, запуск Ctrl + Alt + N, выключение Insert + Q; на macOS — VoiceOver, Cmd + F5; на Android — TalkBack. Первый опыт будет неудобным: это нормально, вы пользуетесь незнакомым инструментом, а не «примеряете чужие трудности». Режимы чтения и горячие клавиши разобраны в статье про скринридеры.
Вот как звучит одна и та же карточка товара в двух вариантах вёрстки:
DIV-СУП СЕМАНТИЧНАЯ ВЁРСТКА
«графическое изображение» «Ноутбук ProBook 14, заголовок уровня 3»
«Ноутбук ProBook 14» «изображение: ноутбук ProBook 14, крышка открыта»
«89 990 рублей» «89 990 рублей»
«Купить» «Купить, кнопка»
«Подробнее» «Подробнее о ноутбуке ProBook 14, ссылка»
клавиша B не находит ничего, B находит кнопку, K — ссылку, H — заголовок,
Tab проходит карточку насквозь порядок Tab совпадает с визуальным
Второй вариант отличается от первого выбором тегов и одним осмысленным alt.
Типичные ошибки: чек-лист для ревью
<div onclick>вместо<button>— самая частая и самая дорогая ошибка.<a>безhref: не фокусируется, роли нет. И зеркальная — кнопка внутри ссылки или ссылка внутри кнопки: невалидная вложенность, непредсказуемый фокус.- Заголовок выбран по размеру шрифта — оглавление превращается в кашу.
<section>без имени в надежде получить ориентир: получаетсяgeneric.- Двадцать ссылок «Подробнее» на одной странице: в списке ссылок скринридера они неразличимы. Лечится переписыванием текста или уточняющим
aria-label. - Пропущенный
altи, наоборот,alt="картинка"— обе крайности бесполезны. langне задан или задан неверно (lang="en"на русской странице).- Плейсхолдер вместо
<label>: подпись исчезает при вводе; подробности — в статье про формы. display: noneдля текста, нужного скринридеру. Такой текст скрыт от всех — нужен классvisually-hidden.tabindexбольше нуля ломает естественный порядок обхода почти всегда; таблица без<th>и<caption>делает данные нечитаемыми при навигации по ячейкам.
Что это даёт, кроме доступности
Хороший аргумент в разговоре с командой, которой «некогда заниматься доступностью»: семантика окупается сразу в нескольких местах.
- Тесты устойчивее.
getByRole('button', { name: /купить/i })не сломается от переименования CSS-класса, в отличие отquerySelector('.btn-primary'). Про подход — в треке тестирования, «Ручное тестирование». - Роботы, превью соцсетей, режим чтения, переводчики страниц и автозаполнение опираются на теги и
autocomplete— на семантичной разметке всё это работает само. - Новые люди в команде быстрее читают вёрстку:
<nav>понятнее, чем<div class="n-wrap">. - Меньше кода. Каждый нативный элемент — это десятки строк, которые вы не написали.
Мини-итог
- Дерево доступности строится из тегов. Не написали смысл в разметке — его неоткуда взять дальше по конвейеру.
- У каждого элемента должны быть роль, имя, состояние и свойства; нативные элементы дают их бесплатно.
divс обработчиком клика теряет восемь механизмов сразу. Догнать нативную кнопку вручную можно, но дорого и неточно.- Ориентиры (
header,nav,main,aside,footer) — карта страницы, которой пользуются ежедневно. Весь контент внутри ориентиров, одноимённые — с разными именами. - Заголовки задают оглавление, а не размер шрифта: уровни не пропускаем,
h1один. - CSS умеет ломать семантику:
display: none,display: contents,list-style: noneв WebKit. - Пятиминутная ручная проверка — клавиатура, консольный скрипт, дерево доступности, оглавление, скринридер — ловит подавляющее большинство проблем этого слоя.
Хорошая семантика — не одолжение кому-то, а обычный признак аккуратно сделанного продукта: такой же, как отсутствие ошибок в консоли или разумные имена переменных.
Источники
- WCAG 2.2 — Web Content Accessibility Guidelines, W3C Recommendation
- HTML Accessibility API Mappings 1.0 — как теги превращаются в роли
- ARIA in HTML — какие роли допустимы для каких элементов
- Accessible Name and Description Computation — вычисление доступного имени
- WAI-ARIA Authoring Practices Guide — эталонные паттерны компонентов
- MDN: справочник HTML-элементов и MDN: доступность
- WebAIM Million и WebAIM Screen Reader User Survey — статистика и реальные привычки пользователей
- The A11Y Project — чек-лист
Что дальше
Мы выжали максимум из нативной семантики. Но остаются виджеты, которых в HTML просто нет: табы, комбобоксы, деревья, тулбары. Для них существует WAI-ARIA — способ достроить роли, свойства и состояния там, где тегов не хватает, и одновременно самый лёгкий способ сделать интерфейс хуже, чем он был. Разбираемся, когда ARIA нужна, а когда вредна: