Поддержка динамических тем оформления сайта
Динамическая смена темы — это не просто кнопка «тёмный/светлый режим». Это система управления дизайн-токенами, которая должна работать без мерцания при загрузке, корректно сохраняться между сессиями, уважать системные настройки пользователя и поддерживать произвольное количество тем без дублирования CSS.
Архитектура на CSS-переменных
Правильный фундамент — CSS Custom Properties. Вся палитра и типографика описываются через переменные, компоненты используют только переменные (никаких хардкодных #1a1a2e):
/* Базовая тема (светлая) */
:root {
--color-bg-primary: #ffffff;
--color-bg-secondary: #f5f5f5;
--color-text-primary: #1a1a1a;
--color-text-muted: #6b7280;
--color-accent: #3b82f6;
--color-border: #e5e7eb;
--shadow-card: 0 1px 3px rgba(0,0,0,0.1);
}
/* Тёмная тема */
[data-theme="dark"] {
--color-bg-primary: #0f172a;
--color-bg-secondary: #1e293b;
--color-text-primary: #f1f5f9;
--color-text-muted: #94a3b8;
--color-accent: #60a5fa;
--color-border: #334155;
--shadow-card: 0 1px 3px rgba(0,0,0,0.5);
}
/* Третья тема (пример: высокий контраст) */
[data-theme="high-contrast"] {
--color-bg-primary: #000000;
--color-text-primary: #ffffff;
--color-accent: #ffff00;
--color-border: #ffffff;
}
Смена темы — один setAttribute:
document.documentElement.setAttribute('data-theme', 'dark');
Переход мгновенный, без перезагрузки страницы, без JavaScript-перекраски каждого элемента.
Устранение FOUC (Flash of Unstyled Content)
Главная проблема: если тема загружается через React после гидрации — пользователь видит мерцание светлого экрана перед тёмной темой. Решение — инлайн-скрипт в <head>, который выполняется до рендера:
<!-- В <head>, до любых стилей -->
<script>
(function() {
var theme = localStorage.getItem('theme');
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var resolved = theme || (prefersDark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', resolved);
})();
</script>
Этот скрипт синхронный и крошечный (~200 байт). Он выполняется немедленно, устанавливая правильную тему до рендера любого CSS.
React Context + хук
type Theme = 'light' | 'dark' | 'high-contrast' | 'system';
interface ThemeContextValue {
theme: Theme;
resolvedTheme: 'light' | 'dark' | 'high-contrast';
setTheme: (theme: Theme) => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [theme, setThemeState] = useState<Theme>(() => {
if (typeof window === 'undefined') return 'system';
return (localStorage.getItem('theme') as Theme) || 'system';
});
const systemTheme = useMediaQuery('(prefers-color-scheme: dark)') ? 'dark' : 'light';
const resolvedTheme = theme === 'system' ? systemTheme : theme;
useEffect(() => {
document.documentElement.setAttribute('data-theme', resolvedTheme);
}, [resolvedTheme]);
const setTheme = (newTheme: Theme) => {
setThemeState(newTheme);
localStorage.setItem('theme', newTheme);
};
return (
<ThemeContext.Provider value={{ theme, resolvedTheme, setTheme }}>
{children}
</ThemeContext.Provider>
);
};
export const useTheme = () => {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be inside ThemeProvider');
return ctx;
};
Переключатель темы
const ThemeToggle: React.FC = () => {
const { theme, setTheme } = useTheme();
const options: { value: Theme; icon: React.ReactNode; label: string }[] = [
{ value: 'light', icon: <SunIcon />, label: 'Светлая' },
{ value: 'dark', icon: <MoonIcon />, label: 'Тёмная' },
{ value: 'system', icon: <MonitorIcon />, label: 'Системная' },
];
return (
<div className="theme-toggle" role="group" aria-label="Выбор темы">
{options.map(opt => (
<button
key={opt.value}
onClick={() => setTheme(opt.value)}
aria-pressed={theme === opt.value}
title={opt.label}
>
{opt.icon}
</button>
))}
</div>
);
};
Плавный переход между темами
Без анимации смена темы выглядит резко:
*, *::before, *::after {
transition:
background-color 200ms ease,
color 150ms ease,
border-color 200ms ease,
box-shadow 200ms ease;
}
Важный нюанс: эту transition нужно отключать на время начальной загрузки, иначе при возврате на страницу будет видна анимация с дефолтных цветов:
// Убираем transition на 1 кадр после загрузки
useEffect(() => {
document.documentElement.classList.add('no-transition');
requestAnimationFrame(() => {
document.documentElement.classList.remove('no-transition');
});
}, []);
.no-transition * { transition: none !important; }
Интеграция с Tailwind CSS
Tailwind 4 поддерживает CSS-переменные нативно. Маппинг:
/* В tailwind.config или @theme */
@theme {
--color-primary: var(--color-accent);
--color-background: var(--color-bg-primary);
}
Для Tailwind 3 — darkMode: 'class' в конфиге, но лучше полностью перейти на CSS-переменные и не зависеть от dark: префиксов.
Пользовательские темы (colour picker)
Для продвинутых случаев — пользователь сам выбирает акцентный цвет:
const AccentPicker: React.FC = () => {
const handleChange = (color: string) => {
document.documentElement.style.setProperty('--color-accent', color);
// Автоматически вычисляем hover-состояние
document.documentElement.style.setProperty(
'--color-accent-hover',
adjustLightness(color, -10)
);
localStorage.setItem('accent-color', color);
};
return <input type="color" onChange={e => handleChange(e.target.value)} />;
};
Сроки
| Задача | Время |
|---|---|
| CSS-переменные + 2 темы (light/dark) | 0.5 дня |
| FOUC-fix + React Context | 0.5 дня |
| Переключатель + сохранение в localStorage | 0.5 дня |
| Плавные переходы | 0.5 дня |
| Дополнительные темы / colour picker | 1–2 дня |
Базовая реализация light/dark: 1.5–2 дня.







