Уроки

Реализация тёмной темы: полное руководство для разработчиков

7 мин чтения

Тёмная тема переместилась из нишевой опции в ожидаемую функциональность. Пользователи на всех платформах — macOS, Windows, Android, iOS — могут установить тёмный вид на уровне системы, и они ожидают, что сайты и приложения будут его учитывать. Правильная реализация тёмной темы требует большего, чем замена белого на чёрный: нужен системный подход к цвету, контрасту и управлению настройками. Это руководство охватывает весь процесс — от архитектуры CSS через механику JavaScript-переключателя до тщательного тестирования обеих тем.

CSS-переменные для тем

Наиболее поддерживаемый способ управления тёмной темой в CSS — через пользовательские свойства (CSS-переменные). Вместо рассеивания значений цветов по всем таблицам стилей вы определяете каждый цвет как переменную на :root, а затем переопределяете эти переменные для тёмного режима. Стили компонентов ссылаются только на переменные, но никогда на сырые hex-коды.

Определение светлой и тёмной палитр

Начните со светлой палитры как базовой по умолчанию. Чистая отправная точка может выглядеть так:

:root {
  /* Фоны */
  --color-bg-base:      #FFFFFF;
  --color-bg-elevated:  #F8F9FA;
  --color-bg-overlay:   #F1F3F5;

  /* Текст */
  --color-text-primary:   #1A1A2E;
  --color-text-secondary: #4A4A6A;
  --color-text-muted:     #6C757D;

  /* Рамки */
  --color-border:         #DEE2E6;
  --color-border-strong:  #ADB5BD;

  /* Бренд / акцент */
  --color-accent:         #3B82F6;
  --color-accent-hover:   #2563EB;

  /* Обратная связь */
  --color-success:  #22C55E;
  --color-warning:  #F59E0B;
  --color-danger:   #EF4444;
}

Затем определите переопределения для тёмного режима в отдельном блоке. Ключевая идея: вы не просто инвертируете цвета — вы выбираете специально созданную палитру для тёмной поверхности:

[data-theme="dark"] {
  /* Фоны */
  --color-bg-base:      #0F0F17;
  --color-bg-elevated:  #1A1A2E;
  --color-bg-overlay:   #252540;

  /* Текст */
  --color-text-primary:   #E8E8F0;
  --color-text-secondary: #A8A8C0;
  --color-text-muted:     #6A6A88;

  /* Рамки */
  --color-border:         #2E2E4A;
  --color-border-strong:  #4A4A6A;

  /* Бренд / акцент — часто немного светлее для читаемости на тёмном фоне */
  --color-accent:         #60A5FA;
  --color-accent-hover:   #93C5FD;

  /* Обратная связь — немного ненасыщеннее во избежание жёсткости */
  --color-success:  #4ADE80;
  --color-warning:  #FCD34D;
  --color-danger:   #F87171;
}

Обратите внимание: акцент #3B82F6 в светлой теме становится #60A5FA в тёмной. Оттенок тот же, но светлота увеличивается — это необходимо, поскольку контекст контраста изменился на противоположный. Цвет, проходящий WCAG AA на белом фоне, почти всегда провалит тест на почти чёрном, если вы его не скорректируете. Генератор оттенков позволяет исследовать полный диапазон 50–950 любого цвета, упрощая выбор подходящего оттенка для каждой темы.

Использование переменных в компонентах

Когда палитра определена, каждый компонент ссылается на переменные, а не на сырые значения:

.card {
  background-color: var(--color-bg-elevated);
  border: 1px solid var(--color-border);
  color: var(--color-text-primary);
}

.btn-primary {
  background-color: var(--color-accent);
  color: #FFFFFF;
}

.btn-primary:hover {
  background-color: var(--color-accent-hover);
}

Когда атрибут [data-theme="dark"] присутствует на элементе <html>, все переменные обновляются одновременно, и каждый компонент, ссылающийся на них, меняет внешний вид — дополнительный CSS не требуется.

Медиазапрос prefers-color-scheme

Ещё до того, как пользователь взаимодействует с переключателем, вы можете учесть его системные настройки с помощью медиазапроса prefers-color-scheme. Он срабатывает, когда в ОС установлен тёмный вид.

@media (prefers-color-scheme: dark) {
  :root {
    --color-bg-base:      #0F0F17;
    --color-bg-elevated:  #1A1A2E;
    --color-bg-overlay:   #252540;
    --color-text-primary:   #E8E8F0;
    --color-text-secondary: #A8A8C0;
    --color-text-muted:     #6A6A88;
    --color-border:         #2E2E4A;
    --color-border-strong:  #4A4A6A;
    --color-accent:         #60A5FA;
    --color-accent-hover:   #93C5FD;
    --color-success:  #4ADE80;
    --color-warning:  #FCD34D;
    --color-danger:   #F87171;
  }
}

Этот подход работает без JavaScript, не вызывает сдвигов макета и немедленно учитывает заявленные предпочтения пользователя при загрузке страницы. Это правильный базовый уровень. Его ограничение — пользователи не могут переопределить настройку в вашем приложении: если ОС тёмная, сайт тёмный, без возможности изменить. Именно поэтому большинство продакшн-реализаций добавляют поверх медиазапроса JavaScript-переключатель.

Сочетание обоих подходов

Рекомендуемый паттерн использует медиазапрос как значение по умолчанию, а атрибут data-theme — как явное переопределение. Это можно реализовать через трюк со специфичностью CSS или правильным порядком правил:

/* 1. Светлый режим по умолчанию */
:root {
  --color-bg-base: #FFFFFF;
  /* ... */
}

/* 2. Переопределение тёмным режимом ОС (когда нет явных предпочтений) */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --color-bg-base: #0F0F17;
    /* ... */
  }
}

/* 3. Явный тёмный режим (переключён пользователем через JS) */
[data-theme="dark"] {
  --color-bg-base: #0F0F17;
  /* ... */
}

Селектор :not([data-theme="light"]) в медиазапросе означает, что тёмные предпочтения ОС применяются только тогда, когда пользователь явно не выбрал светлый режим. После переключения его явный выбор побеждает.

Механизм переключателя на JavaScript

Хорошо реализованный переключатель делает три вещи: немедленно изменяет текущий вид, сохраняет предпочтение в localStorage и читает сохранённое предпочтение при загрузке страницы до первого рендера.

Чтение предпочтения при загрузке

Этот скрипт должен выполняться в <head> — до рендера страницы — чтобы предотвратить мигание неправильной темы:

<head>
  <script>
    (function() {
      const stored = localStorage.getItem('theme');
      const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
      const theme = stored ?? (prefersDark ? 'dark' : 'light');
      document.documentElement.setAttribute('data-theme', theme);
    })();
  </script>
</head>

Это немедленно устанавливает data-theme на <html> до применения стилей. Браузер вычисляет правильные значения переменных с первого рендера — без мигания.

Функция переключения

function toggleTheme() {
  const current = document.documentElement.getAttribute('data-theme');
  const next = current === 'dark' ? 'light' : 'dark';
  document.documentElement.setAttribute('data-theme', next);
  localStorage.setItem('theme', next);
}

// Привязка к кнопке
document.getElementById('theme-toggle').addEventListener('click', toggleTheme);

Синхронизация состояния кнопки

Кнопка переключения должна визуально отражать текущий режим. Простой подход использует иконки:

<button id="theme-toggle" aria-label="Toggle dark mode">
  <span class="icon-light">☀️</span>
  <span class="icon-dark">🌙</span>
</button>
[data-theme="dark"] .icon-light { display: none; }
[data-theme="dark"] .icon-dark  { display: inline; }
[data-theme="light"] .icon-light { display: inline; }
[data-theme="light"] .icon-dark  { display: none; }

Поскольку видимость иконок управляется CSS-переменными, привязанными к data-theme, состояние кнопки обновляется автоматически при каждом изменении атрибута — без дополнительного JavaScript.

Стратегии адаптации цветов

Выбор цветов тёмного режима — это не просто инверсия светлой палитры. Несколько принципов помогают сделать правильный выбор.

Снижать контраст, а не переворачивать его

Белый текст на чёрном фоне (#FFFFFF на #000000) технически даёт максимальный контраст — 21:1, — но когнитивно утомителен при длительном чтении. Снижайте оба экстремума: используйте почти белый вроде #E8E8F0 для основного текста и очень тёмно-синий вроде #0F0F17 для фона страницы. Это сохраняет достаточный контраст (по-прежнему выше 15:1), снижая при этом зрительную усталость.

Используйте Проверщик контраста, чтобы убедиться, что каждая комбинация текст/фон в тёмной теме соответствует как минимум WCAG AA (4,5:1 для обычного текста, 3:1 для крупного). Типичные точки отказа:

  • текст-заполнитель в полях формы;
  • метки отключённых кнопок;
  • вторичный текст метаданных (временные метки, авторы);
  • кнопки только с иконками без видимых меток.

Многоуровневая глубина с тёмными поверхностями

В светлой теме глубина обычно выражается тенями. В тёмной теме тени становятся невидимыми на тёмном фоне. Спецификация Material Design 3 предлагает более эффективный подход: более светлые поверхности воспринимаются как более высокие. Используйте немного более светлые фоны для поднятых компонентов:

/* Шкала глубины в тёмном режиме */
--color-bg-base:     #0F0F17;  /* Фон страницы */
--color-bg-elevated: #1A1A2E;  /* Карточки, боковые панели */
--color-bg-overlay:  #252540;  /* Модальные окна, выпадающие меню */
--color-bg-tooltip:  #2E2E4A;  /* Подсказки */

#0F0F17 в качестве базы, #1A1A2E для карточек, #252540 для модальных окон — каждый шаг примерно на 8–10% светлее по шкале HSL. Это создаёт чёткую визуальную иерархию без использования теней.

Немного ненасыщивайте цвета тёмного режима

Высоконасыщенные цвета выглядят жёстко и неоново на тёмном фоне. При адаптации цветов бренда для тёмного режима снижайте насыщенность на 10–20% вместе с повышением светлоты. Вместо яркого зелёного успеха #22C55E предпочтите #4ADE80 — светлее и немного менее насыщенный, что читается как успех без напряжения глаз.

Генератор оттенков идеально подходит для этого: введите основной зелёный или синий цвет бренда и исследуйте диапазон 300–400 для использования в тексте и иконках тёмной темы, а диапазон 500–600 — для интерактивных элементов.

Изображения и медиа

Изображения с белым фоном выглядят резко в тёмном режиме. CSS может помочь:

/* Снижение резкости изображений в тёмном режиме */
[data-theme="dark"] img:not([src*=".svg"]) {
  filter: brightness(0.9) contrast(1.05);
}

/* Или позвольте изображениям слегка смешаться с фоном */
[data-theme="dark"] img {
  mix-blend-mode: luminosity;
  opacity: 0.9;
}

Для SVG-иконок, которым нужна адаптация, использование currentColor в качестве значения заливки означает, что они автоматически принимают текущий цвет текста:

.icon { color: var(--color-text-secondary); }
<svg fill="currentColor" viewBox="0 0 24 24">...</svg>

Тестирование обоих режимов

Тщательное тестирование предотвращает попадание регрессий тёмного режима в продакшн.

Эмуляция в DevTools браузера

Chrome и Firefox предлагают эмуляцию тёмного режима в DevTools без изменения системных настроек. В Chrome: откройте DevTools, нажмите три точки, перейдите в More Tools → Rendering и установите "Emulate CSS media feature prefers-color-scheme" в значение "dark". Это позволяет сравнивать оба режима бок о бок.

Автоматическое тестирование контраста

Ручная точечная проверка ненадёжна. Интегрируйте автоматизированный аудит контрастности в рабочий процесс разработки. Используйте инструменты вроде Axe или Lighthouse в CI для обнаружения новых цветовых добавлений, не соответствующих порогам WCAG. Проверщик контраста позволяет быстро проверить пару передний план/фон по всем уровням WCAG — просто вставьте любую пару hex-кодов и мгновенно получите соотношение.

Тестирование с реальным контентом

Баги тёмного режима часто проявляются на страницах с динамическим контентом: загруженными пользователями изображениями, встроенными сторонними виджетами, графиками и картами. Тестируйте на реалистичной выборке контента, а не только в компонентной библиотеке дизайн-системы в изоляции.

Тестирование на уровне ОС

После проверки через эмуляцию DevTools тестируйте с ОС, действительно установленной в тёмный режим. Медиазапрос prefers-color-scheme срабатывает на основе системных настроек, и некоторые браузеры ведут себя немного иначе в зависимости от того, реальная ли это настройка или эмулированная. Также тестируйте переход: переключайтесь между режимами при открытой странице и убеждайтесь, что не возникает сдвигов макета или артефактов рендеринга.

Контрольный список типичных проблем

  • Жёстко заданные hex-значения в CSS компонентов вместо переменных — найдите в таблицах стилей сырые hex-коды и замените переменными
  • SVG-иконки с жёстко заданным fill="#000000" — измените на fill="currentColor"
  • Сторонние компоненты, не уважающие data-theme — оберните их в скопированный CSS-слой
  • Свойство color-scheme не установлено — добавьте color-scheme: light dark в :root, чтобы хром браузера (полосы прокрутки, элементы форм) тоже адаптировался
  • Отсутствует <meta name="color-scheme"> в <head> — добавьте, чтобы браузер мог применить правильный цвет фона до загрузки CSS
<meta name="color-scheme" content="light dark">
:root {
  color-scheme: light dark;
}

Это небольшое дополнение заставляет нативные полосы прокрутки, выборщики дат и другие элементы формы, рендеримые ОС, автоматически переключаться в тёмные варианты — деталь, которую многие реализации упускают.

Ключевые выводы

  • Определите все цвета как CSS-переменные на :root и переопределяйте их для тёмного режима через [data-theme="dark"]. Стили компонентов ссылаются только на переменные, что делает переключение темы нулевым усилием после установки палитры.
  • Используйте prefers-color-scheme: dark как автоматическое значение по умолчанию для пользователей, установивших ОС в тёмный вид. Добавьте JavaScript-переключатель с сохранением в localStorage для пользователей, желающих переопределить.
  • Запускайте анти-мигательный скрипт в <head> до загрузки CSS, чтобы предотвратить мигание неправильной темы при первом рендере.
  • Цвета тёмного режима — не инвертированные цвета светлого: снижайте экстремальный контраст, используйте более светлые фоны для передачи глубины и немного ненасыщивайте акценты бренда во избежание неоновой жёсткости.
  • Проверяйте каждую пару текст/фон с помощью Проверщика контраста и используйте Генератор оттенков для поиска правильного оттенка каждого цвета бренда для обеих тем.
  • Добавьте color-scheme: light dark и соответствующий тег <meta>, чтобы нативные UI-элементы браузера (полосы прокрутки, поля ввода) тоже переключались автоматически.

Похожие цвета

Похожие бренды

Похожие инструменты