Семантический HTML: структура, формы, метаданные и почему теги важны
Есть заблуждение: «HTML — простая часть, вся сложность во фреймворках». Оно живёт до первого инцидента, когда форма оплаты не работает в мобильном Safari, менеджер паролей не подставляет логин, ссылка в мессенджере разворачивается без картинки, а юрист присылает письмо про требования к доступности.
HTML — единственный слой, который напрямую разговаривает с платформой. CSS меняет пиксели, JavaScript меняет поведение, а HTML сообщает браузеру и операционной системе, что это за штука. Из этого сообщения платформа бесплатно достаёт клавиатурную навигацию, фокус, автозаполнение, режим чтения, ориентиры для скринридера, индексацию, превью в соцсетях, корректную печать. Всё это вы либо получаете одной строкой разметки, либо переписываете руками на трёхстах строках JavaScript — и хуже.
Дальше — как думать о разметке как о структуре данных для машин. Про парсинг и рендеринг подробно в статье Как работает браузер; здесь мы на уровень выше.
1. Первый принцип: тег — это контракт с платформой
Два фрагмента, которые после CSS выглядят пиксель в пиксель одинаково:
<div class="btn" onclick="pay()">Оплатить</div> <!-- «работает же» -->
<button type="submit" class="btn">Оплатить</button> <!-- то же самое, но по-настоящему -->
Первый вариант теряет: фокус по Tab, срабатывание по Enter и Space, роль button в дереве доступности, состояние disabled, участие в отправке формы, распознавание голосовым управлением («нажми Оплатить»), правильный курсор. Чтобы догнать второй, нужно дописать tabindex="0", role="button", обработчики keydown для Enter и keyup для Space, логику aria-disabled — и всё равно останется рассинхрон с настройками ОС.
Вы декларируете намерение — платформа предоставляет реализацию. div и span — единственные элементы, у которых по определению нет смысла; они существуют как последнее средство, когда подходящего тега действительно нет.
Кто читает вашу разметку
Ключевой узел — дерево доступности: отдельная структура, которую браузер строит из DOM параллельно с деревом рендеринга. У каждого узла есть роль (что это), имя (как называется), значение и состояния. Скринридер не «читает экран» — он ходит по этому дереву. Автотесты через Testing Library (getByRole('button', { name: 'Оплатить' })) ходят по нему же, поэтому доступная разметка автоматически даёт тестируемую разметку — см. Тестирование фронтенда. Посмотреть можно прямо сейчас: Chrome DevTools → Elements → панель Accessibility справа (там же кнопка полного дерева); в Firefox — вкладка Accessibility. Если вместо button "Оплатить" вы видите generic, разметка сломана, как бы хорошо всё ни выглядело.
2. Скелет документа и ориентиры
Ориентиры (landmarks) — крупные регионы страницы, между которыми можно прыгать одной клавишей. Пользователь скринридера почти никогда не читает подряд: он открывает список ориентиров или список заголовков и переходит сразу к нужному. Нет ориентиров — остаётся Tab-Tab-Tab через всю шапку на каждой странице.
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Оформление заказа — Магазин</title>
</head>
<body>
<a class="skip-link" href="#content">Перейти к содержимому</a>
<header>
<a href="/" aria-label="На главную"><img src="/logo.svg" alt="" width="120" height="32"></a>
<nav aria-label="Основная">
<ul><li><a href="/catalog">Каталог</a></li>
<li><a href="/cart" aria-current="page">Корзина</a></li></ul>
</nav>
<search><form action="/search">
<label for="q">Поиск по товарам</label>
<input id="q" name="q" type="search"> <button type="submit">Найти</button>
</form></search>
</header>
<main id="content"><h1>Оформление заказа</h1></main>
<aside aria-label="Вам может подойти"><h2>Похожие товары</h2></aside>
<footer>
<p><small>© 2026 Магазин</small></p>
<address><a href="mailto:help@example.com">help@example.com</a></address>
</footer>
</body>
</html>
lang="ru" обязателен. От него зависят голос и произношение синтезатора речи, переносы (hyphens: auto), работа автоперевода, типографские кавычки в CSS-свойстве quotes. Иноязычные вкрапления помечайте отдельно: <span lang="en">Continuous Delivery</span> — иначе русский синтезатор прочитает это по буквам. Skip-link — первый интерактивный элемент в DOM, невидимый до фокуса. Важно: display: none и visibility: hidden убирают элемент из порядка фокуса, ссылка станет недостижимой. Сдвиг трансформацией фокусируемость сохраняет:
.skip-link { /* уводим за экран, но НЕ display:none */
position: absolute; inset: 0 auto auto 0; padding: 0.75rem 1rem;
background: Canvas; color: CanvasText; translate: 0 -200%; transition: translate 0.15s;
}
.skip-link:focus-visible { translate: 0 0; }
<header> и <footer> дают роли banner и contentinfo только на верхнем уровне — по HTML-AAM, если они не потомки article, aside, main, nav или section. Внутри карточки-article тот же <footer> — просто generic, и это правильно: подвал у страницы один, у карточек их много. <section> без имени бесполезен: роль region он получает, только если у него есть доступное имя через aria-label или aria-labelledby, иначе в дереве доступности он неотличим от div. Правило: есть заголовок — <section aria-labelledby="...">; нет — вам нужен div. <search> — элемент 2023 года с ролью search; раньше писали role="search" руками.
Заголовки: то, что ломают чаще всего
Заголовки h1–h6 — оглавление документа, скринридер строит из них навигационное дерево. Уровень отражает вложенность смысла, а не размер шрифта. Отдельно: алгоритм автопостроения структуры (outline algorithm) не был реализован ни одним браузером и в 2022 году удалён из спецификации. Миф «в каждой section можно писать h1, браузер сам понизит уровень» вреден: <h1> внутри <section> остаётся h1.
Плохо — <h1>Оформление заказа</h1> и сразу <h4>Доставка</h4> (уровень выбран по размеру шрифта, пропущены h2 и h3). Хорошо — <h2 class="text-sm">Доставка</h2>: уровень по смыслу, размер через класс. Проверка в консоли занимает секунду: $$('h1,h2,h3,h4,h5,h6').map(h => h.tagName + ' ' + h.textContent.trim()) — читается ли это как осмысленное оглавление?
3. Карта элементов: что брать под какую задачу
<article> против <section>. article — самодостаточный фрагмент, осмысленный в отрыве от страницы: пост, карточка товара, комментарий. Тест: «можно ли отдать это в RSS отдельным элементом?» section — тематическая часть внутри чего-то, обычно с заголовком. <strong>/<em> против <b>/<i>. strong — важность, em — интонационное ударение, меняющее смысл фразы. b и i — «выделено по типографской традиции без изменения важности»: названия судов, биологические термины, лид-абзац. Отдельно стоит вспомнить <dl>: это не только «словарь», а список пар «имя-значение» — характеристики товара, метаданные заказа, параметры конфигурации; самый недооценённый элемент HTML.
Таблицы — только для табличных данных (для раскладки есть Grid, см. Вёрстка), но тогда полноценно:
<table>
<caption>Тарифы доставки на июль 2026</caption>
<thead>
<tr><th scope="col">Регион</th><th scope="col">Срок</th><th scope="col">Стоимость</th></tr>
</thead>
<tbody>
<tr><th scope="row">Москва</th><td>1 день</td><td>290 ₽</td></tr>
<tr><th scope="row">Дальний Восток</th><td>7–10 дней</td><td>1 190 ₽</td></tr>
</tbody>
</table>
<!-- Широкую таблицу оборачивают в контейнер с overflow-x, tabindex="0" и role="region",
иначе на телефоне до правых колонок не добраться с клавиатуры -->
scope превращает набор ячеек в осмысленную таблицу: скринридер зачитает «Дальний Восток, Стоимость, 1 190 рублей» вместо «1 190 рублей». <caption> даёт таблице имя и попадает в список таблиц.
Виджеты, которые больше не нужно писать
<!-- Аккордеон: name делает пункты взаимоисключающими, как у радиокнопок -->
<details name="faq"><summary>Когда спишутся деньги?</summary><p>В момент передачи в доставку.</p></details>
<details name="faq"><summary>Можно ли вернуть товар?</summary><p>В течение 14 дней.</p></details>
<!-- Всплывающий слой: top layer, закрытие по Esc и клику вне — без JS -->
<button popovertarget="promo">Промокод</button> <div id="promo" popover>SUMMER26</div>
<!-- Модальное окно: фокус-ловушка, inert для фона и ::backdrop из коробки -->
<dialog id="confirm">
<form method="dialog">
<h2>Удалить адрес?</h2>
<button value="cancel">Отмена</button>
<button value="delete">Удалить</button>
</form>
</dialog>
delBtn.addEventListener('click', () => confirmDialog.showModal());
// method="dialog" закрывает окно и кладёт value нажатой кнопки в returnValue
confirmDialog.addEventListener('close', () => {
if (confirmDialog.returnValue === 'delete') removeAddress();
});
Раньше на это уходила библиотека на 12 КБ и три бага с возвратом фокуса. Платформа систематически забирает себе то, что писали руками: виджет, ради которого сегодня ставят зависимость, через два года окажется атрибутом — привычка сначала искать нативное решение экономит килобайты и баги. <dialog> доступен везде с марта 2022, Popover API — с середины 2024, details name — с 2024–2025. Свою модалку на div в 2026 году писать незачем.
4. Формы: где семантика приносит деньги
<form action="/signup" method="post" novalidate>
<fieldset>
<legend>Контактные данные</legend>
<div class="field">
<label for="email">Рабочая почта</label>
<input id="email" name="email" type="email" autocomplete="email" inputmode="email"
required aria-describedby="email-hint email-err">
<p id="email-hint" class="hint">Пришлём подтверждение — нужен доступ к ящику.</p>
<p id="email-err" class="error" hidden></p>
</div>
<div class="field">
<label for="password">Пароль</label>
<input id="password" name="password" type="password" autocomplete="new-password"
minlength="12" required>
</div>
<div class="field">
<label for="code">Код из SMS</label>
<input id="code" name="code" type="text" inputmode="numeric" pattern="[0-9]{6}"
autocomplete="one-time-code" maxlength="6" enterkeyhint="done">
</div>
</fieldset>
<fieldset>
<legend>Способ доставки</legend>
<label><input type="radio" name="shipping" value="courier" checked> Курьером</label>
<label><input type="radio" name="shipping" value="pickup"> Самовывоз</label>
</fieldset>
<button type="submit">Создать аккаунт</button>
</form>
Что здесь работает без единой строки JS:
autocomplete— не «удобство», а официальный словарь токенов из спецификации:email,new-password,current-password,one-time-code,cc-number,postal-code,given-name,street-address. Включает автозаполнение браузера и менеджеров паролей, аone-time-code— автоподстановку кода из SMS в iOS и Android. Плюс это требование WCAG 2.1, критерий 1.3.5.inputmode— какая клавиатура откроется на телефоне:numeric,decimal,tel,email,url,search. Для кодов и номеров карт беритеinputmode="numeric"приtype="text", а неtype="number": числовой инпут теряет ведущие нули, реагирует на прокрутку колеса и рисует бесполезные стрелки. Рядом с нимenterkeyhintзадаёт надпись на клавише Enter:done,next,send,search.fieldset+legend— единственный нативный способ дать группе радиокнопок общее имя: «Способ доставки, Курьером, переключатель, отмечен, 1 из 2».novalidateотключает браузерные пузыри-подсказки, но не отключает Constraint Validation API:input.validityиform.checkValidity()продолжают работать. Стандартный приём, когда нужен свой дизайн ошибок, но не хочется писать валидацию с нуля.
Жизненный цикл валидности поля
Главная ошибка UX-валидации — красить поле красным на первом же символе: пользователь ещё печатает a, а ему уже сообщают, что почта неверна.
Правило: ошибку показываем поздно (после blur или submit), убираем рано (сразу как исправлено). В CSS это один псевдокласс:
/* :invalid срабатывает сразу при загрузке — почти всегда не то, что нужно */
input:user-invalid { border-color: #c0603c; outline-color: #c0603c; }
input:user-valid { border-color: #5aa469; }
.error:not([hidden])::before { content: "⚠ "; } /* цвет не должен быть единственным сигналом */
:user-invalid и :user-valid учитывают взаимодействие пользователя и поддержаны везде с 2023 года.
Связываем через Constraint Validation API
const form = document.querySelector('form');
// Свои тексты вместо системных: один словарь на все поля
const MESSAGES = {
valueMissing: () => 'Заполните поле',
typeMismatch: () => 'Проверьте формат',
patternMismatch: () => 'Не совпадает с нужным форматом',
tooShort: (el) => `Минимум ${el.minLength} символов`,
};
function showError(el) {
const box = document.getElementById(el.getAttribute('aria-describedby')?.split(' ').at(-1));
const invalid = !el.checkValidity();
el.setAttribute('aria-invalid', String(invalid));
if (!box) return;
const key = Object.keys(MESSAGES).find((k) => el.validity[k]);
box.textContent = invalid ? (key ? MESSAGES[key](el) : el.validationMessage) : '';
box.hidden = !invalid;
}
// Событие invalid не всплывает, поэтому слушаем blur в фазе перехвата
form.addEventListener('blur', (e) => { if (e.target.willValidate) showError(e.target); }, true);
// Поле уже помечено ошибочным — снимаем метку сразу после исправления
form.addEventListener('input', (e) => {
if (e.target.getAttribute('aria-invalid') === 'true') showError(e.target);
});
form.addEventListener('submit', (e) => {
const fields = [...form.elements].filter((el) => el.willValidate);
fields.forEach(showError);
const firstInvalid = fields.find((el) => !el.validity.valid);
if (firstInvalid) { e.preventDefault(); firstInvalid.focus(); } // фокус, а не только скролл
});
willValidate отсеивает кнопки, disabled-поля и элементы без ограничений. setCustomValidity() закрывает правила, которых нет в HTML (совпадение паролей, занятый логин): непустая строка делает поле невалидным, пустая — снова валидным.
Что происходит при отправке
Две вещи ломают эту цепочку чаще всего. У поля нет name — значение не попадёт ни в FormData, ни в тело запроса; никакой ошибки, просто тихо пропадает (проверяйте в DevTools → Network → Payload). Кнопка без type — внутри <form> она по умолчанию submit, поэтому кнопка «Показать пароль» без type="button" отправляет форму: классический баг «форма отправляется сама». Полезная мелочь: submit-кнопка со своими name и value попадает в тело запроса — это бесплатный роутинг действий без JS.
<button type="submit" name="intent" value="save">Сохранить</button>
<button type="submit" name="intent" value="publish">Опубликовать</button>
<!-- на клиенте: Object.fromEntries(new FormData(form)).intent === 'publish' -->
Схемная валидация (Zod, Valibot), React Hook Form и серверные ошибки — тема статьи Формы и валидация. Важно другое: любой фреймворк форм строится поверх этих нативных механизмов, и когда он ломается, чинить приходится здесь.
5. Метаданные: <head> как API для внешнего мира
<head> — структурированное описание страницы для тех, кто её не открывал: поисковиков, мессенджеров, браузера, ОС. Порядок тегов важнее, чем кажется.
<head>
<!-- Кодировка строго в первых 1024 байтах, иначе браузер перезапустит парсинг с начала -->
<meta charset="utf-8">
<!-- НИКОГДА не добавляйте user-scalable=no или maximum-scale=1: это валит WCAG 1.4.4 -->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark"> <!-- иначе тёмная страница мигает белым -->
<meta name="theme-color" content="#111418" media="(prefers-color-scheme: dark)">
<link rel="preconnect" href="https://cdn.example.com" crossorigin> <!-- ранние подключения -->
<link rel="preload" href="/fonts/inter-var.woff2" as="font" type="font/woff2" crossorigin>
<title>Ботинки Trek 3 — Магазин</title>
<meta name="description" content="Зимние ботинки Trek 3: мембрана, вес 480 г, размеры 39–47.">
<link rel="canonical" href="https://example.com/boots/trek-3">
<!-- Превью в соцсетях: og:image ТОЛЬКО абсолютным URL, 1200×630 -->
<meta property="og:type" content="product"><meta property="og:title" content="Ботинки Trek 3">
<meta property="og:url" content="https://example.com/boots/trek-3">
<meta property="og:image" content="https://example.com/og/trek-3.png">
<meta property="og:image:width" content="1200"><meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
</head>
Относительные пути в og:image мессенджеры не резолвят, изображение меньше 200×200 многие площадки игнорируют. Микроразметка говорит поисковику не «здесь текст», а «здесь товар, цена 8990 рублей, в наличии»; Google рекомендует JSON-LD — он не переплетается с вёрсткой и не ломается при рефакторинге.
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Product",
"name": "Ботинки Trek 3",
"image": ["https://example.com/img/trek-3.jpg"],
"brand": { "@type": "Brand", "name": "Trek" },
"offers": { "@type": "Offer", "url": "https://example.com/boots/trek-3",
"priceCurrency": "RUB", "price": "8990", "availability": "https://schema.org/InStock" },
"aggregateRating": { "@type": "AggregateRating", "ratingValue": "4.6", "reviewCount": "218" }
}
</script>
Правило номер один: микроразметка описывает то, что реально есть на странице. Рейтинг в JSON-LD без видимых отзывов — прямой путь к ручным санкциям. Проверять в Rich Results Test и валидаторе schema.org. Окупаются чаще всего типы Product, Article, BreadcrumbList, FAQPage, Organization, JobPosting.
6. Разметка и скорость: почему «просто HTML» тормозит
Core Web Vitals целиком разобраны в статье Производительность фронтенда, но часть метрик выигрывается прямо в разметке — до всякого JavaScript.
| Метрика | Порог «хорошо» | Что решается в HTML |
|---|---|---|
| LCP (загрузка главного элемента) | ≤ 2,5 с | fetchpriority="high" на LCP-картинке, отсутствие на ней loading="lazy", preload шрифта, preconnect к CDN |
| CLS (сдвиги макета) | ≤ 0,1 | width/height у картинок и iframe, зарезервированное место под баннеры |
| INP (отклик на действие) | ≤ 200 мс | меньше JS: нативные dialog, details, popover вместо самописных виджетов |
Preload scanner. Пока основной парсер заблокирован синхронным <script>, браузер запускает вторичный сканер: он бежит вперёд по сырому HTML и заранее ставит в очередь картинки, стили и скрипты. Одна из крупнейших оптимизаций в браузерах — и она видит только то, что написано в разметке.
<img src="/hero.avif" width="1200" height="600" fetchpriority="high" alt="Ботинки Trek 3 на камне">
<div id="hero" data-bg="/hero.avif"></div> <!-- этого сканер не увидит: URL появится после JS -->
<!-- Ниже первого экрана: форматы и размеры через picture/srcset, ленивая загрузка -->
<picture><source type="image/avif" sizes="(min-width: 64rem) 40rem, 100vw"
srcset="/img/trek-400.avif 400w, /img/trek-800.avif 800w, /img/trek-1600.avif 1600w">
<img src="/img/trek-800.jpg" width="1600" height="1067" loading="lazy" decoding="async" alt="…">
</picture>
Вариант с data-bg откладывает загрузку главного изображения на сотни миллисекунд, иногда на секунды. Практическое следствие: фоновая картинка в CSS никогда не должна быть LCP-элементом. А width/height задают соотношение сторон до загрузки файла — это и есть лекарство от CLS (реальный размер по-прежнему задаёт CSS: width: 100%; height: auto). loading="lazy" — только ниже первого экрана; на LCP-изображении это регресс. alt="" (пустая строка, но атрибут присутствует) — правильная разметка декоративной картинки: скринридер её пропустит, тогда как при отсутствии alt он зачитает имя файла. По ежегодному отчёту WebAIM Million отсутствующие альт-тексты и поля без подписей год за годом входят в тройку самых частых нарушений на главных страницах миллиона сайтов.
7. ARIA: когда HTML не хватает
Первое правило ARIA из спецификации: не используйте ARIA, если можно использовать нативный элемент. Второе: плохая ARIA хуже её отсутствия — сломанные роли активно вводят в заблуждение, тогда как без ролей пользователь хотя бы услышит текст. ARIA не добавляет поведения: role="button" не делает элемент кликабельным с клавиатуры, aria-hidden не убирает его из порядка фокуса (для этого есть inert). Она меняет только то, что сообщается дереву доступности — где нативного элемента нет, без неё не обойтись:
<!-- Табы: такого элемента в HTML нет -->
<div role="tablist" aria-label="Информация о товаре">
<button role="tab" id="t1" aria-selected="true" aria-controls="p1">Описание</button>
<button role="tab" id="t2" aria-selected="false" aria-controls="p2" tabindex="-1">Состав</button>
</div>
<div role="tabpanel" id="p1" aria-labelledby="t1">…</div>
<!-- Живые области: контейнер должен быть в DOM ДО появления текста -->
<div role="status" aria-live="polite"></div> <div role="alert"></div>
<button aria-busy="true" aria-disabled="true">Отправляем…</button>
Если вставить <div role="alert">Ошибка</div> целиком, часть скринридеров его не заметит — правильный паттерн — пустой контейнер в разметке, в который потом кладётся текст. aria-disabled вместо disabled на кнопке — сознательный компромисс: настоящий disabled убирает кнопку из фокуса, и пользователь клавиатуры не поймёт, почему форма не отправляется; зато aria-disabled требует руками блокировать обработчик. Готовые паттерны с клавиатурными раскладками — в ARIA Authoring Practices Guide, общая тема — в гайде по accessibility.
8. Семантика в компонентном мире
Главная причина div-супа в 2026 году — не лень, а компонентная абстракция: <Card>, <Stack>, <Box> рендерят div, потому что компонент не знает, где его применят. Лечится полиморфностью и осознанным выбором тега на месте использования.
function Card({ as: Tag = 'div', children, ...rest }) { return <Tag className="card" {...rest}>{children}</Tag>; }
// Карточка поста — это article, а не div
<Card as="article" aria-labelledby="post-42-title"><h3 id="post-42-title">Семантический HTML</h3></Card>
// Лишняя обёртка ломает контракт списка: между ul и li не должно быть div
<ul>{items.map((i) => <React.Fragment key={i.id}><li>{i.title}</li></React.Fragment>)}</ul>
// htmlFor вместо for; id обязан быть уникальным на всей странице
function Field({ label, hint, ...props }) {
const id = React.useId(), hintId = `${id}-hint`; // стабильный id, безопасный при SSR
return (
<div className="field">
<label htmlFor={id}>{label}</label>
<input id={id} aria-describedby={hint ? hintId : undefined} {...props} />
{hint && <p id={hintId} className="hint">{hint}</p>}
</div>
);
}
// Кликабельная карточка: интерактивен один настоящий элемент, а не div с onClick
<article className="card">
<h3><a href={`/posts/${post.slug}`} className="card__link">{post.title}</a></h3>
</article>
// Область клика расширяется на всю карточку без потери семантики:
// .card { position: relative } .card__link::after { content:""; position:absolute; inset:0 }
useId важен именно из-за серверного рендеринга: случайные id, сгенерированные на сервере и на клиенте по-разному, дают ошибку гидратации — про сам механизм в статье Роутинг и стратегии рендеринга.
Честно про фреймворки: по семантике они почти равны, но нюансы есть. Vue и Svelte работают с обычным HTML в шаблонах, поэтому for, class и булевы атрибуты пишутся привычно и ошибиться сложнее. React вводит собственные имена (htmlFor, className) и до 19-й версии молча выбрасывал незнакомые атрибуты — источник тихо пропадающих aria-*-опечаток. Angular из коробки даёт CDK a11y с FocusTrap и LiveAnnouncer. Solid ближе всех к платформе: нет виртуального DOM, атрибуты ставятся напрямую. Разбор целиком — в Сравнении фреймворков. Отдельная история — веб-компоненты: кастомный элемент по умолчанию имеет роль generic, семантику ему дают через ElementInternals (this.attachInternals().role = 'slider'), а участие в формах — через static formAssociated = true и setFormValue().
9. Как проверять себя
Порядок проверок неслучаен: автоматика ловит примерно треть проблем, клавиатурный обход — ещё треть, остальное слышно только на реальном скринридере.
- Nu HTML Checker: дублирующиеся
idи<div>внутри<p>ломают и дерево доступности, и React-гидратацию. - DevTools → дерево доступности: везде ли осмысленные роли и имена вместо
generic. - Обход страницы только с клавиатуры (Tab, Shift+Tab, Enter, Space, Esc): фокус всегда виден и никогда не пропадает.
- axe DevTools или Lighthouse: контраст, имена элементов, порядок заголовков. Затем реальный скринридер: NVDA + Firefox на Windows, VoiceOver + Safari на macOS.
// В CI то же самое автоматически: npx html-validate "dist/**/*.html" и axe внутри E2E
import AxeBuilder from '@axe-core/playwright';
test('страница оформления заказа без нарушений', async ({ page }) => {
await page.goto('/checkout');
const results = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag21aa']).analyze();
expect(results.violations).toEqual([]);
});
10. Типичные ошибки и что вместо них
| Ошибка | Чем плохо | Как правильно |
|---|---|---|
<div onclick> вместо <button> |
нет фокуса, клавиатуры, роли | <button type="button"> |
Картинка без width/height |
сдвиги макета, плохой CLS | всегда реальные размеры |
loading="lazy" на первом экране |
откладывает LCP | lazy только ниже сгиба |
user-scalable=no в viewport |
зум заблокирован, нарушение WCAG 1.4.4 | никогда не использовать |
Поле без name |
значение не уходит на сервер | name у каждого поля с данными |
:invalid вместо :user-invalid |
всё красное сразу при загрузке | :user-invalid |
Мини-итог
- Семантический тег — декларация намерения, за которую браузер бесплатно отдаёт поведение: фокус, клавиатуру, роли, автозаполнение, индексацию.
- Из DOM строится отдельное дерево доступности — общий API для скринридеров, голосового управления и автотестов; смотреть на него нужно так же регулярно, как на экран.
- Ориентиры (
header,nav,main,aside,footer) и лестница заголовков дают навигацию за одно нажатие клавиши. - Формы — платформенный механизм, а не набор инпутов:
label,name,autocomplete,inputmode,fieldset, Constraint Validation API. Ошибки показывать поздно, снимать рано. <head>— публичный API страницы: кодировка в первом килобайте, viewport без блокировки зума, канонический URL, Open Graph с абсолютными ссылками, JSON-LD, соответствующий видимому контенту. Разметка напрямую двигает и Core Web Vitals:width/heightпротив CLS, видимость для preload scanner против LCP, нативные виджеты против INP.- ARIA — на случай, когда нативного элемента нет; она сообщает, но не реализует поведение. В компонентах семантика теряется по умолчанию: делайте тег параметром, а не константой.
Источники
- HTML Living Standard, WHATWG — первоисточник; MDN: справочник элементов — ежедневный рабочий справочник
- ARIA in HTML, W3C и HTML Accessibility API Mappings — точные правила «тег → роль»
- ARIA Authoring Practices Guide — паттерны виджетов с клавиатурой; WCAG 2.2 — критерии, на которые ссылаются регуляторы
- web.dev: Learn HTML и Learn Forms — бесплатные курсы от команды Chrome; Nu HTML Checker, WebAIM Million, Inclusive Components
- Resilient Web Design, Jeremy Keith — книга про прогрессивное улучшение
- Schema.org, Google Search Central: структурированные данные, Open Graph protocol
Что дальше
Разметка задаёт смысл, но пользователь видит оформление. Дальше — по каким правилам браузер решает, какое правило применится к элементу, и почему стили иногда «не работают»: CSS с нуля: каскад, специфичность, наследование, единицы измерения.