Підтримка динамічних тем веб-сайту
Динамічна зміна теми — це не просто кнопка «темний/світлий режим». Це система управління дизайн-токенами, яка повинна працювати без мерцання при завантаженні, правильно зберігатися між сеансами, поважати системні налаштування користувача та підтримувати довільну кількість тем без дублювання 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 після гідрації — користувачі бачать мерцання світлого екрана перед темною темою. Рішення — inline-скрипт у <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: префіксів.
Користувацькі теми (Color 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 дня |
| Додаткові теми / color picker | 1–2 дні |
Базова реалізація light/dark: 1.5–2 дні.







