Принцип пары
Ключевая идея темы: поверхность и текст на ней объявляются
вместе. Токен без суффикса — фон, токен с -foreground —
то, что на нём пишут.
<div class="bg-card text-card-foreground">…</div>
<button class="bg-primary text-primary-foreground">…</button>
<span class="bg-destructive/10 text-destructive">…</span>
Благодаря этому невозможно случайно получить «серый текст на сером фоне» при смене темы: пара меняется целиком.
Тема — как набор форменной одежды: китель и рубашка подобраны друг к другу. Вы не выбираете «тёмно-синий» и «тёмно-серый» по отдельности — вы берёте комплект. Поэтому люди в форме выглядят одинаково аккуратно, независимо от того, кто утром одевался.
Полный набор ролей
| Токен | Где применяется |
|---|---|
--background / --foreground | фон страницы и основной текст |
--card / --card-foreground | карточки и панели |
--popover / --popover-foreground | всплывающие слои: меню, подсказки |
--primary / --primary-foreground | основное действие |
--secondary / --secondary-foreground | второстепенное действие |
--muted / --muted-foreground | приглушённые области и подписи |
--accent / --accent-foreground | подсветка: наведение в меню, активная строка |
--destructive | опасные действия и ошибки |
--border, --input, --ring | границы, поля ввода, кольцо фокуса |
--chart-1 … --chart-5 | цвета графиков |
--sidebar и производные | боковая панель со своим набором пар |
--radius | базовый радиус, из него считаются остальные |
<div class="grid gap-2 sm:grid-cols-2">
<div class="rounded-lg bg-background p-3 text-sm text-foreground ring-1 ring-border">background / foreground</div>
<div class="rounded-lg bg-card p-3 text-sm text-card-foreground ring-1 ring-border">card / card-foreground</div>
<div class="rounded-lg bg-primary p-3 text-sm text-primary-foreground">primary / primary-foreground</div>
<div class="rounded-lg bg-secondary p-3 text-sm text-secondary-foreground">secondary / secondary-foreground</div>
<div class="rounded-lg bg-muted p-3 text-sm text-muted-foreground">muted / muted-foreground</div>
<div class="rounded-lg bg-accent p-3 text-sm text-accent-foreground">accent / accent-foreground</div>
<div class="rounded-lg bg-destructive/10 p-3 text-sm text-destructive">destructive</div>
<div class="rounded-lg p-3 text-sm text-foreground ring-1 ring-ring">ring</div>
</div>
Как токены становятся утилитами
Связь описана в блоке @theme inline:
/* src/index.css — сопоставление токенов с утилитами */
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
}
Слово inline здесь принципиально. Оно означает: утилита
bg-card получит значение var(--card) —
ту самую переменную, которую вы переопределяете в .dark.
Без inline значение зафиксировалось бы на этапе сборки,
и переключение темы не работало бы.
Значения: светлая и тёмная
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
}
Обратите внимание на две детали.
Первая: --primary в тёмной теме становится
светлым (oklch(0.922 0 0)), а его foreground — тёмным.
Пара переворачивается, потому что светлая кнопка на тёмном фоне контрастнее
тёмной.
Вторая: границы в тёмной теме заданы через прозрачность
oklch(1 0 0 / 10%) — белым с малой непрозрачностью. Так граница
подстраивается под любую поверхность под ней.
Радиусы от одной переменной
--radius: 0.625rem; /* 10px — базовый */
--radius-sm: calc(var(--radius) * 0.6); /* 6px */
--radius-md: calc(var(--radius) * 0.8); /* 8px */
--radius-lg: var(--radius); /* 10px */
--radius-xl: calc(var(--radius) * 1.4); /* 14px */
Одно значение задаёт «характер» интерфейса: 0 — строгий и техничный, 0.625rem — нейтральный, 1.5rem — мягкий и дружелюбный. Меняется в одной строке.
<div class="flex flex-wrap items-center gap-4">
<div class="rounded-none bg-primary px-4 py-2 text-sm text-primary-foreground">radius: 0</div>
<div class="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground">radius: 0.625rem</div>
<div class="rounded-2xl bg-primary px-4 py-2 text-sm text-primary-foreground">radius: 1rem</div>
<div class="rounded-full bg-primary px-4 py-2 text-sm text-primary-foreground">полный</div>
</div>
Свой фирменный токен
Набор ролей не догма — его расширяют. Добавим пару для бренда:
/* src/index.css — добавляем свою семантическую пару */
@theme inline {
--color-brand: var(--brand);
--color-brand-foreground: var(--brand-foreground);
}
:root {
--brand: oklch(0.55 0.21 264);
--brand-foreground: oklch(0.985 0 0);
}
.dark {
--brand: oklch(0.62 0.19 264);
--brand-foreground: oklch(0.145 0 0);
}
После этого bg-brand, text-brand,
border-brand и ring-brand работают как встроенные,
и их можно использовать в вариантах компонентов (урок 18):
<div class="flex flex-wrap items-center gap-3">
<button class="inline-flex h-8 items-center justify-center rounded-lg bg-brand px-3 text-sm font-medium text-brand-foreground hover:opacity-90">bg-brand</button>
<span class="inline-flex h-8 items-center rounded-lg border border-brand px-3 text-sm text-brand">border-brand</span>
<span class="text-sm font-semibold text-brand">text-brand</span>
</div>
--brand переживёт ребрендинг, а --blue станет
враньём в тот день, когда бренд позеленеет.Когда className, а когда токен
| Ситуация | Решение |
|---|---|
| Один блок должен быть шире | className="max-w-xl" |
| Все кнопки должны стать фирменного цвета | токен --primary |
| Одна кнопка на лендинге — особенная | вариант brand в компоненте |
| Карточки должны быть менее скруглёнными | --radius |
| Разовый акцент в одном экране | className с обычными утилитами |
Правило: если изменение касается всего продукта — это тема.
Если одного места — это className. Промежуточный
случай — вариант компонента.
Контраст в обеих темах
Собственные токены нужно проверять дважды: то, что читается на белом, может «сгореть» на тёмном. Быстрый способ — DevTools: выбрать текст и посмотреть значение контраста в палитре цвета. Ориентир: 4.5:1 для обычного текста, 3:1 для крупного.
--muted-foreground) и с цветными состояниями
(--destructive на тёмном фоне). Проверяйте именно их.