Тёмная тема: реализация
Зачем тёмная тема
Что даёт:
- Комфорт — меньше нагрузки на глаза в темноте.
- Экономия — OLED-экраны тратят меньше.
- Ожидание — современные ОС имеют тёмный режим.
- Доступность — важно для светочувствительных.
Правило: тёмная тема — стандарт, не опция.
Три способа
1. По системной теме:
@media (prefers-color-scheme: dark) {
/* тёмные стили */
}
2. Переключатель:
- Пользователь сам выбирает.
- Хранится в localStorage.
3. Гибрид:
- Системная тема по умолчанию.
- Переключатель переопределяет.
Правило: гибрид — оптимально.
CSS-переменные
Основной подход:
:root {
--bg: #fafafa;
--surface: #ffffff;
--border: #e5e5e5;
--fg: #1a1a1a;
--fg-muted: #6b6b6b;
--accent: #c1442e;
}
[data-theme="dark"] {
--bg: #0f0f10;
--surface: #1a1a1c;
--border: #2a2a2e;
--fg: #ededed;
--fg-muted: #8b8b8b;
--accent: #e85d42;
}
body {
background: var(--bg);
color: var(--fg);
}
Что даёт: переключение одной строкой.
Правило: все цвета — через переменные.
prefers-color-scheme
Автоматически по системной:
:root {
--bg: #fafafa;
--fg: #1a1a1a;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f0f10;
--fg: #ededed;
}
}
Что даёт: работает без JS.
⚠️ Нет переключателя.
Комбинирование
Системная тема + ручной выбор:
:root {
--bg: #fafafa;
--fg: #1a1a1a;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f0f10;
--fg: #ededed;
}
}
[data-theme="light"] {
--bg: #fafafa;
--fg: #1a1a1a;
}
[data-theme="dark"] {
--bg: #0f0f10;
--fg: #ededed;
}
Логика:
- Без
data-theme— системная. - С
data-theme="light"— светлая. - С
data-theme="dark"— тёмная.
HTML-атрибут
<html data-theme="dark">
JS:
document.documentElement.setAttribute('data-theme', 'dark');
Что даёт: глобальное переключение.
Переключатель
HTML:
<button
type="button"
aria-label="Переключить тему"
id="theme-toggle"
>
<svg class="icon-sun">...</svg>
<svg class="icon-moon">...</svg>
</button>
JS:
const toggle = document.getElementById('theme-toggle');
const html = document.documentElement;
function getTheme() {
return html.getAttribute('data-theme');
}
function setTheme(theme) {
html.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
}
toggle.addEventListener('click', () => {
const current = getTheme();
setTheme(current === 'dark' ? 'light' : 'dark');
});
Anti-FOUC (вспышка)
Проблема: при загрузке страница на мгновение светлая.
Решение: скрипт в <head> до рендера:
<head>
<script>
(function () {
try {
const stored = localStorage.getItem('theme');
const system = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
document.documentElement.setAttribute('data-theme', stored || system);
} catch (e) {}
})();
</script>
<style>/* CSS с переменными */</style>
</head>
Что даёт: тема применяется до отрисовки.
Правило: inline-скрипт в <head>, до CSS.
localStorage
Три состояния:
null— не выбрано, идём по системной.'light'— принудительно светлая.'dark'— принудительно тёмная.
const stored = localStorage.getItem('theme');
const system = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
const theme = stored || system;
Слушатель системной темы
Пользователь сменил тему в ОС:
window.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', (e) => {
if (!localStorage.getItem('theme')) {
document.documentElement.setAttribute(
'data-theme',
e.matches ? 'dark' : 'light'
);
}
});
Что даёт: живое обновление без перезагрузки.
Дизайн тёмной темы
Правила:
- Не чистый чёрный —
#0f0f10, не#000. - Не чистый белый текст —
#ededed, не#fff. - Accent цвет — светлее для контраста.
- Тени — почти не видны, заменить границами.
- Картинки — приглушить.
- Не инвертировать цвета — переделать палитру.
[data-theme="dark"] {
--bg: #0f0f10;
--surface: #1a1a1c;
--surface-hover: #232326;
--border: #2a2a2e;
--fg: #ededed;
--fg-muted: #8b8b8b;
--accent: #e85d42; /* светлее */
--accent-hover: #ff6f52;
--shadow: 0 4px 16px rgba(0, 0, 0, 0.4); /* плотнее */
}
Картинки в тёмной теме
Приглушение:
[data-theme="dark"] img:not(.no-dim) {
filter: brightness(0.85);
}
Или через <picture>:
<picture>
<source srcset="logo-light.svg" media="(prefers-color-scheme: dark)">
<img src="logo-dark.svg" alt="Логотип">
</picture>
Правило: белый логотип на тёмном фоне — отдельная картинка.
Формы
Inputs наследуют цвета? Нет, надо указать:
input, textarea, select {
background: var(--surface);
color: var(--fg);
border-color: var(--border);
}
input::placeholder {
color: var(--fg-muted);
opacity: 0.7;
}
color-scheme:
:root {
color-scheme: light;
}
[data-theme="dark"] {
color-scheme: dark;
}
Что даёт: системные контролы (скроллбар, select, checkbox) станут тёмными.
Правило: color-scheme — обязательно для тёмной темы.
Скроллбар
Кастомный:
::-webkit-scrollbar {
width: 10px;
}
::-webkit-scrollbar-track {
background: var(--bg);
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 5px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--fg-muted);
}
Firefox:
* {
scrollbar-width: thin;
scrollbar-color: var(--border) var(--bg);
}
Хранение в cookie для SSR
Next.js:
import { cookies } from 'next/headers';
export default function Layout({ children }) {
const theme = cookies().get('theme')?.value || 'light';
return (
<html lang="ru" data-theme={theme}>
<body>{children}</body>
</html>
);
}
Что даёт: тема на SSR без FOUC.
Cookie set:
document.cookie = `theme=${theme}; path=/; max-age=31536000; SameSite=Lax`;
React / Next.js компонент
'use client';
import { useEffect, useState } from 'react';
type Theme = 'light' | 'dark';
export function ThemeToggle() {
const [theme, setTheme] = useState<Theme>('light');
useEffect(() => {
const stored = localStorage.getItem('theme') as Theme | null;
const system = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
setTheme(stored || system);
}, []);
function toggle() {
const next = theme === 'dark' ? 'light' : 'dark';
setTheme(next);
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
}
return (
<button onClick={toggle} aria-label="Переключить тему">
{theme === 'dark' ? '☀️' : '🌙'}
</button>
);
}
Laravel / Blade
<head>:
<script>
(function () {
try {
const stored = localStorage.getItem('theme');
const system = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
document.documentElement.setAttribute('data-theme', stored || system);
} catch (e) {}
})();
</script>
Кнопка:
<button type="button" id="theme-toggle" aria-label="Переключить тему">🌙</button>
JS:
const toggle = document.getElementById('theme-toggle');
const html = document.documentElement;
toggle.addEventListener('click', () => {
const current = html.getAttribute('data-theme') || 'light';
const next = current === 'dark' ? 'light' : 'dark';
html.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
toggle.textContent = next === 'dark' ? '☀️' : '🌙';
});
Практика
1. Определите переменные для цветов в :root.**
2. Добавьте [data-theme="dark"].
3. Настройте prefers-color-scheme.
4. Скрипт anti-FOUC в <head>.
5. Переключатель с localStorage.
6. color-scheme для системных контролов.
7. Прогоните контраст через axe DevTools.
8. Проверьте картинки — не выбиваются?
Частые ошибки
1. FOUC:
Тема мигает при загрузке
Решение: inline-скрипт в head.
2. #000 и #fff:
Слишком резкий контраст
Решение: #0f0f10 и #ededed.
3. Забыли color-scheme:
Скроллбар белый
Решение: color-scheme: dark.
4. Инверсия через filter: invert():
Картинки страшные
Решение: отдельная палитра.
5. Тени в тёмной теме как в светлой:
Не видно
Решение: границы или плотные тени.
6. Inputs светлые:
Не наследуют фон
Решение: явно задать.
7. Логотип чёрный на тёмном:
Не видно
Решение: <picture> или currentColor.
8. Слушатель matchMedia без проверки localStorage:
Перебивает выбор пользователя
Решение: обновлять только если нет stored.
Итог
- CSS-переменные — основа.
[data-theme]— переключение.prefers-color-scheme— системная.- Anti-FOUC — inline-скрипт.
- localStorage — сохранение выбора.
color-scheme— системные контролы.- Не
#000и#fff. - Приглушать картинки.
- Тени → границы.
- Accent — светлее.
Дальше — графики: Chart.js — простой старт.