Headless-компоненты: купите стейт-машину, владейте скином
Виджет — это на 80% стейт-машина, клавиатурный контракт, ARIA и фокус: headless-библиотеки дают это и ноль пикселей; за вами скин, движение, плотность. Headless — когда бренд силён, styled-kit — когда важна скорость; обёртка над styled-kit означает войну со специфичностью.
Самописный дропдаун выглядел идеально — дизайн-команда вылизала каждый пиксель под бренд. Потом 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 — в основном про это: перечислить состояния машины и доказать, что каждое рендерится.
- 01Разложите, что поставляет headless-примитив и что остаётся за внедряющей командой, с провалом по каждую сторону линии.
- 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-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.