Legan Studio
Все статьи
~ 6 мин чтения

Тёмная тема на сайте: как сделать правильно

Разбираем тёмную тему в Next.js 15: токены, переключение без мигания, prefers-color-scheme, контраст по WCAG, тонкости с изображениями и графиками.

  • веб
  • дизайн
  • ux

Тёмная тема перестала быть фичей «для гиков» — половина пользователей ноутбуков и почти все мобильные пользователи держат системную тему в 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" },
  ],
};

Тестирование

Перед релизом:

  1. Пройдите все ключевые страницы в обеих темах.
  2. Проверьте формы — placeholder, disabled, focus-ring.
  3. Графики, таблицы, карты — везде ли видно сетку.
  4. Тосты, модалки, поповеры — фон, тень, бордер.
  5. Скриншоты — не выглядят ли как «вырезанная белая дыра» в dark.
  6. 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-режиме популярных клиентов.