{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "use-theme", "type": "registry:hook", "title": "useTheme", "description": "The theme engine: light, dark, system, and OLED, with persistence and theme-color meta kept in sync.", "categories": [ "hooks" ], "files": [ { "path": "hooks/use-theme.ts", "type": "registry:hook", "target": "hooks/use-theme.ts", "content": "import { useCallback, useEffect, useRef, useState } from \"react\";\n\nexport type ThemeMode = \"light\" | \"dark\" | \"system\";\n\nexport interface ThemeAppliedState {\n isDark: boolean;\n /** True when the OLED overlay is active (dark mode + extraDark). */\n extraDark: boolean;\n /** The value written to meta[name=\"theme-color\"]. */\n themeColor: string;\n}\n\nexport interface UseThemeOptions {\n /**\n * Controlled mode: pass the persisted preference (e.g. from a Dexie\n * settings row) and keep persistence app-side -- `setMode` becomes a no-op.\n * Omit to let the hook own persistence via `storageKey` (localStorage).\n */\n mode?: ThemeMode;\n /** Uncontrolled persistence key. Default: `wj-theme`. */\n storageKey?: string;\n /**\n * OLED overlay: in dark mode, additionally sets the DS `.extra-dark` class\n * (re-points dark surface tokens to pure black).\n *\n * Controlled/uncontrolled works exactly as `mode` does, and independently of\n * it. PASS it and the app owns the value -- `setExtraDark` becomes a no-op\n * (Chip Away keeps the flag in a Dexie settings row, where the hook storing\n * it too would be a second source of truth). OMIT it and the hook persists\n * the flag under `` `${storageKey}-extra-dark` `` and syncs every instance,\n * which is what an app with no database of its own wants -- otherwise each\n * one re-implements this hook's event-plus-`storage` sync to keep a Layout\n * that applies the class agreeing with a Settings page that sets it.\n */\n extraDark?: boolean;\n /**\n * meta[name=\"theme-color\"] values. Omit them: the resolved color is read\n * from the DS background token that is actually painting the page, so an\n * app or theme that re-points `--color-background-*` is followed for free.\n * Only pass these for a bar color that deliberately differs from the page.\n */\n themeColors?: { light?: string; dark?: string; extraDark?: string };\n /**\n * When set, the resolved appearance (`'light' | 'dark' | 'oled'`) is\n * mirrored to this localStorage key on every apply, for an index.html\n * pre-paint script to read on the next launch (kills the first-frame\n * light-to-dark flash). Best-effort: storage errors are swallowed.\n */\n launchMirrorKey?: string;\n /**\n * Paint the resolved color onto `` (default `true`) -- the cold-launch\n * flash backstop described on the apply effect below.\n *\n * Pass `false` for a window whose canvas must stay TRANSPARENT: the root\n * element's background propagates to the canvas, so painting it defeats a\n * `body { background: transparent }` rule and fills the whole window rect.\n * Chip Away's Linux build is the case -- an undecorated `transparent: true`\n * window whose rounded corners are pure CSS, and which needs genuine alpha\n * outside the rounded box to show them. Setting `false` also CLEARS any\n * color an index.html pre-paint script already set, so the opt-out still\n * holds when the app can only detect the platform after that script ran.\n */\n paintRoot?: boolean;\n /**\n * App-specific side effects after each apply -- native status-bar bridges,\n * window-chrome updates, and similar. Called with the resolved state.\n */\n onApplied?: (state: ThemeAppliedState) => void;\n}\n\nexport interface UseThemeResult {\n /** The preference ('light' / 'dark' / 'system'). */\n mode: ThemeMode;\n /** Persist + broadcast a new preference. No-op in controlled mode. */\n setMode: (mode: ThemeMode) => void;\n /** The resolved appearance. */\n isDark: boolean;\n /**\n * The OLED preference, which is what a settings toggle binds to. This is the\n * preference rather than the resolved state: it stays true in light mode,\n * where the overlay does not apply. For the resolved state read `extraDark`\n * off `onApplied`, or `isDark && extraDark` here.\n */\n extraDark: boolean;\n /** Persist + broadcast a new OLED preference. No-op when `extraDark` is passed. */\n setExtraDark: (extraDark: boolean) => void;\n}\n\nconst CHANGE_EVENT = \"wj-theme-changed\";\nconst DEFAULT_KEY = \"wj-theme\";\n/**\n * Derived rather than a second option, so an app namespaces both halves of its\n * theme state by setting `storageKey` once.\n */\nconst EXTRA_DARK_SUFFIX = \"-extra-dark\";\n/**\n * Last resort only, for when variables.css has not been applied (SSR, tests).\n * These mirror the `background.light` / `background.dark` tokens and the\n * `.dark.extra-dark` overlay; the live token wins whenever it can be read.\n */\nconst FALLBACK_COLORS = { light: \"#f2f0ed\", dark: \"#1d1d1d\", extraDark: \"#000000\" };\n\n/**\n * Read a DS custom property off , resolved against its CURRENT classes\n * (so `.dark.extra-dark` re-pointing `--color-background-dark` to black comes\n * out on its own). Returns `fallback` when no stylesheet has landed yet.\n */\nfunction readTokenColor(name: string, fallback: string): string {\n try {\n const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();\n return value || fallback;\n } catch {\n // Non-DOM environments and locked-down webviews.\n return fallback;\n }\n}\n\nfunction readStored(key: string): ThemeMode {\n try {\n const saved = localStorage.getItem(key);\n if (saved === \"light\" || saved === \"dark\" || saved === \"system\") return saved;\n } catch {\n // Locked-down webviews can throw; fall through to the default.\n }\n return \"system\";\n}\n\nfunction readStoredExtraDark(key: string): boolean {\n try {\n return localStorage.getItem(key) === \"1\";\n } catch {\n // Same webviews; the overlay is off by default.\n return false;\n }\n}\n\n/**\n * The one theme engine: resolves light/dark/system (live OS tracking), toggles\n * the `dark` / `.extra-dark` classes on , keeps meta[name=\"theme-color\"]\n * in sync, and -- uncontrolled -- persists the preference and syncs every hook\n * instance in the tab (plus other tabs) so Layout and Settings stay agreed.\n */\nexport function useTheme(options: UseThemeOptions = {}): UseThemeResult {\n const {\n mode: controlledMode,\n storageKey = DEFAULT_KEY,\n extraDark: controlledExtraDark,\n themeColors,\n launchMirrorKey,\n paintRoot = true,\n onApplied,\n } = options;\n const controlled = controlledMode !== undefined;\n // Independent of `controlled`: an app can control the mode from its own store\n // and still let the hook own the OLED flag, or the reverse.\n const extraDarkControlled = controlledExtraDark !== undefined;\n const extraDarkKey = `${storageKey}${EXTRA_DARK_SUFFIX}`;\n\n const [storedMode, setStoredMode] = useState(() =>\n controlled || typeof window === \"undefined\" ? \"system\" : readStored(storageKey),\n );\n const mode = controlled ? controlledMode : storedMode;\n\n const [storedExtraDark, setStoredExtraDark] = useState(() =>\n extraDarkControlled || typeof window === \"undefined\"\n ? false\n : readStoredExtraDark(extraDarkKey),\n );\n const extraDark = extraDarkControlled ? controlledExtraDark : storedExtraDark;\n\n const [systemDark, setSystemDark] = useState(\n () => typeof window !== \"undefined\" && window.matchMedia(\"(prefers-color-scheme: dark)\").matches,\n );\n\n // Track the OS preference so 'system' mode reacts live.\n useEffect(() => {\n const mq = window.matchMedia(\"(prefers-color-scheme: dark)\");\n setSystemDark(mq.matches);\n const handler = (e: MediaQueryListEvent) => setSystemDark(e.matches);\n mq.addEventListener(\"change\", handler);\n return () => mq.removeEventListener(\"change\", handler);\n }, []);\n\n // Uncontrolled: stay in sync with other hook instances (same tab) and other\n // tabs of the same app.\n useEffect(() => {\n if (controlled) return;\n const sync = () => setStoredMode(readStored(storageKey));\n window.addEventListener(CHANGE_EVENT, sync);\n window.addEventListener(\"storage\", sync);\n return () => {\n window.removeEventListener(CHANGE_EVENT, sync);\n window.removeEventListener(\"storage\", sync);\n };\n }, [controlled, storageKey]);\n\n // The same sync for the OLED flag, on the same event. Separate because the\n // two halves control independently: an app can pass `mode` and omit\n // `extraDark`, and then only this one runs.\n useEffect(() => {\n if (extraDarkControlled) return;\n const sync = () => setStoredExtraDark(readStoredExtraDark(extraDarkKey));\n window.addEventListener(CHANGE_EVENT, sync);\n window.addEventListener(\"storage\", sync);\n return () => {\n window.removeEventListener(CHANGE_EVENT, sync);\n window.removeEventListener(\"storage\", sync);\n };\n }, [extraDarkControlled, extraDarkKey]);\n\n const isDark = mode === \"dark\" || (mode === \"system\" && systemDark);\n const oled = isDark && extraDark;\n\n const onAppliedRef = useRef(onApplied);\n onAppliedRef.current = onApplied;\n\n const lightOverride = themeColors?.light;\n const darkOverride = themeColors?.dark;\n const oledOverride = themeColors?.extraDark;\n\n // Apply the resolved appearance.\n useEffect(() => {\n const root = document.documentElement;\n root.classList.toggle(\"dark\", isDark);\n root.classList.toggle(\"extra-dark\", oled);\n // Derive the bar color from the token that is painting the page, read\n // AFTER the class toggle so the `.extra-dark` overlay resolves itself. A\n // hardcoded copy here drifts silently the moment a background token moves\n // -- and it takes native chrome with it, because `onApplied` consumers\n // (Chip Away's Android status-bar bridge) paint whatever this resolves to.\n const themeColor = isDark\n ? oled\n ? oledOverride ?? readTokenColor(\"--color-background-dark\", FALLBACK_COLORS.extraDark)\n : darkOverride ?? readTokenColor(\"--color-background-dark\", FALLBACK_COLORS.dark)\n : lightOverride ?? readTokenColor(\"--color-background-light\", FALLBACK_COLORS.light);\n document.querySelector('meta[name=\"theme-color\"]')?.setAttribute(\"content\", themeColor);\n // Paint the root element itself, not only the classes. The page background\n // normally comes from a `body` rule, which does not exist until the app's\n // render-blocking stylesheet has loaded -- so between navigation and that\n // load the canvas is the browser's or webview's own default (white), which\n // is the cold-launch flash. A pre-paint script in index.html cannot fix it\n // by toggling classes, because a class is inert without CSS; it CAN set this\n // property, and doing the same here keeps the root in sync for the rest of\n // the session so a later theme change does not leave a stale color behind.\n //\n // The root's background PROPAGATES TO THE CANVAS, which is why this reaches\n // the launch frame at all -- and equally why `paintRoot: false` exists: it\n // also overrides a transparent `body`, so a transparent window loses the\n // alpha it needs (see the option's doc). Clearing rather than skipping means\n // the opt-out also undoes a pre-paint script that ran before the app could\n // tell which platform it was on.\n root.style.backgroundColor = paintRoot ? themeColor : \"\";\n if (launchMirrorKey) {\n try {\n localStorage.setItem(launchMirrorKey, oled ? \"oled\" : isDark ? \"dark\" : \"light\");\n } catch {\n // Best-effort mirror.\n }\n }\n onAppliedRef.current?.({ isDark, extraDark: oled, themeColor });\n }, [isDark, oled, lightOverride, darkOverride, oledOverride, launchMirrorKey, paintRoot]);\n\n const setMode = useCallback(\n (next: ThemeMode) => {\n if (controlled) return;\n try {\n localStorage.setItem(storageKey, next);\n } catch {\n // The in-memory state still updates; persistence is best-effort.\n }\n setStoredMode(next);\n window.dispatchEvent(new Event(CHANGE_EVENT));\n },\n [controlled, storageKey],\n );\n\n const setExtraDark = useCallback(\n (next: boolean) => {\n if (extraDarkControlled) return;\n try {\n localStorage.setItem(extraDarkKey, next ? \"1\" : \"0\");\n } catch {\n // The in-memory state still updates; persistence is best-effort.\n }\n setStoredExtraDark(next);\n window.dispatchEvent(new Event(CHANGE_EVENT));\n },\n [extraDarkControlled, extraDarkKey],\n );\n\n return { mode, setMode, isDark, extraDark, setExtraDark };\n}\n" } ], "docs": "Use this instead of hand-rolling matchMedia listeners, dark-class toggling, or theme-color meta updates. Two modes: uncontrolled (pass storageKey and the hook owns localStorage) or controlled (pass mode from your own settings store). The OLED overlay follows the same split, independently: omit extraDark and the hook persists it under `${storageKey}-extra-dark` and hands back setExtraDark, so a Layout that applies the class and a Settings page that sets it stay in sync with no shared parent; pass extraDark and your store owns it. The theme color derives from the background tokens, so a token change reaches the meta tag with no per-app edit.", "meta": { "group": "hooks", "related": [ "toggle-group" ], "exports": [ "useTheme" ], "siteSlug": "use-theme" } }