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

Самая частая жалоба на тёмную тему — не то, что её нет, а то, что она сделана криво: страница грузится белой, потом моргает и становится тёмной. Или тема сбрасывается при переходе на другую страницу. Или где-то остался белый блок, потому что цвет захардкожен прямо в стилях компонента. Всё это — следствие трёх решений, принятых в самом начале. Разберём, как их принять так, чтобы потом не переделывать.
Статья про фронтенд-реализацию на CSS-переменных и небольшом скрипте. Серверный рендеринг предпочтений и синхронизация между устройствами — отдельная тема, здесь её не трогаем.
Три подхода к переключению темы
Прежде чем писать код, надо выбрать, откуда сайт узнаёт, какую тему показать. Вариантов три, и они не взаимоисключающие — правильная реализация комбинирует их.
Только системное предпочтение
Медиазапрос prefers-color-scheme сообщает, что пользователь выбрал в настройках ОС. Самый дешёвый вариант: ноль JavaScript, ноль хранилища.
:root {
--bg: #ffffff;
--text: #1a1a1a;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #121212;
--text: #e6e6e6;
}
}
body {
background: var(--bg);
color: var(--text);
}
Минус один, но существенный: пользователь не может переключить тему только на вашем сайте. Если у него светлая система, но он хочет читать ваш блог в тёмном — он не сможет.
Только ручной переключатель
Кнопка на странице, выбор пишется в localStorage. Пользователь управляет темой явно. Минус: при первом заходе вы игнорируете системную настройку и показываете, скажем, светлую по умолчанию, хотя у человека тёмная ОС.
Комбинированный: система по умолчанию, переключатель перекрывает
Рабочий вариант для большинства сайтов. При первом заходе тема берётся из системы. Как только пользователь нажал переключатель — его выбор сохраняется и имеет приоритет. Именно его и реализуем ниже.
| Подход | JS нужен | Учитывает систему | Ручной выбор |
|---|---|---|---|
Только prefers-color-scheme | Нет | Да | Нет |
| Только переключатель | Да | Нет (или вручную) | Да |
| Комбинированный | Да | Да | Да |
Цвета через переменные, а не хардкод
Главная ошибка — раскидать color: #333 по всем компонентам. Тогда тёмная тема превращается в охоту за каждым таким местом. Заведите семантические CSS-переменные один раз и обращайтесь только к ним.
Ключевое слово — семантические. Переменная называется по роли (--surface, --text-muted, --border), а не по значению (--gray-200). Тогда при смене темы вы меняете, что означает роль, а не переименовываете переменные.
:root {
--bg: #ffffff;
--surface: #f5f5f5;
--text: #1a1a1a;
--text-muted: #666666;
--border: #e0e0e0;
--accent: #2563eb;
}
[data-theme="dark"] {
--bg: #121212;
--surface: #1e1e1e;
--text: #e6e6e6;
--text-muted: #9a9a9a;
--border: #333333;
--accent: #60a5fa;
}
Обратите внимание: акцентный цвет в тёмной теме светлее, чем в светлой. Насыщенный синий #2563eb на чёрном фоне выглядит грязно и хуже читается — на тёмном фоне цвета стоит немного осветлить и снизить насыщенность.
Убираем мигание при загрузке
Мигание белым — самая заметная проблема. Причина: скрипт, который ставит data-theme="dark", выполняется после того, как браузер уже отрисовал страницу со светлыми стилями. Между отрисовкой и переключением проходит кадр — вы видите вспышку.
Лечение одно: прочитать сохранённую тему и проставить атрибут до отрисовки. Это значит — инлайновый скрипт в <head>, до подключения стилей и до <body>. Не внешний файл (его загрузка асинхронна и добавит задержку), а прямо в разметке.
<head>
<script>
// Выполняется синхронно до отрисовки body — миганию неоткуда взяться
(function () {
var saved = localStorage.getItem('theme');
if (saved === 'dark' || saved === 'light') {
document.documentElement.setAttribute('data-theme', saved);
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.setAttribute('data-theme', 'dark');
}
})();
</script>
<link rel="stylesheet" href="/styles.css">
</head>
Любой скрипт управления темой, загружаемый с атрибутом defer или async, гарантированно даст мигание. Антимигальный код обязан быть синхронным и стоять раньше стилей — это единственное место, где инлайновый скрипт в head оправдан.
Переключатель и запоминание выбора
Теперь кнопка. Она читает текущее состояние, инвертирует его, применяет и сохраняет.
// Вызывается по клику на кнопку переключения
function toggleTheme() {
var root = document.documentElement;
var current = root.getAttribute('data-theme');
var next = current === 'dark' ? 'light' : 'dark';
root.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
}
document
.querySelector('#theme-toggle')
.addEventListener('click', toggleTheme);
Есть нюанс с системной темой. Если пользователь не нажимал кнопку, а сменил тему в настройках ОС на лету — сайт должен отреагировать. Для этого слушаем изменение медиазапроса, но только пока в хранилище нет ручного выбора.
var mq = window.matchMedia('(prefers-color-scheme: dark)');
mq.addEventListener('change', function (event) {
// Ручной выбор перекрывает систему — тогда игнорируем
if (localStorage.getItem('theme')) return;
document.documentElement.setAttribute(
'data-theme',
event.matches ? 'dark' : 'light'
);
});
Что часто ломают в деталях
- Картинки и иконки. Скриншоты со светлым фоном на тёмной теме режут глаз. Для SVG-иконок используйте
currentColor, чтобы они наследовали цвет текста. Для растровых иллюстраций держите две версии и переключайте через<picture>с медиазапросом, либо приглушайте фильтром. - Тени. На тёмном фоне
box-shadowс чёрным цветом невидим. Тени в тёмной теме почти не работают — глубину лучше показывать через разницу яркости--bgи--surface. - Прозрачности поверх фона. Полупрозрачный чёрный оверлей на тёмном фоне сливается. Задавайте отдельные значения для каждой темы, а не один
rgba(0,0,0,0.5)на всё. - Скроллбары и системный UI. Добавьте
<meta name="color-scheme">или CSS-свойствоcolor-scheme, чтобы браузер перекрасил нативные элементы — скроллбары, поля форм, автозаполнение.
:root { color-scheme: light; }
[data-theme="dark"] { color-scheme: dark; }
Плавный переход без тормозов
Анимировать переход между темами хочется, но transition: all на body заставит браузер пересчитывать всё дерево и будет тормозить. Ограничьте переход конкретными свойствами и уважайте настройку уменьшенного движения.
body {
transition: background-color 0.2s ease, color 0.2s ease;
}
@media (prefers-reduced-motion: reduce) {
body { transition: none; }
}
Что дальше
Базовая тёмная тема готова: она не мигает, помнит выбор и уважает систему. Дальше стоит посмотреть на две вещи. Первое — контраст: прогоните ключевые пары «текст на фоне» через проверку на соответствие WCAG (минимум 4.5:1 для основного текста), потому что светлый серый на тёмном часто не дотягивает. Второе — если у вас несколько устройств одного пользователя и есть авторизация, подумайте о сохранении выбора на сервере, чтобы тема не разъезжалась между браузерами. Для контроля в проде замерьте, не растёт ли Cumulative Layout Shift после добавления темы — если растёт, антимигальный скрипт стоит не там, где надо.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: prefers-color-scheme — CSS media feature, MDN, color-scheme — CSS property, MDN
Частые вопросы
Почему тема всё равно мигает, хотя я всё сделал по инструкции?
Работает ли prefers-color-scheme во всех браузерах?
Стоит ли использовать класс .dark на body вместо атрибута data-theme?
Не сработало: тема сбрасывается при переходе между страницами. В чём дело?
Как быть с тёмной темой в CMS вроде WordPress, где у меня нет доступа к head напрямую?
Нужно ли снижать яркость белого текста в тёмной теме?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.