Урок 19. Теория: пары токенов и @theme inline

📁 Раздел: shadcn/ui ⏱️ Время изучения: ~50 мин 🎯 Сложность: Продвинутая

⚡ Кратко

Каждая поверхность в теме идёт в паре с цветом текста на ней. Компонент использует bg-card text-card-foreground и потому остаётся читаемым в любой теме. Ваша работа при брендировании — задать значения переменных, а не переписывать классы.

Принцип пары

Ключевая идея темы: поверхность и текст на ней объявляются вместе. Токен без суффикса — фон, токен с -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базовый радиус, из него считаются остальные
background / foreground
card / card-foreground
primary / primary-foreground
secondary / secondary-foreground
muted / muted-foreground
accent / accent-foreground
destructive
ring
<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 — мягкий и дружелюбный. Меняется в одной строке.

radius: 0
radius: 0.625rem
radius: 1rem
полный
<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):

border-brand text-brand
<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 на тёмном фоне). Проверяйте именно их.