Platform
    • Naming & Linter Rules
    • Styling Standards
    • Theme
    • Zod & Typing
    • Internationalization
Projects
  • API Connections & Tools
  1. Documentation
  2. Theme Master Guide

Система тем приложения

Руководство по переключению тем (light/dark) без мигания: Vite — React Context + inline-скрипт, Next.js — next-themes.

Архитектура управления темой

Преимущество: Изоляция логики темы от бизнес-компонентов. Автоматическое сохранение выбора пользователя и отсутствие 'мигания' при загрузке.

Структура файлов Темизации

Расположение и Соглашения

  • Провайдер:

    Находится в src/app/providers/theme/. Отвечает за глобальную инициализацию.

  • Shared Logic:

    Контекст, хуки и UI-компонент выносятся в shared/, так как они переиспользуемы.

01Типы и Интерфейсы

Определяем допустимые значения темы (light, dark) и структуру контекста для управления состоянием.
shared/ui/layout/theme-toggle/model/theme.types.ts
1export type TTheme = "light" | "dark"; 2 3export interface IThemeContextType { 4 theme: TTheme; 5 toggleTheme: () => void; 6}

02React Context

Создаем канал передачи данных темы через дерево компонентов без использования пропсов.
shared/ui/layout/theme-toggle/model/theme.context.ts
1import { createContext } from "react"; 2import { IThemeContextType } from "./theme.types"; 3 4export const ThemeContext = createContext<IThemeContextType | undefined>(undefined);

03Кастомный Хук

Инкапсулируем логику доступа к контексту в удобном хуке useTheme с проверкой на наличие провайдера.
shared/ui/layout/theme-toggle/model/use-theme.ts
1import { useContext } from "react"; 2import { ThemeContext } from "./theme.context"; 3 4export const useTheme = () => { 5 const context = useContext(ThemeContext); 6 if (!context) { 7 throw new Error("useTheme must be used within a ThemeProvider"); 8 } 9 return context; 10};

04Theme Provider

Inline-скрипт в index.html ставит класс на html до первой отрисовки (сохранённый выбор или prefers-color-scheme). Провайдер берёт этот класс как начальное состояние, следует за системной темой, пока пользователь не выбрал свою, и сохраняет выбор в localStorage. На Next.js вместо этого — next-themes.
index.html
1<script> 2 (function () { 3 try { 4 var saved = localStorage.getItem("theme"); 5 var system = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; 6 document.documentElement.classList.add(saved === "light" || saved === "dark" ? saved : system); 7 } catch (e) { 8 document.documentElement.classList.add("light"); 9 } 10 })(); 11</script>
app/providers/theme/theme.provider.tsx
1import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; 2import { type TTheme, ThemeContext } from "@/shared/ui/layout/theme-toggle"; 3 4const STORAGE_KEY = "theme"; 5const DARK_QUERY = "(prefers-color-scheme: dark)"; 6 7const getSystemTheme = (): TTheme => 8 window.matchMedia(DARK_QUERY).matches ? "dark" : "light"; 9 10/** Класс уже выставлен inline-скриптом до первой отрисовки */ 11const getInitialTheme = (): TTheme => 12 document.documentElement.classList.contains("dark") ? "dark" : "light"; 13 14export const ThemeProvider = ({ children }: { children: ReactNode }) => { 15 const [theme, setTheme] = useState<TTheme>(getInitialTheme); 16 17 // Следуем за системной темой, пока нет явного выбора 18 useEffect(() => { 19 const media = window.matchMedia(DARK_QUERY); 20 const onChange = () => { 21 if (!localStorage.getItem(STORAGE_KEY)) setTheme(getSystemTheme()); 22 }; 23 media.addEventListener("change", onChange); 24 return () => media.removeEventListener("change", onChange); 25 }, []); 26 27 useEffect(() => { 28 const root = document.documentElement; 29 root.classList.remove("light", "dark"); 30 root.classList.add(theme); 31 }, [theme]); 32 33 const toggleTheme = useCallback(() => { 34 setTheme((prev) => { 35 const next = prev === "light" ? "dark" : "light"; 36 localStorage.setItem(STORAGE_KEY, next); 37 return next; 38 }); 39 }, []); 40 41 const value = useMemo(() => ({ theme, toggleTheme }), [theme, toggleTheme]); 42 43 return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>; 44}; 45 46// Next.js: вместо своего провайдера — next-themes 47// (attribute="class", defaultTheme="system", enableSystem, <html suppressHydrationWarning>)

05HOC для инициализации

HOC withTheme оборачивает корневой компонент в ThemeProvider — тема подключается одной строкой: export default withTheme(App).
app/providers/theme/with-theme.tsx
1import type { ComponentType, FC } from "react"; 2import { ThemeProvider } from "./theme.provider"; 3 4/** Подключение темы одной строкой: export default withTheme(App) */ 5export const withTheme = <P extends object>(Component: ComponentType<P>): FC<P> => { 6 const WithTheme: FC<P> = (props) => ( 7 <ThemeProvider> 8 <Component {...props} /> 9 </ThemeProvider> 10 ); 11 12 WithTheme.displayName = `withTheme(${Component.displayName || Component.name || "Component"})`; 13 14 return WithTheme; 15};

06UI Компонент (Toggle)

Создаем кнопку-переключатель, которая использует хук useTheme для смены визуального стейта.
shared/ui/layout/theme-toggle/ui/theme-toggle.tsx
1import { Moon, Sun } from "lucide-react"; 2import { Button } from "@/shared/ui"; 3import { useTheme } from "../model"; 4 5export const ThemeToggle = () => { 6 const { theme, toggleTheme } = useTheme(); 7 8 return ( 9 <Button onClick={toggleTheme} variant="outline" size="sm"> 10 {theme === "light" ? <Moon size={16} /> : <Sun size={16} />} 11 </Button> 12 ); 13};