Каскад, CSS Modules, токены дизайна, варианты, компоненты без оформления, темы, адаптивность и визуальные контракты.
Дизайн-система — это набор ограничений и контрактов, а не папка случайных компонентов одного цвета.
Вы построите компоненты с темами на основе токенов, разделите структуру и оформление и зафиксируете варианты и адаптивное поведение.
Понадобятся проектирование API компонентов, доступность и современный CSS. Итог урока — базовые Button, Card и Dialog с токенами, темой, адаптацией к контейнеру и визуальными тестами.
Каскад учитывает источник, слой, специфичность и порядок. CSS Modules дают локальные имена классов, но глобальные токены и каскад продолжают работать. Не решайте конфликт постоянным повышением специфичности и !important; заранее задайте слои (cascade layers) и владельцев правил.
Слои задают порядок один раз и снимают гонку специфичности: правило из позднего слоя выигрывает независимо от числа селекторов.
/* src/styles/index.css */
@layer reset, base, components, utilities;
@layer components {
.card { padding: var(--space-4); background: var(--color-surface); }
}
@layer utilities {
/* победит без !important: слой utilities объявлен позже */
.p-0 { padding: 0; }
}Без слоёв та же задача решается либо !important, либо селектором вида .page .card div span — и оба варианта делают компонент зависимым от страницы, на которой он оказался.
Проверьте себя. Разделите слои сброса, основы, компонентов и утилит.
Частая ошибка. Селектор .page .card div span как API компонента.
Базовые цвета и интервалы преобразуются в семантические токены (design tokens) поверхности, текста, акцента, опасности и фокуса. Компоненты используют семантический слой, а тема меняет соответствие значениям. Имя токена описывает роль, а не конкретный цвет или экземпляр компонента.
Двухслойная схема выглядит так: сырые значения отдельно, роли отдельно.
/* src/styles/tokens.css */
:root {
/* слой значений: сюда не смотрят компоненты */
--blue-600: #2563eb;
--slate-50: #f8fafc;
/* слой ролей: только это используется в компонентах */
--color-primary: var(--blue-600);
--color-surface: var(--slate-50);
--color-ring: var(--blue-600);
}Компонент ссылается на роль, поэтому смена палитры или темы не требует правок в компонентах:
.button--primary {
background: var(--color-primary);
color: var(--color-background);
}Имя --color-button-blue вместо --color-primary разрушает смысл слоя: при смене фирменного цвета на зелёный имя станет ложью, а найти все места использования будет нечем.
Проверьте себя. Перенесите цвета Button в семантические пользовательские свойства.
Частая ошибка. Шестнадцатеричные цвета и случайные интервалы в каждом компоненте.
Свойства вариантов — объединения строковых литералов, согласованные с правилами CSS. Составные варианты описывают реальные сочетания; не создавайте имена классов из произвольной пользовательской строки. Размер, тон и выразительность независимы лишь тогда, когда поддержаны все комбинации.
Конечное перечисление вариантов проверяется компилятором и не даёт собрать несуществующий класс:
// src/shared/ui/button.tsx
type Variant = 'primary' | 'secondary' | 'ghost';
type Size = 'sm' | 'md' | 'lg';
const styles: Record<Variant, string> = {
primary: 'bg-primary-600 text-background',
secondary: 'bg-surface border border-border',
ghost: 'bg-transparent hover:bg-surface',
};
export function Button({ variant = 'primary', size = 'md', ...rest }: ButtonProps) {
return <button className={cn(styles[variant], sizes[size])} {...rest} />;
}Сборка имени из строки выглядит короче, но ломает и типизацию, и очистку неиспользованных стилей:
// ❌ класса button-${userValue} может не существовать, а сборщик его не найдёт
<button className={`button-${userValue}`} />Матрица вариантов и состояний нужна именно для того, чтобы обнаружить неподдерживаемые сочетания: ghost + disabled + тёмная тема часто оказывается нечитаемым.
Проверьте себя. Составьте матрицу вариантов и состояний.
Частая ошибка. className={"button-" + userValue}.
Провайдер темы задаёт атрибут или класс и семантические пользовательские свойства. Компоненты остаются теми же; SSR должен выдать начальную тему или ранний скрипт без вспышки и расхождения гидратации. Системная тема служит значением по умолчанию, выбор пользователя — сохраняемым переопределением.
Тема меняет значения ролей, а не структуру дерева:
:root { --color-surface: #ffffff; --color-foreground: #0f172a; }
:root[data-theme='dark'] { --color-surface: #0f172a; --color-foreground: #e2e8f0; }
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) { --color-surface: #0f172a; --color-foreground: #e2e8f0; }
}Переключение — это запись атрибута, поэтому поддерево не перемонтируется и состояние компонентов не теряется:
// src/shared/theme/ThemeProvider.tsx
useEffect(() => {
document.documentElement.dataset.theme = theme;
}, [theme]);Вариант isDark ? <DarkButton /> : <LightButton /> даёт другую идентичность элементов: при переключении темы React размонтирует поддерево, и открытая форма, положение прокрутки и фокус будут потеряны.
Проверьте себя. Переключите светлую и тёмную темы без повторного монтирования поддерева.
Частая ошибка. isDark ? <DarkButton> : <LightButton> для каждого компонента.
Медиазапросы области просмотра отвечают за глобальную среду, контейнерные запросы (container queries) — за доступное компоненту место. Переиспользуемая карточка не должна знать страницу; объявите контейнер и адаптируйте внутреннюю разметку. Используйте логические свойства и точки перелома, выбранные по содержимому.
Контейнерный запрос описывает правило «если мне дали мало места», и компонент становится переносимым:
.card-slot { container-type: inline-size; }
.card { display: grid; grid-template-columns: 1fr; }
@container (min-width: 480px) {
.card { grid-template-columns: 96px 1fr; } /* картинка слева, когда место есть */
}Одна и та же карточка теперь корректно выглядит и в узкой боковой панели, и в широкой сетке — при одинаковой ширине окна. Медиазапрос этого не умеет: он знает только про область просмотра. Чтение window.innerWidth в JavaScript добавляет к этому ещё и расхождение при гидратации, потому что на сервере такого значения нет.
Проверьте себя. Покажите Card в боковой панели и основной сетке при одной ширине окна.
Частая ошибка. Читать window.innerWidth в JavaScript для каждого решения о расположении.
Движение объясняет изменение и сохраняет непрерывность, но не задерживает основное действие. Переходы CSS подходят простым состояниям; сложное появление и уход требуют отдельного инструмента и очистки. prefers-reduced-motion убирает несущественное движение, сохраняя состояние понятным.
Уменьшенное движение не означает «без обратной связи»: изменение состояния остаётся, исчезает только перемещение.
.dialog { animation: dialog-in 180ms ease-out; }
@keyframes dialog-in {
from { opacity: 0; transform: translateY(8px) scale(0.98); }
to { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
.dialog { animation: fade-in 120ms linear; } /* появление есть, движения нет */
}Анимация длиной 800 мс перед доступностью результата — это не оформление, а задержка работы: пользователь уже знает, что нажал кнопку, и ждёт данные. Ориентир для интерфейсных переходов — 120–200 мс.
Проверьте себя. Добавьте вариант перехода диалога с уменьшенным движением.
Частая ошибка. Анимация 800ms перед доступностью результата.
Слой без оформления (headless) реализует модель состояния, aria-* и клавиатуру; оформленный слой задаёт токены и расположение. Разделение полезно при нескольких визуальных системах, но обязательные свойства и ref нельзя потерять. Иногда один цельный компонент проще и безопаснее.
Оформленный компонент оборачивает поведение и пробрасывает ref — иначе внешний код потеряет доступ к узлу для фокуса и измерений:
// src/shared/ui/select.tsx
export const Select = forwardRef<HTMLButtonElement, SelectProps>(function Select(
{ label, ...rest },
ref,
) {
return (
<SelectPrimitive.Root {...rest}>
{/* поведение и aria-* берём у примитива, задаём только оформление */}
<SelectPrimitive.Trigger ref={ref} className="border border-border rounded-xl px-3 py-2">
<SelectPrimitive.Value placeholder={label} />
</SelectPrimitive.Trigger>
</SelectPrimitive.Root>
);
});Если вместо этого отдать вызывающей стороне сырые aria-* без безопасных значений по умолчанию, каждая команда реализует доступность заново — и каждая по-своему ошибётся.
Проверьте себя. Соберите оформленный Select поверх контракта поведения.
Частая ошибка. Отдать вызывающей стороне низкоуровневые детали aria-* без безопасных значений по умолчанию.
Снимки экрана ловят изменения расположения и цвета в фиксированных примерах и размерах окна. Они чувствительны к шрифтам и сглаживанию и не доказывают поведение или доступность. Стабилизируйте окружение, скрывайте случайное содержимое и проверяйте ожидаемые различия вручную.
Полезный визуальный тест снимает матрицу состояний компонента, а не главную страницу целиком:
// e2e/button.visual.spec.ts
for (const theme of ['light', 'dark'] as const) {
test(`варианты Button, тема ${theme}`, async ({ page }) => {
await page.goto(`/__stories/button?theme=${theme}`);
await page.clock.setFixedTime(new Date('2026-01-01T00:00:00Z')); // убираем время
await expect(page.getByTestId('button-matrix')).toHaveScreenshot(`button-${theme}.png`);
});
}Фиксация времени, отключение анимаций и стабильные шрифты обязательны: иначе тест начнёт падать от миллисекунд и сглаживания, и его отключат. Снимок главной страницы, наоборот, падает на любом изменении контента и при этом не покрывает состояния disabled, focus-visible и loading.
Проверьте себя. Зафиксируйте матрицу вариантов в двух темах.
Частая ошибка. Один снимок главной страницы вместо состояний компонентов.
Создайте небольшую дизайн-систему: семантические токены цвета, интервалов и типографики, светлую и тёмную темы, варианты Button, адаптивную сетку Card, состояния фокуса, отключения и высокой контрастности и примеры компонентов. Не используйте исходные цвета напрямую.
Работа готова, когда темы меняются без ветвления JSX, компоненты не зависят от селекторов страницы, а контрастность, фокус и визуальные состояния проверены.
Далее: Производительность и React Compiler