Урок 13. Теория: dark:, токены, переключение темы

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

⚡ Кратко

Вариант dark: — обычный вариант, только условие у него — тёмная тема. Дублировать его на каждом цвете утомительно, поэтому в проектах вводят семантические токены: bg-surface, text-foreground. Тогда переключение темы — это смена значений переменных, а разметка не меняется вообще.

Три способа включить тёмную тему

СпособКакКогда
По системеничего не настраивать — dark: уже слушает prefers-color-schemeсайт-визитка, лендинг
По классу@custom-variant dark (&:where(.dark, .dark *)); и класс на <html>приложение с переключателем
По атрибутувариант через [data-theme="dark"]если тема хранится в data-атрибуте
/* src/index.css */
@import "tailwindcss";

/* тёмная тема включается классом .dark на <html>, а не только настройкой системы */
@custom-variant dark (&:where(.dark, .dark *));

Именно так настроены демонстрации этого курса: переключатель темы сайта вешает класс dark на корневой элемент, и все примеры реагируют.

Тёмная тема — не «фотонегатив», а другое освещение в комнате. Ночью вы не переворачиваете мебель — вы включаете другой свет: меняются контрасты, тени становятся бесполезны, а яркие поверхности бьют по глазам. Поэтому в тёмной теме мы меняем роли цветов, а не инвертируем значения.

Прямой способ: dark: у каждого класса

Быстро и наглядно — годится, пока цветов немного:

Карточка

Каждый цвет продублирован приставкой dark:.

<div class="max-w-sm rounded-xl border border-slate-200 bg-white p-5 dark:border-slate-700 dark:bg-slate-900">
  <h3 class="text-base font-semibold text-slate-900 dark:text-slate-50">Карточка</h3>
  <p class="mt-1 text-sm text-slate-600 dark:text-slate-400">
    Каждый цвет продублирован приставкой dark:.
  </p>
  <button class="mt-4 rounded-md bg-slate-900 px-3 py-2 text-sm font-medium text-white
                 hover:bg-slate-700 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-white">
    Действие
  </button>
</div>

Проблема видна сразу: строка классов удваивается, и каждый новый компонент нужно проверять дважды. На третьем десятке компонентов это перестаёт работать.

Правильный способ: семантические токены

Вместо «серый 900» договариваемся о ролях: поверхность, текст на поверхности, приглушённый текст, граница, акцент. Значения ролей меняются вместе с темой, а классы — нет.

/* src/index.css */
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));

@theme inline {
  --color-surface: var(--surface);
  --color-surface-foreground: var(--surface-foreground);
  --color-brand: var(--brand);
}

:root {
  --surface: oklch(1 0 0);
  --surface-foreground: oklch(0.21 0.01 265);
  --brand: oklch(0.55 0.21 264);
}

.dark {
  --surface: oklch(0.21 0.01 265);
  --surface-foreground: oklch(0.98 0 0);
  --brand: oklch(0.62 0.19 264);
}

После этого разметка перестаёт знать о теме:

Карточка на токенах

Ни одного класса dark: — цвета приходят из переменных темы.

<div class="max-w-sm rounded-xl border border-border bg-card p-5 text-card-foreground">
  <h3 class="text-base font-semibold">Карточка на токенах</h3>
  <p class="mt-1 text-sm text-muted-foreground">
    Ни одного класса dark: — цвета приходят из переменных темы.
  </p>
  <button class="mt-4 rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:opacity-90">
    Действие
  </button>
</div>

Переключите тему страницы — карточка изменится, хотя в её классах нет ни одного упоминания тёмного режима. Это и есть подход shadcn/ui, который мы подробно разберём в уроке 19.

Что меняется в тёмной теме кроме цвета

  • Тени не работают. Чёрная тень на тёмном фоне невидима. Вместо неё — чуть более светлый фон и тонкая граница dark:ring-1 dark:ring-white/10.
  • Границы становятся светлее фона. В светлой теме граница темнее фона, в тёмной — наоборот.
  • Насыщенные цвета «горят». Яркий синий на чёрном выглядит агрессивно: берите на ступень темнее или добавляйте прозрачность.
  • Изображения и иллюстрации. Белый фон картинки в тёмной теме превращается в светлое пятно — нужны варианты или приглушение.

Тень → рамка

В тёмной теме тень заменена кольцом.

Только тень

В темноте карточка «сливается» с фоном.

<div class="grid gap-4 sm:grid-cols-2">
  <div class="rounded-xl bg-white p-4 shadow-lg dark:bg-slate-800 dark:shadow-none dark:ring-1 dark:ring-white/10">
    <p class="text-sm font-medium text-slate-900 dark:text-slate-100">Тень → рамка</p>
    <p class="mt-1 text-xs text-slate-600 dark:text-slate-400">В тёмной теме тень заменена кольцом.</p>
  </div>
  <div class="rounded-xl bg-white p-4 shadow-lg dark:bg-slate-800">
    <p class="text-sm font-medium text-slate-900 dark:text-slate-100">Только тень</p>
    <p class="mt-1 text-xs text-slate-600 dark:text-slate-400">В темноте карточка «сливается» с фоном.</p>
  </div>
</div>

Переключатель темы

Логика простая: прочитать сохранённый выбор, поставить класс, запомнить. Важна одна деталь — сделать это до первой отрисовки, иначе страница мигнёт светлой темой.

// src/theme.ts — переключение темы и запоминание выбора
const KEY = 'theme'

export function applyTheme(theme: 'light' | 'dark' | 'system') {
  const isDark =
    theme === 'dark' ||
    (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)

  document.documentElement.classList.toggle('dark', isDark)
  localStorage.setItem(KEY, theme)
}

// вызывать как можно раньше, до первой отрисовки
applyTheme((localStorage.getItem(KEY) as 'light' | 'dark' | 'system') ?? 'system')
Чтобы не было мигания, скрипт с чтением localStorage вставляют inline в <head> — до подключения основного бандла. Это единственный надёжный способ: любое асинхронное решение сработает уже после первой отрисовки.

Три состояния, а не два

Хороший переключатель предлагает «светлая / тёмная / системная». Третий вариант — это не лень, а уважение к настройке пользователя: человек мог включить тёмную тему по расписанию в системе.

Контраст в темноте

Ошибка «серый текст на тёмно-сером фоне» встречается чаще, чем в светлой теме, потому что глаз в темноте менее чувствителен к разнице. Для тёмной темы берите текст ступени 100–300 на фоне 800–950 и обязательно проверяйте измерителем контраста в DevTools.

text-slate-500 на slate-900 — плохо

text-slate-300 — вторичный текст

text-slate-50 — основной текст

<div class="space-y-2 rounded-lg bg-slate-900 p-4">
  <p class="text-sm text-slate-500">text-slate-500 на slate-900 — плохо</p>
  <p class="text-sm text-slate-300">text-slate-300 — вторичный текст</p>
  <p class="text-sm text-slate-50">text-slate-50 — основной текст</p>
</div>