Тёмная тема в CSS и JavaScript: разбор частых ошибок
Мигание белого экрана при загрузке, потеря выбора пользователя, серый текст на сером фоне — разбираем, почему тёмная тема ломается и как это чинить.

Тёмную тему добавляют почти на каждый новый сайт, но реализуют её одинаково криво. Три симптома встречаются чаще всего: белая вспышка на долю секунды при загрузке страницы в тёмном режиме, сброс выбора темы при переходе между страницами и нечитаемый текст, потому что цвета инвертировали на глаз. Разберём каждую ошибку, покажу цену решения и рабочий код для актуальных браузеров (Chrome 120+, Firefox 120+, Safari 17). Речь о собственной реализации на CSS-переменных и JavaScript, без фреймворков и библиотек.
Ошибка 1. Белая вспышка при загрузке — FOUC для тёмной темы
Самая заметная проблема. Пользователь выбрал тёмную тему, но при каждой загрузке страницы на 100–300 мс вспыхивает белый фон, потом становится тёмным. Причина в порядке событий: браузер сначала рисует страницу со стилями по умолчанию, и только потом выполняется JavaScript, который читает выбор пользователя из localStorage и вешает класс на <html>.
Классический неправильный вариант — навесить обработчик на загрузку документа:
// Так делать нельзя: код сработает слишком поздно
document.addEventListener('DOMContentLoaded', () => {
const theme = localStorage.getItem('theme');
if (theme === 'dark') {
document.documentElement.classList.add('dark');
}
});
DOMContentLoaded наступает после того, как весь HTML разобран и отрисован. Вспышка гарантирована.
Правильный подход — инлайновый скрипт в <head>, до любого рендеринга. Он блокирует парсинг ровно на время выполнения нескольких строк и ставит класс до первой отрисовки:
<!-- В самом верху <head>, до подключения CSS и остального JS -->
<script>
(function () {
var saved = localStorage.getItem('theme');
var systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var isDark = saved === 'dark' || (saved === null && systemDark);
if (isDark) {
document.documentElement.classList.add('dark');
}
})();
</script>
Да, это инлайновый блокирующий скрипт, и линтеры на него ругаются. Но альтернативы без вспышки нет: класс должен появиться до того, как браузер начнёт рисовать. Проверка: откройте сайт в тёмном режиме и обновите страницу десять раз подряд — белого кадра быть не должно.
Тёмную тему нельзя включать после загрузки. Её нужно включать до неё — иначе пользователь всегда увидит долю секунды светлого фона, и никакой transition это не спрячет.
Ошибка 2. Игнор системной темы и её изменений
Многие сайты либо совсем не смотрят на системную настройку, либо считывают её один раз при загрузке и больше не реагируют. Пользователь переключил систему на тёмную (по расписанию заката, например), а открытая вкладка осталась светлой.
Логика приоритетов должна быть такой: явный выбор пользователя важнее системного. Если пользователь ничего не выбирал — следуем за системой и слушаем её изменения:
const media = window.matchMedia('(prefers-color-scheme: dark)');
media.addEventListener('change', (event) => {
// Реагируем на смену системной темы,
// только если пользователь не сделал явный выбор
const savedChoice = localStorage.getItem('theme');
if (savedChoice === null) {
document.documentElement.classList.toggle('dark', event.matches);
}
});
Старый метод media.addListener() устарел, но всё ещё работает в старых версиях Safari. В целевых браузерах используйте addEventListener('change', ...).
Три состояния вместо двух
Частая ошибка — переключатель на два положения: светло/темно. Тогда теряется вариант «как в системе». Правильнее хранить три значения:
'light'— пользователь явно выбрал светлую;'dark'— явно выбрал тёмную;null(ключ отсутствует) — следуем за системой.
Кнопка «Авто» должна удалять ключ из localStorage, а не писать туда строку 'auto', иначе придётся обрабатывать три случая вместо двух в инлайновом скрипте и раздувать его.
Ошибка 3. Инверсия цветов на глаз вместо палитры
Соблазн сделать тёмную тему через filter: invert(1) или просто заменить #fff на #000 и наоборот. Результат: чистый чёрный фон #000000 с чистым белым текстом #ffffff режет глаза сильнее, чем светлая тема. Контраст завышен, буквы «дрожат».
В тёмной теме фон не должен быть абсолютно чёрным, а текст — абсолютно белым. Material Design рекомендует фон около #121212, текст с пониженной непрозрачностью. Работайте через CSS-переменные и семантические имена, а не через конкретные цвета в каждом правиле:
:root {
--bg-primary: #ffffff;
--bg-surface: #f5f5f5;
--text-primary: #1a1a1a;
--text-secondary: #555555;
--border: #e0e0e0;
}
.dark {
--bg-primary: #121212;
--bg-surface: #1e1e1e;
--text-primary: rgba(255, 255, 255, 0.87);
--text-secondary: rgba(255, 255, 255, 0.60);
--border: #2c2c2c;
}
body {
background: var(--bg-primary);
color: var(--text-primary);
}
Смысл в том, что вы описываете назначение цвета (--text-secondary), а не его вид. Тогда переключение темы — это подмена значений переменных, а не переписывание всех правил. Проверьте итог инструментом контраста в DevTools: для основного текста нужен коэффициент не ниже 4.5:1 по WCAG для уровня AA.
Отдельная боль — тени и картинки
Тени box-shadow, рассчитанные на белый фон, на тёмном почти не видны — их часто забывают адаптировать. А фотографии и графики с белым фоном на тёмной теме выглядят вырезанными прямоугольниками света. Для картинок с прозрачностью помогает лёгкое приглушение:
.dark img:not([src*=".svg"]) {
/* Слегка гасим яркость картинок в тёмной теме */
filter: brightness(0.85);
}
Инверсию цветов картинок применяйте осторожно: логотипы и скриншоты интерфейсов от неё ломаются.
Ошибка 4. Резкий transition на всём подряд
Чтобы переключение выглядело плавно, вешают transition: all 0.3s на *. Это бьёт по производительности: браузер начинает анимировать сотни свойств у тысяч элементов, при переключении заметны рывки, а при первой загрузке — ещё и лишняя анимация появления.
Анимируйте только цвет и фон, и только там, где нужно:
body,
.card,
.header {
transition: background-color 0.2s ease, color 0.2s ease;
}
Отдельная тонкость: при первой загрузке transition не нужен вовсе, иначе тёмная тема будет «доезжать» до цвета на глазах у пользователя. Решается добавлением класса-переключателя transition только после первого кадра — либо ставьте transition на конкретные интерактивные блоки, а не на body.
Сравнение способов хранения выбора
| Способ | Работает без JS | Доступен в инлайн-скрипте до рендера | Когда выбирать |
|---|---|---|---|
localStorage | Нет | Да, синхронно | Статические сайты, SPA — стандартный выбор |
| Cookie | Да, если сервер читает и ставит класс в HTML | Да | SSR, когда важно убрать вспышку на уровне сервера |
Только prefers-color-scheme | Да, чистый CSS | — | Когда ручной переключатель не нужен |
Если у вас серверный рендеринг, cookie предпочтительнее: сервер читает её и сразу отдаёт HTML с нужным классом на <html>, и инлайновый скрипт не нужен. На статике и в клиентских SPA localStorage проще.
Если совсем без переключателя
Когда ручной выбор темы не требуется, весь JavaScript можно выбросить и обойтись медиазапросом. Тогда сайт следует за системой полностью на CSS:
@media (prefers-color-scheme: dark) {
:root {
--bg-primary: #121212;
--text-primary: rgba(255, 255, 255, 0.87);
}
}
Ни вспышки, ни рассинхрона, ни зависимости от localStorage. Минус один: пользователь не сможет включить тёмную тему на сайте, оставив систему светлой. Для многих проектов это приемлемо.
Что проверить перед выкладкой
- Обновите страницу в тёмном режиме десять раз — белой вспышки быть не должно.
- Переключите тему системы при открытой вкладке — сайт должен отреагировать, если пользователь не делал явный выбор.
- Прогоните основной текст через проверку контраста в DevTools — не ниже 4.5:1.
- Посмотрите на картинки, графики и тени в тёмной теме отдельно: они адаптируются не автоматически.
- Проверьте формы: браузерные автозаполненные поля Chrome красит своим цветом, это отдельно правится через
:-webkit-autofill.
Дальше стоит посмотреть в сторону CSS-функции light-dark() — она позволяет задавать пару значений «светлое/тёмное» прямо в свойстве без дублирования переменных. Поддержка появилась в Chrome 123, Safari 17.5 и Firefox 120. Для проектов без поддержки старых браузеров это заметно сокращает CSS, но перед внедрением сверьтесь с актуальной таблицей поддержки на MDN.
Разбор научной статьи: Сложный текст — понятным языком, с терминами
Спросить за 12 ₽Источники: prefers-color-scheme — MDN Web Docs, light-dark() — MDN Web Docs
Частые вопросы
Почему у меня всё равно мигает белым, хотя скрипт в head?
А если у пользователя старый браузер без prefers-color-scheme?
Стоит ли использовать filter: invert для быстрой тёмной темы?
Инлайновый скрипт в head — это же плохо для производительности и CSP?
Как быть с автозаполненными полями Chrome — они белые в тёмной теме?
Нужен ли transition при переключении темы?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.