Урок 05

Тёмная тема: реализация

Зачем тёмная тема

Что даёт:

  • Комфорт — меньше нагрузки на глаза в темноте.
  • Экономия — 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 — простой старт.