Три способа включить тёмную тему
| Способ | Как | Когда |
|---|---|---|
| По системе | ничего не настраивать — 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>