Frontend-разработка Семантический HTML: структура, формы, метаданные и почему теги важны
0%

Семантический HTML: структура, формы, метаданные и почему теги важны

Семантический 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>&copy; 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" руками.

Заголовки: то, что ломают чаще всего

Заголовки h1h6 — оглавление документа, скринридер строит из них навигационное дерево. Уровень отражает вложенность смысла, а не размер шрифта. Отдельно: алгоритм автопостроения структуры (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. Как проверять себя

Порядок проверок неслучаен: автоматика ловит примерно треть проблем, клавиатурный обход — ещё треть, остальное слышно только на реальном скринридере.

  1. Nu HTML Checker: дублирующиеся id и <div> внутри <p> ломают и дерево доступности, и React-гидратацию.
  2. DevTools → дерево доступности: везде ли осмысленные роли и имена вместо generic.
  3. Обход страницы только с клавиатуры (Tab, Shift+Tab, Enter, Space, Esc): фокус всегда виден и никогда не пропадает.
  4. 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 — на случай, когда нативного элемента нет; она сообщает, но не реализует поведение. В компонентах семантика теряется по умолчанию: делайте тег параметром, а не константой.

Источники

Что дальше

Разметка задаёт смысл, но пользователь видит оформление. Дальше — по каким правилам браузер решает, какое правило применится к элементу, и почему стили иногда «не работают»: CSS с нуля: каскад, специфичность, наследование, единицы измерения.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов