UI-компонент — повторяемый элемент интерфейса с заранее заданными частями, вариантами и состояниями: кнопку, поле или карточку описывают один раз так, чтобы их можно было сверстать без догадок. Для каждого компонента определяют анатомию (из каких частей он состоит), варианты (primary, secondary, размеры) и все состояния — default, hover, focus, active, disabled, loading и error. Состояние, которого нет в макете, всё равно появится в продукте, только решать, как оно выглядит, будет тот, кто верстает.
После материала вы сможете
- разложить кнопку, поле ввода и карточку на части: контейнер, подпись, иконка, вспомогательный текст, действия
- перечислить для компонента все состояния — default, hover, focus-visible, active, disabled, loading, error — и описать, что меняется в каждом
- составить матрицу «вариант × состояние» и спецификацию компонента для передачи в разработку
- объяснить, почему фокус нельзя прятать, а placeholder не заменяет подпись поля
Что знать заранее
Проверено на
HTML + CSSMicrosoft Edge 155Playwright 1.64
Сверено с документацией: 11 октября 2026 г.
Содержание
- Компонент как договор между дизайном и кодом
- Анатомия: контейнер, подпись, иконка, вспомогательный текст
- Варианты и размеры: primary, secondary, ghost; S/M/L
- Состояния: hover, focus-visible, active, disabled, loading, error
- Поле ввода: label, placeholder, подсказка, ошибка
- Карточка: кликабельная область и вложенные действия
- Спецификация компонента для передачи в разработку
Компонент как договор между дизайном и кодом
Кнопка «Записаться» встречается на сайте в десятке мест: в шапке, на карточке услуги, в форме, в модальном окне. Если каждый раз её рисуют заново, через пару месяцев в продукте живут пять разных синих кнопок с разными отступами, радиусами и оттенками. Компонент решает эту проблему: элемент описывают один раз — части, варианты, состояния, правила применения, — а дальше только используют.
Для дизайнера компонент — мастер-элемент в макете: в Figma он так и называется, component, а его копии на экранах — instances. Для разработчика это код с параметрами, например <Button variant="primary" size="m">. Договор работает, когда обе стороны называют одно и то же одинаково: те же варианты, те же состояния, те же имена свойств. Набор компонентов называют UI-kit, а UI-kit вместе с токенами, правилами и документацией — дизайн-системой.
Анатомия: контейнер, подпись, иконка, вспомогательный текст
Анатомия — это разбор компонента на именованные части. Названия нужны не ради терминологии: когда в спецификации написано «в состоянии error меняется только рамка контейнера», разработчик точно знает, какое свойство трогать. Apple в Human Interface Guidelines описывает кнопку тремя атрибутами: стиль (размер, цвет, форма), содержимое (иконка, текст или оба) и роль (смысл действия: основное, отмена, удаление).
Контейнер
- Кнопка
- Фон, рамка, радиус, внутренние отступы
- Поле ввода
- Рамка или подложка, высота, радиус
- Карточка
- Фон, рамка или тень, отступы, область клика
Подпись
- Кнопка
- Глагол действия: «Записаться», «Сохранить»
- Поле ввода
- Label над полем, видна всегда
- Карточка
- Заголовок — главная ссылка карточки
Иконка
- Кнопка
- Необязательна, слева или справа от текста
- Поле ввода
- Необязательна: поиск, показать пароль, очистить
- Карточка
- Изображение или иконка сверху
Вспомогательный текст
- Кнопка
- Нет: пояснение ставят рядом с кнопкой
- Поле ввода
- Подсказка и сообщение об ошибке под полем
- Карточка
- Описание и мета: цена, дата, теги
Действия
- Кнопка
- Сама кнопка и есть действие
- Поле ввода
- Иногда кнопка внутри поля («Очистить»)
- Карточка
- Основное — переход, второстепенные — отдельные кнопки
Размер области нажатия — тоже часть анатомии. Apple рекомендует для кнопки область не меньше 44×44 pt. WCAG 2.2 в критерии 2.5.8 (уровень AA) требует, чтобы цель для указателя была не меньше 24×24 CSS-пикселей, с оговорёнными в стандарте исключениями. Видимая часть может быть компактнее, но кликабельная область — нет.
Варианты и размеры: primary, secondary, ghost; S/M/L
Вариант — заранее определённая версия компонента для действий разной важности. У кнопки обычно три-четыре уровня заметности:
Primary
- Как выглядит
- Заливка фирменным цветом
- Когда использовать
- Главное действие экрана: «Записаться», «Оплатить»
Secondary
- Как выглядит
- Контур или светлая подложка
- Когда использовать
- Альтернатива рядом с главным: «Позвонить», «Подробнее»
Ghost (текстовая)
- Как выглядит
- Только текст, подложка появляется при наведении
- Когда использовать
- Третьестепенное: «Отмена», «Пропустить»
Destructive
- Как выглядит
- Красный акцент
- Когда использовать
- Удаление и другие необратимые действия
Apple советует держать на одном экране одну-две заметные кнопки: чем больше акцентов, тем дольше человек выбирает. Второе правило оттуда же: предпочтительный вариант выделяют стилем, а не размером — кнопки разной величины рядом выглядят несогласованно.
Размеры (S, M, L) меняют высоту, внутренние отступы и кегль, но не смысл. Их удобно брать из той же шкалы отступов, что и весь интерфейс (о шкале — в уроке про визуальную иерархию). Для основного действия на телефоне выбирайте размер, у которого область нажатия не меньше рекомендованной платформой.
Состояния: hover, focus-visible, active, disabled, loading, error
Состояние — то, как компонент выглядит и ведёт себя в данный момент. Для пользователя это ответ на вопрос «меня поняли?». Nielsen Norman Group советует не задерживать отклик: фокус и нажатие должны отображаться примерно за 100–150 мс, иначе люди жмут повторно.
Default
- Когда
- Компонент доступен
- Что меняется
- Базовый вид
- Нюанс
- Подпись контрастна к фону
Hover
- Когда
- Курсор над элементом
- Что меняется
- Фон темнеет или появляется подложка
- Нюанс
- На сенсорных экранах наведения нет — смысл за ним не прячут
Focus-visible
- Когда
- Фокус с клавиатуры
- Что меняется
- Контур вокруг компонента
- Нюанс
- WCAG 2.4.7: фокус должен быть виден; контур — не меньше 3:1 к соседнему цвету
Active
- Когда
- Момент нажатия
- Что меняется
- Фон ещё темнее, лёгкий сдвиг
- Нюанс
- Apple: у собственной кнопки состояние нажатия обязательно
Disabled
- Когда
- Действие сейчас недоступно
- Что меняется
- Приглушённые фон и подпись
- Нюанс
- Не фокусируется; причину объясняют рядом
Loading
- Когда
- Действие выполняется
- Что меняется
- Спиннер, подпись остаётся
- Нюанс
- Повторные нажатия игнорируются, результат объявляется
Error
- Когда
- Значение неверно или действие не удалось
- Что меняется
- Рамка и текст ошибки
- Нюанс
- Цвет — не единственный признак
Про фокус. Псевдокласс :focus срабатывает при любом фокусе, а :focus-visible — только когда браузер решает, что фокус нужно показать: при переходе клавишей Tab — да, при клике мышью по кнопке — обычно нет. Это позволяет дать клавиатуре заметный контур и не мешать им тем, кто кликает. Убирать индикатор совсем нельзя: MDN прямо предупреждает, что без него навигация с клавиатуры становится недоступной для зрячих пользователей.
Про disabled. Атрибут disabled у <button> браузер обрабатывает сам: кнопка не нажимается и выпадает из порядка Tab. Критерий WCAG 1.4.11 освобождает неактивные компоненты от требования контраста 3:1, но подпись всё равно должна читаться. Если недоступная кнопка должна оставаться в фокусе, используют aria-disabled="true" — тогда блокировать нажатие и стилизовать состояние придётся самостоятельно.
Ниже — небольшой пример: три варианта кнопки, отключённая кнопка и состояние загрузки. Его можно сохранить в два файла рядом и открыть в браузере.
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Состояния кнопки</title>
<link rel="stylesheet" href="button.css">
</head>
<body>
<form id="booking">
<button class="btn btn--primary" type="submit">Записаться</button>
<button class="btn btn--secondary" type="button">Позвонить</button>
<button class="btn btn--ghost" type="button">Отмена</button>
<button class="btn btn--primary" type="button" disabled>Нет свободных окон</button>
<p id="status" role="status"></p>
</form>
<script>
const form = document.getElementById("booking");
const submit = form.querySelector('[type="submit"]');
const status = document.getElementById("status");
form.addEventListener("submit", async (event) => {
event.preventDefault();
if (submit.getAttribute("aria-busy") === "true") return; // уже отправляем
submit.setAttribute("aria-busy", "true");
status.textContent = "Отправляем заявку…";
await new Promise((resolve) => setTimeout(resolve, 1500)); // вместо запроса
submit.removeAttribute("aria-busy");
status.textContent = "Заявка отправлена";
});
</script>
</body>
</html>:root {
--brand: #1d4ed8;
--brand-hover: #1e40af;
--brand-active: #1e3a8a;
--brand-tint: #eff6ff;
--on-brand: #ffffff;
--muted-bg: #e5e7eb;
--muted-text: #4b5563;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 44px;
padding: 0 20px;
border: 1px solid transparent;
border-radius: 10px;
font: 600 16px/1.2 system-ui, sans-serif;
cursor: pointer;
transition: background-color 150ms ease-out, transform 100ms ease-out;
}
/* Варианты: один и тот же компонент, разная заметность */
.btn--primary { background: var(--brand); color: var(--on-brand); }
.btn--secondary { background: transparent; color: var(--brand); border-color: currentColor; }
.btn--ghost { background: transparent; color: var(--brand); }
/* Hover и active — только у доступной кнопки */
.btn--primary:not(:disabled, [aria-busy="true"]):hover { background: var(--brand-hover); }
.btn--secondary:not(:disabled, [aria-busy="true"]):hover,
.btn--ghost:not(:disabled, [aria-busy="true"]):hover { background: var(--brand-tint); }
.btn--primary:not(:disabled, [aria-busy="true"]):active { background: var(--brand-active); }
.btn:not(:disabled, [aria-busy="true"]):active { transform: translateY(1px); }
/* Фокус с клавиатуры: контур не меньше 3:1 к фону страницы */
.btn:focus-visible {
outline: 3px solid var(--brand);
outline-offset: 2px;
}
/* Disabled: приглушён, но подпись читается */
.btn:disabled {
background: var(--muted-bg);
color: var(--muted-text);
border-color: transparent;
cursor: not-allowed;
}
/* Loading: кнопка остаётся в фокусе, повторные нажатия игнорирует скрипт */
.btn[aria-busy="true"] { cursor: progress; }
.btn[aria-busy="true"]::before {
content: "";
width: 16px;
height: 16px;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: btn-spin 700ms linear infinite;
}
@keyframes btn-spin {
to { transform: rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
.btn { transition: none; }
.btn[aria-busy="true"]::before { animation-duration: 2s; }
}Атрибут aria-busy="true" сообщает вспомогательным технологиям, что содержимое элемента обновляется; здесь он же служит хуком для стиля спиннера. Сам он ничего не объявляет: результат зачитывает область role="status" — её текст экранные дикторы озвучивают, не перенося фокус.
Hover и active
Наведите курсор на «Записаться» — фон темнеет; зажмите кнопку мыши — темнеет ещё сильнее, кнопка чуть сдвигается вниз.
Фокус
Кликните по «Позвонить» — контура нет. Нажмите Tab — контур появляется на «Отмена».
Disabled
Продолжайте нажимать Tab: кнопка «Нет свободных окон» пропускается, клик по ней ничего не делает.
Loading
Нажмите Enter на «Записаться» несколько раз подряд: появляется спиннер, отправка одна, фокус остаётся на кнопке, под кнопками появляется статус.
Уменьшение движения
Включите в системе «уменьшить движение» — плавные переходы отключатся, спиннер замедлится.
Мы прогнали эти шаги автоматически в Microsoft Edge 155 через Playwright: все состояния переключаются, контраст подписи к фону — не ниже 4,5:1 во всех состояниях, включая disabled, контур фокуса к белому фону — 6,7:1.
Поле ввода: label, placeholder, подсказка, ошибка
Поле ввода состоит из подписи (label), самого поля, подсказки под ним и места для сообщения об ошибке. Самая частая ошибка в макетах — убрать подпись и оставить только серый текст внутри поля, placeholder. Nielsen Norman Group разбирает, почему это вредит:
- текст исчезает, как только человек начал печатать, — приходится держать в голове, что это было за поле
- заполненную форму трудно проверить перед отправкой: подписей больше нет
- серый текст плохо контрастирует с фоном, а некоторые экранные дикторы его не зачитывают
- подсказку принимают за уже введённое значение и пропускают поле
Вывод Nielsen Norman Group: подпись и важную подсказку лучше размещать вне поля. Placeholder допустим как пример формата («+7 700 000 00 00»), но не вместо подписи.
Default и focus
Подпись над полем, рамка контрастна к фону. В фокусе рамка утолщается или меняет цвет, курсор мигает внутри.
Filled
Введённое значение выглядит иначе, чем placeholder: тёмный текст, а не серый.
Error
Показывается после попытки отправить форму или после ухода с поля, а не при загрузке страницы. Рамка становится красной, под полем — текст: что не так и как исправить. MDN рекомендует ставить
aria-invalid="true"только по результату проверки и связывать поле с текстом ошибки.Disabled и read-only
Недоступное поле приглушено. Поле «только для чтения» — другое состояние: значение можно выделить и скопировать.
Карточка: кликабельная область и вложенные действия
Nielsen Norman Group определяет карточку как контейнер для нескольких коротких связанных фрагментов информации — краткое представление объекта со ссылкой на подробности. Типичные части: изображение, заголовок, короткое описание, мета (дата, цена, теги) и иногда второстепенное действие.
Чаще всего вся карточка ведёт на страницу объекта: большая область нажатия удобнее на телефоне и для мыши. Но внутри бывают свои кнопки — «В избранное», «Поделиться». Обернуть всю карточку в ссылку <a> и положить внутрь кнопку нельзя: HTML запрещает вкладывать интерактивные элементы в ссылку. Рабочий приём — ссылкой делают заголовок, а её область нажатия растягивают на всю карточку через CSS; второстепенные кнопки ставят поверх этого слоя.
Как ломается
- Вся карточка — ссылка, внутри кнопка «В избранное»: невалидная разметка, клик по сердечку открывает страницу
- Кликабельна только маленькая надпись «Подробнее»
- Фокус с клавиатуры виден только на тексте, а не на карточке
- Наведение — единственный признак, что карточка кликабельна
Как стоит
- Заголовок — ссылка, её область растянута на карточку
- Второстепенные действия — отдельные кнопки, визуально отделённые от контента
- При focus-visible контур обводит всю карточку
- Карточку выдают тень, рамка и заголовок-ссылка, а не только hover
У карточки те же состояния, что у кнопки, плюс свои: hover (тень или подъём), focus-visible, selected — если карточки выбирают, как тарифы, — и loading, когда вместо неё временно стоит skeleton. Nielsen Norman Group напоминает и об ограничении: карточки хороши для просмотра разнородного контента, но плохо подходят, когда варианты нужно сравнивать между собой, — там удобнее список или таблица.
Спецификация компонента для передачи в разработку
Спецификация — короткий документ или страница в макете, где собрано всё, что нужно для вёрстки компонента. По нашему опыту, она экономит больше всего времени на ревью: вопросы «а как это выглядит при ошибке?» закрываются до кода, а не после.
- Назначение: какую задачу решает компонент и когда его не использовать
- Анатомия: схема с подписанными частями и обязательными / необязательными элементами
- Варианты и размеры: список с примерами применения
- Матрица «вариант × состояние»: каждая ячейка нарисована или явно помечена «не бывает»
- Размеры и отступы: высота, внутренние отступы, радиус, минимальная область нажатия — через токены, а не «на глаз»
- Поведение: что происходит при нажатии, при долгой загрузке, при ошибке; что с фокусом после действия
- Тексты: правила подписей, максимальная длина, перенос длинного текста
- Доступность: контраст, видимый фокус, подпись для экранного диктора у кнопки-иконки
Как проверено3 · пройденосверено 11 октября 2026 г.
- запущено, работает
index.html + button.css (варианты, hover/active/focus-visible/disabled/aria-busy, role=status)
статическая страница во временной папке, Playwright 1.64 (из node_modules репозитория) + Microsoft Edge 155 headless: 21 проверка (computed styles по состояниям, :focus-visible после клика мышью/Tab, disabled вне Tab-порядка и без click, одна отправка при повторных нажатиях, фокус сохраняется, статус, reduced motion, контраст ≥4,5:1 / контур 6,7:1), скриншоты 01–06; EN-вариант (verify/…/en) прогнан тем же сценарием — 21 PASS
- запущено, работает
danger.css (решение упражнения destructive)
подключён поверх button.css в Edge 155: hover/active/focus-visible переключаются, контраст белого текста 6,47/8,31/10,02:1
- запущено, работает
утверждение «disabled на сфокусированной кнопке сбрасывает фокус»
Edge 155: после btn.disabled = true document.activeElement = BODY
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.
Источники
- Nielsen Norman Group — Button States: Communicate Interaction
- Apple Human Interface Guidelines — Buttons
- W3C WAI — Understanding SC 2.4.7: Focus Visible
- W3C WAI — Understanding SC 1.4.11: Non-text Contrast
- Nielsen Norman Group — Placeholders in Form Fields Are Harmful
- Nielsen Norman Group — Cards: UI-Component Definition