Guide to implementing theme switching (light/dark) without flicker: Vite — React Context + inline script, Next.js — next-themes.
Benefit: Isolation of theme logic from business components. Automatic saving of user choice and prevention of theme 'flicker' on load.
Located in src/app/providers/theme/. Responsible for global initialization.
Context, hooks, and UI components are placed in shared/ as they are reusable.
light, dark) and context structure for state management.1export type TTheme = "light" | "dark";
2
3export interface IThemeContextType {
4 theme: TTheme;
5 toggleTheme: () => void;
6}1import { createContext } from "react";
2import { IThemeContextType } from "./theme.types";
3
4export const ThemeContext = createContext<IThemeContextType | undefined>(undefined);useTheme hook with provider presence validation.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};index.html sets the html class before first paint (saved choice or prefers-color-scheme). The provider reads that class as its initial state, follows the system theme until the user picks one, and persists the choice in localStorage. On Next.js use next-themes instead.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>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>)withTheme HOC wraps a root component in ThemeProvider, so the theme is connected in one line: export default withTheme(App).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};useTheme hook to switch the visual state.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};