Надёжная тёмная тема начинается с CSS-переменных, а не с сотни новых классов. Вы заведёте палитру для двух режимов, учтёте системную тему, добавите одну кнопку и сохраните выбор в localStorage.
Если перекрашивать каждый элемент отдельно, новая карточка почти наверняка останется белой. Переменные превращают тему в небольшой набор токенов: фон, поверхность, текст, граница и акцент. Компоненты используют смысловые цвета и не знают, какая тема включена.
Пять смысловых цветов вместо случайных hex по файлу.
Сначала сохранённое решение, затем системная настройка.
Контраст, перезагрузка и смена темы без белой вспышки.
Как устроить палитру для двух тем
В корневом селекторе :root задайте светлые значения, а на html[data-theme="dark"] переопределите только переменные. Кнопка будет менять data-theme и aria-pressed. Компоненты продолжат использовать var(--surface), var(--text) и var(--border).
| Токен | Светлая тема | Тёмная тема |
|---|---|---|
| --bg | #f6f7fb | #0f1221 |
| --surface | #ffffff | #181c30 |
| --text | #171a2e | #f4f6ff |
| --muted | #62677f | #aeb4cf |
| --accent | #5b4bff | #8b7cff |
После этого шага у каждой части есть одна роль. Интерфейс собирает действие, логика меняет состояние, а вывод показывает признак успеха. Это проще отлаживать, чем один большой обработчик.

Явный выбор важнее системного. Клик обновляет DOM и хранилище
Пишем CSS и переключатель на JavaScript
Не храните строку false в качестве темы. В localStorage достаточно значений light и dark. Если пользователь ещё ничего не выбирал, matchMedia подскажет системное предпочтение. aria-pressed показывает состояние кнопки вспомогательным технологиям.
<button id="theme" type="button" aria-pressed="false">Тёмная тема</button>
<main class="card"><h1>Панель проекта</h1><p>Тема меняется без перезагрузки.</p></main>
<style>
:root{color-scheme:light;--bg:#f6f7fb;--surface:#fff;--text:#171a2e;--muted:#62677f;--border:#dfe2ee;--accent:#5b4bff}
html[data-theme="dark"]{color-scheme:dark;--bg:#0f1221;--surface:#181c30;--text:#f4f6ff;--muted:#aeb4cf;--border:#303652;--accent:#8b7cff}
body{margin:0;background:var(--bg);color:var(--text);font:16px system-ui;transition:.2s}
.card{max-width:560px;margin:80px auto;padding:32px;background:var(--surface);border:1px solid var(--border);border-radius:24px}
button{margin:20px;padding:12px 16px;border:1px solid var(--border);background:var(--surface);color:var(--text);border-radius:12px}
@media(prefers-reduced-motion:reduce){body{transition:none}}
</style>
<script>
const button=document.querySelector('#theme');
const saved=localStorage.getItem('theme');
const systemDark=matchMedia('(prefers-color-scheme: dark)').matches;
let theme=saved ?? (systemDark?'dark':'light');
function apply(next){
theme=next; document.documentElement.dataset.theme=theme;
button.setAttribute('aria-pressed',String(theme==='dark'));
button.textContent=theme==='dark'?'Светлая тема':'Тёмная тема';
}
apply(theme);
button.addEventListener('click',()=>{apply(theme==='dark'?'light':'dark');localStorage.setItem('theme',theme)});
</script>Компоненты используют смысловые CSS-переменные. Первый запуск учитывает prefers-color-scheme. Ручной выбор сохраняется после перезагрузки. Кнопка синхронизирует подпись и aria-pressed.

Токены масштабируются лучше. Сравнение показывает, какой вариант легче поддерживать и проверять.
Как выбрать тему при первом запуске
color-scheme сообщает браузеру, какой вариант встроенных контролов и полосы прокрутки уместен. Это не красит ваши карточки автоматически, зато помогает нативным элементам не спорить с палитрой. data-theme остаётся вашим явным переключателем.
| Ситуация | Источник темы | Результат |
|---|---|---|
| Есть localStorage | Сохранённое значение | Выбор пользователя важнее |
| Хранилище пусто | prefers-color-scheme | Совпадает с системой |
| Клик по кнопке | Новое значение | Сохраняется |
| reduced motion | Медиа-запрос | Нет перехода |
Сохраняйте в одном месте то, что может измениться. Если одно и то же значение скопировано в три функции, после первой правки они начнут расходиться. Один объект состояния и одна функция отрисовки снимают эту проблему.
Практика: проверяем контраст и сохранение
Проверьте не только фон и текст. Инпуты, границы, состояния hover, focus-visible, disabled и иллюстрации тоже должны читаться. Перезагрузите страницу после ручного выбора, очистите localStorage и поменяйте системную тему. Так вы увидите все три ветки логики. Отдельно откройте нативный select, поле даты и автозаполненный input: браузер рисует их часть сам, поэтому именно там часто остаётся чужой светлый фон или теряется текст. В конце сделайте снимки одного экрана в двух режимах и сравните визуальную иерархию: главный текст, вторичная подпись и активная кнопка должны сохранять одинаковую заметность.
- Откройте страницу при светлой системной теме.
- Переключите режим и перезагрузите страницу.
- Удалите ключ theme из localStorage.
- Включите тёмную тему в системе и обновите страницу.
- Пройдите кнопку клавишей Tab.
- Сверьте текст и границы в обеих темах.
Нет белых карточек в тёмном режиме. Ручной выбор переживает перезагрузку. Без выбора работает системная тема. Текст, границы и фокус видны в обоих режимах.

Четыре места, которые часто забывают. Пройдите все четыре сценария перед публикацией проекта.
Доводим тёмную тему до готового вида
Чтобы убрать короткую белую вспышку на реальном сайте, применяйте сохранённую тему маленьким скриптом в head до загрузки основного CSS. Не дублируйте всю таблицу стилей: меняйте токены и оставляйте компоненты общими.
Новая тема потребует переписать каждый блок.
Первый экран может ослепить пользователя.
После F5 выбор исчезнет.
Проверяйте контраст, а не только красоту палитры.
Что получится в итоге

Готовая страница одинаково аккуратно выглядит в светлом и тёмном режиме, а переключатель запоминает выбор.
Зачем CSS-переменные?
Они отделяют смысл цвета от конкретного значения и позволяют менять палитру в одном месте.
Что важнее: система или сохранённый выбор?
Сохранённый явный выбор пользователя.
Для чего color-scheme?
Он помогает браузеру подобрать вид встроенных элементов под тему.
Как понять, что проект готов?
Он повторяет целевой сценарий несколько раз подряд, понятно реагирует на ошибочный ввод и не теряет состояние при обычных действиях.
Можно ли копировать код целиком?
Можно как стартовую точку, но сначала замените значения из примера, затем запустите проект по шагам. Если вы не можете объяснить одну строку, её лучше разобрать до следующего шага.
Переменные, каскад и медиа-запросы разбираются в курсе CSS, события и localStorage в курсе JavaScript. Для структуры проекта откройте гайд по подключению JavaScript.
После темы добавьте адаптивное бургер-меню. В Кодике удобно открыть две версии рядом и проверить одни и те же состояния.