open atlas
↑ К треку
React с нуля до senior RCT · 12 · 02

Headless-компоненты: купите стейт-машину, владейте скином

Виджет — это на 80% стейт-машина, клавиатурный контракт, ARIA и фокус: headless-библиотеки дают это и ноль пикселей; за вами скин, движение, плотность. Headless — когда бренд силён, styled-kit — когда важна скорость; обёртка над styled-kit означает войну со специфичностью.

RCT Senior ◷ 17 min
Уровень
ОсновыJuniorMiddleSenior

Самописный дропдаун выглядел идеально — дизайн-команда вылизала каждый пиксель под бренд. Потом enterprise-клиент потребовал WCAG-аудит перед подписанием, и дропдаун провалил одиннадцать критериев за один вечер: ArrowDown не делал ничего, Escape оставлял попап открытым, фокус проваливался в страницу при закрытии, скринридер объявлял «button, collapsed» и замолкал при смене опций. Компонент строили шесть недель два года назад; ретрофит поведения команда оценила ещё в пять — клавиатурный контракт, aria-activedescendant, восстановление фокуса, typeahead, прокрутка к подсвеченной опции, плюс причуды VoiceOver на iOS, которые никто не закладывает в бюджет. Фикс, который реально уехал в прод, занял восемь дней: визуальный слой сняли со старого компонента и натянули на headless-примитив, в котором уже был реализован весь WAI-ARIA-паттерн листбокса. Пиксели не изменились; сделка закрылась. Шесть недель исходных усилий — и трудная часть, невидимые 80%, оказалась той, которую они построили хуже всего и могли получить бесплатно. Весь аргумент за headless — в одном этом аудите.

80%, которых не видно

Select — это не стилизованный список. Это стейт-машина: closed → open (по клику, Enter, Space, ArrowDown), индекс активной опции, двигаемый стрелками и Home/End, буфер typeahead, копящий печатные символы и истекающий по таймауту, выбор, который закрывает или не закрывает попап, и переключение модальности «указатель — клавиатура», решающее, двигает ли hover подсветку. Вокруг машины — клавиатурный контракт (WAI-ARIA-паттерн специфицирует каждую клавишу), ARIA-проводка (role="listbox", role="option", aria-expanded, aria-activedescendant, чтобы скринридер следил за подсветкой, пока DOM-фокус остаётся на триггере) и управление фокусом — ловушка или нет, восстановление при закрытии, что происходит, когда попап размонтируется посреди взаимодействия. Сами контракты разбирает юнит про доступность; этот урок — о том, кто должен их реализовывать. Честная оценка: надёжный комбобокс с нуля — недели работы (опубликованный опыт команд, которые это делали, ложится в диапазон 4–8 недель), а дальше хвост багов длиной в кварталы: события композиции IME, VoiceOver на iOS, скролл-контейнеры, RTL. Скин — цвета, радиусы, плотность, движение — дни. Это соотношение и есть весь аргумент: видимая часть — дешёвая часть.

Ландшафт headless, честно

Headless значит: библиотека поставляет поведение и ни одного пикселя. Три семейства, по-настоящему разных:

  • Radix Primitives поставляют компоненты, рендерящие DOM: Select.Trigger, Select.Content, Select.Item — compound-семейство со встроенной стейт-машиной, клавиатурой, ARIA и фокусом, без стилей. Вы стилизуете отрендеренные элементы через className и data-атрибуты (data-state="open", data-highlighted, data-disabled), открывающие состояние машины для CSS.
  • React Aria поставляет хуки: useSelect, useListBox, useOption возвращают prop-геттеры, которые вы распыляете на элементы, рендеримые вами. Максимальный контроль над DOM, под капотом a11y-строгость Adobe (на этом работает React Spectrum), сборки больше — части соединяете сами.
  • TanStack Table и Virtual — headless в чистейшем смысле: вообще не UI-библиотеки, а логика данных — сортировка, группировка, состояние колонок, виртуальные окна — возвращают голое состояние и функции, ноль DOM, ноль мнений о разметке.

Что вы наследуете бесплатно: клавиатурный контракт, ARIA-проводку, управление фокусом, typeahead, портал и логику закрытия. Что остаётся вашим — и здесь команды обжигаются — это всё видимое: каждому состоянию машины нужен визуальный ответ (data-highlighted без CSS-правила означает, что клавиатурные пользователи навигируют вслепую), движение, плотность, тёмная тема и семантика уровня контента: кнопке с одной иконкой всё равно нужно доступное имя, и никакая библиотека не напишет ваши подписи.

Викторина

Команда берёт Radix Dialog, выпускает кнопку закрытия из одной иконки и авто-фокусирует разрушительное действие Delete. A11y-аудит всё равно валит диалог. Что headless реально купил команде?

Строить на headless или купить styled-kit

Честное решение — не «headless лучше», а трёхсторонний размен между силой бренда, мощностью команды и временем:

  • Styled-kit (MUI, Mantine) побеждает, когда бренд слаб или внутренний — админки, бэк-офис, прототипы — и когда команда мала или без дизайн-поддержки. Вы принимаете внешний вид кита и его мнения, а взамен выпускаете полные, доступные виджеты в этот же спринт. Это хороший размен — если он сделан с открытыми глазами.
  • Headless плюс ваш скин побеждает, когда бренд — это продукт, есть настоящая дизайн-команда, и система проживёт годы. Вы платите дни на виджет за скин и сохраняете полный визуальный контроль; поведение поддерживается, аудируется и чинится выше по течению.
  • С нуля оправдан только там, где для взаимодействия не существует примитива — кастомный canvas-редактор, новая жестовая поверхность, — и тогда документы WAI-ARIA-паттернов становятся спекой, по которой вы реализуете.

Провал, которым полны постмортемы, — гибрид, который никто не выбирал сознательно: обернуть styled-kit и воевать с ним. Команда с сильным брендом берёт MUI «чтобы двигаться быстро», затем переопределяет его — сначала токенами темы (поддерживается), потом вложенными селекторами по внутренним именам классов (не контракт), потом археологией !important. Любой апгрейд кита вправе переименовать эти внутренности; у одной команды минорный бамп версии сломал 40 точек переопределения разом. В итоге вы платите за оба слоя: бандл и мнения кита плюс ваш стилевой слой переопределений, фактически форк его DOM. Внешний вид styled-kit — и есть продукт, который вы купили; купить его, чтобы с ним воевать, — значит владеть войной. Если бренд должен победить — покупайте поведение без пикселей; ради этого headless и существует.

Викторина

Стартап с сильным брендом оборачивает styled-kit, переопределяя внутренние имена классов под дизайн. Через шестнадцать месяцев минорный апгрейд кита ломает 40 точек переопределения. Корневая причина?

Как выглядит раздел на практике

С Radix скин — это CSS по data-атрибутам: состояние машины и есть API стилизации:

.item[data-highlighted] {
  background: var(--surface-raised);   /* подсветка с клавиатуры — без этого правила
                                          навигация стрелками невидима */
}
.content[data-state="open"] {
  animation: slide-in 120ms var(--ease-out);
}
.item[data-disabled] {
  opacity: 0.5;
  pointer-events: none;
}

С React Aria тот же раздел происходит в JSX — хуки владеют поведением, вы владеете каждым элементом:

function Option({ item, state }) {
  const ref = useRef(null);
  const { optionProps, isFocused, isSelected } = useOption(
    { key: item.key }, state, ref
  );
  return (
    <li {...optionProps} ref={ref}
        className={isFocused ? "item item-active" : "item"}>
      {item.rendered}
      {isSelected ? <CheckIcon /> : null}
    </li>
  );
}

В обоих случаях контракт одинаков: библиотека гарантирует, что data-highlighted или isFocused корректны — выставлены стрелками, typeahead, hover в режиме указателя, сброшены при закрытии, — а вы гарантируете, что у каждого такого состояния есть видимый ответ. Чек-листы ревью при внедрении headless — в основном про это: перечислить состояния машины и доказать, что каждое рендерится.

Вспомните перед уходом
  1. 01
    Разложите, что поставляет headless-примитив и что остаётся за внедряющей командой, с провалом по каждую сторону линии.
  2. 02
    Изложите решение «строить на headless против styled-kit» и объясните, почему обёртка styled-kit под бренд — худшая позиция.
Итог

Видимая часть виджета — дешёвая часть. Под продакшен-селектом лежит стейт-машина — переходы open/close, индекс подсветки, буфер typeahead с истечением, модальность «указатель — клавиатура», — обёрнутая в клавиатурный контракт WAI-ARIA, ARIA-проводку, позволяющую скринридеру следить за подсветкой через aria-activedescendant, и управление фокусом с ловушкой, восстановлением и dismiss. Строить это с нуля — недели, а потом кварталы краевых случаев; стилизовать — дни. Headless-библиотеки существуют, чтобы продавать ровно трудную часть с нулём пикселей, в трёх по-настоящему разных формах: Radix Primitives — нестилизованные DOM-рендерящие compound-компоненты, стилизуемые через data-атрибуты вроде data-state и data-highlighted; React Aria — хуки с prop-геттерами над элементами, которые рендерите вы, со строгостью Adobe и большей сборкой; TanStack Table и Virtual — чистая логика данных вообще без DOM. Внедряя их, вы наследуете клавиатурный контракт, ARIA и поведение фокуса — и по-прежнему должны визуальный ответ на каждое состояние машины, все решения о движении и плотности и семантику контента, которую не напишет ни одна библиотека: доступные имена, разумные цели фокуса. Решение между headless и styled-kit честно только как размен: слабый или внутренний бренд с маленькой командой — за styled-kit и его готовый вид; сильный бренд с дизайн-командой и многолетним горизонтом — за headless плюс ваш скин. Позиция, от которой надо отказываться, — случайный гибрид: обернуть styled-kit и переопределять его внутренности под бренд, для которого он не строился, платя одновременно за его бандл и за ваш форк переопределений — в одном минорном апгрейде от 40 сломанных точек. Теперь, когда встретишь самописный дропдаун с провалом на a11y-аудите, ты знаешь: не хватало невидимых 80% — и знаешь, что решение не переписать компонент, а пересадить его визуальный слой на примитив, у которого эти 80% уже есть.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.