Тёмная тема перестала быть фичей «для гиков» — половина пользователей ноутбуков и почти все мобильные пользователи держат системную тему в dark. Если сайт умеет только light, это больно глазам вечером и выглядит несовременно. Но «просто инвертировать цвета» нельзя: тёмная тема — это отдельный дизайн со своими правилами контраста, своей палитрой и своими ловушками.
В этом материале разберём, как мы делаем тёмную тему в проектах на Next.js 15 + Tailwind: от токенов до борьбы с миганием на первой загрузке.
Зачем вообще делать тёмную тему
Несколько причин, помимо «модно»:
- Снижение нагрузки на глаза в условиях слабой освещённости.
- Экономия батареи на OLED-дисплеях (чёрные пиксели не светятся).
- Доступность: люди со светобоязнью и мигренями физически не могут долго смотреть в белый экран.
- Восприятие сайта как «продуманного» — пользователь видит, что вы учли его системные настройки.
Если у вас контентный сайт с длинным чтением (блог, документация, личный кабинет, дашборд) — тёмная тема обязательна. Лендинг на 1 экран можно оставить только в light.
Дизайн-токены: основа всего
Первое правило: никаких хардкод-цветов в компонентах. Только токены.
:root {
--bg: #ffffff;
--bg-subtle: #f7f7f9;
--bg-card: #ffffff;
--fg: #0e1116;
--fg-muted: #5a6473;
--border: #e5e7eb;
--primary: #4f46e5;
--primary-fg: #ffffff;
--danger: #dc2626;
}
[data-theme="dark"] {
--bg: #0b0d12;
--bg-subtle: #11141b;
--bg-card: #161a23;
--fg: #e8ebf0;
--fg-muted: #9aa3b2;
--border: #232834;
--primary: #818cf8;
--primary-fg: #0b0d12;
--danger: #f87171;
}
Tailwind подключает их через theme.extend.colors:
colors: {
bg: "var(--bg)",
"bg-subtle": "var(--bg-subtle)",
"bg-card": "var(--bg-card)",
fg: "var(--fg)",
"fg-muted": "var(--fg-muted)",
border: "var(--border)",
primary: "var(--primary)",
}
Теперь любой класс bg-bg, text-fg, border-border автоматически меняется при переключении темы. Никаких dark:bg-gray-900 по всему проекту.
Переключатель темы без мигания
Главная боль тёмной темы в SSR — FOUC (Flash of Unstyled Content). Сервер не знает о выборе пользователя, рендерит light, потом JS на клиенте меняет на dark — пользователь видит мигание.
Решение: инлайн-скрипт в <head>, до загрузки React.
// app/layout.tsx
export default function RootLayout({ children }) {
return (
<html lang="ru" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
try {
var t = localStorage.getItem('theme');
if (!t) {
t = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
document.documentElement.dataset.theme = t;
} catch (e) {}
})();
`,
}}
/>
</head>
<body>{children}</body>
</html>
);
}
Скрипт синхронный, выполняется до первой отрисовки. suppressHydrationWarning нужен, чтобы React не ругался на расхождение разметки.
next-themes как готовое решение
Если не хочется писать руками, есть библиотека next-themes:
npm i next-themes
// app/providers.tsx
"use client";
import { ThemeProvider } from "next-themes";
export function Providers({ children }) {
return (
<ThemeProvider attribute="data-theme" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>
);
}
Внутри она делает то же самое — инлайн-скрипт в head плюс хук useTheme() с состоянием.
"use client";
import { useTheme } from "next-themes";
import { Sun, Moon } from "lucide-react";
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<button onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>
{theme === "dark" ? <Sun /> : <Moon />}
</button>
);
}
Три состояния, а не два
Не делайте просто «light/dark». Делайте light / dark / system. Пользователь, который держит macOS в auto (день/ночь), хочет того же от вашего сайта. Если у вас только переключатель «на/выкл», вы его обманываете.
| Состояние | Что хранится в localStorage | Что показывается |
|---|---|---|
| system (по умолчанию) | ничего или "system" | результат prefers-color-scheme |
| light (пользователь явно выбрал) | "light" | всегда light |
| dark (пользователь явно выбрал) | "dark" | всегда dark |
UI-переключатель обычно делают на три кнопки или сегментированный контрол.
Контраст и WCAG
В dark-теме контраст легко промахнуть. Чистый #000 фон + чистый #fff текст — слишком резко, глаза устают за 10 минут. Обычно берут:
- Фон:
#0b0d12или#11141b(не чёрный, тёмно-синий или тёмно-серый). - Текст:
#e8ebf0или#dde1e8(не чистый белый). - Приглушённый текст:
#9aa3b2.
Контраст между основным текстом и фоном должен быть ≥ 4.5:1 (WCAG AA), для крупного — ≥ 3:1. Проверять можно в DevTools (вкладка Lighthouse или панель Accessibility).
Брендовый цвет в тёмной теме
Тут многие промахиваются. Брендовый цвет, который шикарен на белом, на чёрном выглядит грязным или режет глаза.
Пример: #4f46e5 (indigo-600) — отлично на белом, но на тёмном фоне читается тускло. В dark-теме его обычно сдвигают на 1-2 шага светлее, до #818cf8 (indigo-400). И наоборот — для текста на цветной кнопке в dark часто берут не белый, а наоборот тёмный фоновый цвет, чтобы кнопка не «прожигала» экран.
Изображения и иллюстрации
Фотографии не трогаем. SVG-иконки делаем с currentColor:
<svg fill="currentColor" /* ... */ />
Тогда они автоматически перекрашиваются под текущий text-fg.
Для скриншотов и иллюстраций, у которых внутри белый фон, делают две версии — light.png и dark.png:
import { useTheme } from "next-themes";
export function ScreenshotPair({ light, dark, alt }) {
const { resolvedTheme } = useTheme();
return <Image src={resolvedTheme === "dark" ? dark : light} alt={alt} />;
}
Или через CSS-трюк:
<picture>
<source srcset="dashboard-dark.png" media="(prefers-color-scheme: dark)" />
<img src="dashboard-light.png" alt="Дашборд" />
</picture>
Графики и charting-библиотеки
Recharts, Chart.js, ECharts — все они принимают цвета сетки, осей, тултипов. Прокидывайте туда токены, а не хардкод.
const styles = getComputedStyle(document.documentElement);
const fg = styles.getPropertyValue("--fg").trim();
const muted = styles.getPropertyValue("--fg-muted").trim();
<Chart options={{ axisColor: muted, gridColor: "var(--border)", labelColor: fg }} />
При смене темы ререндер должен подхватить новые значения. В Recharts это автоматически, в Chart.js — иногда приходится дёргать .update().
Theme color для адресной строки
theme-color — это цвет шапки браузера на мобильных. Должен меняться вместе с темой:
export const metadata = {
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
{ media: "(prefers-color-scheme: dark)", color: "#0b0d12" },
],
};
В Next.js 15 это в viewport, не в metadata:
export const viewport: Viewport = {
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
{ media: "(prefers-color-scheme: dark)", color: "#0b0d12" },
],
};
Тестирование
Перед релизом:
- Пройдите все ключевые страницы в обеих темах.
- Проверьте формы — placeholder, disabled, focus-ring.
- Графики, таблицы, карты — везде ли видно сетку.
- Тосты, модалки, поповеры — фон, тень, бордер.
- Скриншоты — не выглядят ли как «вырезанная белая дыра» в dark.
- Lighthouse Accessibility ≥ 95 в обеих темах.
Итого
Тёмная тема — это +20% к восприятию качества и -1% к жалобам на «режет глаза». Сделать правильно — день работы по токенам и переключателю плюс ещё день по картинкам и графикам. Сделать неправильно — больно всем: пользователям, которые увидят мигание, и вам, когда придётся всё переделывать.
Частые вопросы
Сколько стоит добавить тёмную тему в готовый проект?
Если изначально проект построен на токенах — 1-2 дня работы фронтендера. Если по проекту разбросаны хардкод-цвета #fff, gray-900, text-black — придётся сначала проводить рефакторинг, и это уже неделя-две, потому что трогать нужно каждый компонент. Поэтому проще сразу закладывать токены, даже если тёмная тема в первой версии не нужна — потом не придётся всё ломать.
prefers-color-scheme или ручной переключатель?
Оба. По умолчанию — system (то есть prefers-color-scheme), плюс ручной переключатель в шапке или настройках. Так покрываются и те, кто не знает про системную настройку, и те, кто хочет на конкретном сайте по-другому. Просто prefers-color-scheme без переключателя — пользователь не сможет выбрать light на тёмной системе.
Что делать с миганием на первой загрузке?
Только инлайн-скрипт в <head>, который синхронно ставит data-theme на <html> до первой отрисовки. Любой асинхронный подход (React effect, отложенная установка) даёт мигание на 100-300 мс. next-themes это делает из коробки — не изобретайте свой велосипед.
Нужно ли две версии логотипа?
Если логотип цветной — обычно нет, он работает и на светлом, и на тёмном фоне. Если логотип чёрный или тёмный — да, нужна светлая версия для тёмной темы. Можно держать SVG с currentColor и менять цвет токеном, но это работает только для одноцветных логотипов.
А SEO не страдает от двух тем?
Нет, поисковики видят одну версию HTML и не отличают тёмную от светлой. CSS-переменные не влияют на текстовый контент. Влияет на SEO только то, насколько контент доступен и читаем — а тут две темы скорее плюс, чем минус.
Стоит ли давать выбирать акцентный цвет, не только тему?
Только в нишевых продуктах (дизайнерские инструменты, кастомизируемые дашборды) и только если есть запрос. Для маркетингового сайта или массового SaaS — нет, это разваливает бренд и раздувает QA. Тёмная тема — да, цветовая палитра — нет.
Как тёмная тема сочетается с email-рассылками?
Никак, это отдельный мир. Письма верстаются с inline-стилями, и тёмные клиенты (Gmail dark, Apple Mail dark) сами инвертируют фон по своим правилам. Стандарт @media (prefers-color-scheme: dark) поддерживается частично, и универсального рецепта нет — обычно делают одну версию письма и тестируют, как она выглядит в dark-режиме популярных клиентов.