Реализация Theme Provider для динамической смены тем
Тема сайта — это набор дизайн-токенов: цвета, типографика, отступы, радиусы, тени. Theme Provider — механизм, который делает эти токены доступными для всех компонентов и позволяет переключать их без перезагрузки страницы.
Два подхода: CSS Custom Properties (нативные CSS-переменные) или Context + CSS-in-JS. Первый — быстрее, проще, не зависит от фреймворка. Второй — больше гибкости при динамических токенах.
Подход на CSS Custom Properties
Тема живёт в CSS, JavaScript только переключает класс или атрибут на <html>:
/* themes.css */
:root,
[data-theme='light'] {
--color-bg: #ffffff;
--color-bg-secondary: #f8fafc;
--color-text: #0f172a;
--color-text-muted: #64748b;
--color-primary: #2563eb;
--color-primary-hover: #1d4ed8;
--color-border: #e2e8f0;
--color-shadow: rgb(0 0 0 / 0.08);
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--font-sans: 'Inter', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', monospace;
}
[data-theme='dark'] {
--color-bg: #0f172a;
--color-bg-secondary: #1e293b;
--color-text: #f1f5f9;
--color-text-muted: #94a3b8;
--color-primary: #3b82f6;
--color-primary-hover: #60a5fa;
--color-border: #1e293b;
--color-shadow: rgb(0 0 0 / 0.3);
}
[data-theme='sepia'] {
--color-bg: #fdf6e3;
--color-bg-secondary: #f5edd6;
--color-text: #433422;
--color-text-muted: #7c6a54;
--color-primary: #c0392b;
--color-primary-hover: #a93226;
--color-border: #e8d5b0;
}
ThemeProvider
type ThemeId = 'light' | 'dark' | 'sepia' | 'system'
interface ThemeContextValue {
theme: ThemeId
resolvedTheme: 'light' | 'dark' | 'sepia'
setTheme: (theme: ThemeId) => void
themes: ThemeId[]
}
const ThemeContext = createContext<ThemeContextValue | null>(null)
const STORAGE_KEY = 'app-theme'
const THEMES: ThemeId[] = ['system', 'light', 'dark', 'sepia']
function getSystemTheme(): 'light' | 'dark' {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<ThemeId>(() => {
if (typeof window === 'undefined') return 'system'
return (localStorage.getItem(STORAGE_KEY) as ThemeId) ?? 'system'
})
const resolvedTheme = useMemo<'light' | 'dark' | 'sepia'>(() => {
if (theme === 'system') return getSystemTheme()
return theme as 'light' | 'dark' | 'sepia'
}, [theme])
// Применяем тему к <html>
useEffect(() => {
const root = document.documentElement
root.setAttribute('data-theme', resolvedTheme)
// Цвет строки браузера (Chrome Mobile)
const metaThemeColor = document.querySelector('meta[name="theme-color"]')
const colors: Record<string, string> = {
light: '#ffffff',
dark: '#0f172a',
sepia: '#fdf6e3',
}
metaThemeColor?.setAttribute('content', colors[resolvedTheme])
}, [resolvedTheme])
// Реагируем на изменение системной темы
useEffect(() => {
if (theme !== 'system') return
const mq = window.matchMedia('(prefers-color-scheme: dark)')
const handler = () => {
document.documentElement.setAttribute('data-theme', getSystemTheme())
}
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [theme])
const setTheme = useCallback((newTheme: ThemeId) => {
setThemeState(newTheme)
localStorage.setItem(STORAGE_KEY, newTheme)
}, [])
return (
<ThemeContext.Provider value={{ theme, resolvedTheme, setTheme, themes: THEMES }}>
{children}
</ThemeContext.Provider>
)
}
function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext)
if (!ctx) throw new Error('useTheme должен использоваться внутри ThemeProvider')
return ctx
}
Предотвращение FOUC (Flash of Unstyled Content)
При SSR или первой загрузке страница может мигнуть неправильной темой. Решение — inline-скрипт в <head>:
<!-- В <head>, до любых стилей -->
<script>
(function () {
var stored = localStorage.getItem('app-theme');
var theme = stored && stored !== 'system' ? stored : (
window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
);
document.documentElement.setAttribute('data-theme', theme);
})();
</script>
В Next.js:
// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ru" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html: `(function(){var s=localStorage.getItem('app-theme');var t=s&&s!=='system'?s:window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';document.documentElement.setAttribute('data-theme',t)})()`,
}}
/>
</head>
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
)
}
Переключатель тем
function ThemeToggle() {
const { theme, setTheme, themes } = useTheme()
const labels: Record<ThemeId, string> = {
system: 'Системная',
light: 'Светлая',
dark: 'Тёмная',
sepia: 'Сепия',
}
return (
<div role="radiogroup" aria-label="Тема оформления">
{themes.map((t) => (
<label key={t}>
<input
type="radio"
name="theme"
value={t}
checked={theme === t}
onChange={() => setTheme(t)}
/>
{labels[t]}
</label>
))}
</div>
)
}
Интеграция с Tailwind CSS
Tailwind поддерживает class-based и attribute-based dark mode:
// tailwind.config.ts
export default {
darkMode: ['selector', '[data-theme="dark"]'],
// Или для мультитемы через CSS-переменные:
theme: {
extend: {
colors: {
bg: 'var(--color-bg)',
'bg-secondary': 'var(--color-bg-secondary)',
primary: 'var(--color-primary)',
text: 'var(--color-text)',
},
},
},
}
Токены через TypeScript
Для строгой типизации токенов в CSS-in-JS:
const baseTheme = {
colors: {
primary: 'var(--color-primary)',
background: 'var(--color-bg)',
text: 'var(--color-text)',
},
radii: {
sm: 'var(--radius-sm)',
md: 'var(--radius-md)',
lg: 'var(--radius-lg)',
},
fonts: {
sans: 'var(--font-sans)',
mono: 'var(--font-mono)',
},
} as const
type Theme = typeof baseTheme
Что входит в работу
Определение токенов для всех тем в CSS, ThemeProvider с поддержкой системной темы, React-хук useTheme, предотвращение FOUC (inline-скрипт), переключатель тем, интеграция с Tailwind или CSS-in-JS.
Срок: 1 день.







