3.7 KiB
<ThemeProvider> — chassis-level theme registry
(no submodules)
Purpose
Mounts the active theme registry, hands it down through React context,
applies the active theme's tokens to <html> as inline CSS variables,
listens for prefers-reduced-motion changes, and prevents flash-of-
unstyled-content via an inline FOUC-prevention <script> tag rendered
into the <head>. The Provider is the only piece of the chassis that
mutates document.documentElement directly — every other consumer reads
through the useTheme / useThemeTokens / useThemedVariants hooks.
Features
- Validates every supplied
ThemePluginagainstThemePluginSchemaat mount; throwsReactUiError(REACT_UI.THEME_TOKEN_MISSING)on failure - Resolves the initial theme from
localStorage[storageKey]first, thendefaultTheme; throwsReactUiError(REACT_UI.THEME_NOT_REGISTERED)when the resolved name is not registered - Inline FOUC-prevention
<script>injected before children render so<html data-theme="...">is set before React hydrates; CSP nonce honoured viascriptNonce - Live
prefers-reduced-motionlistener that updates the live--motion-duration-*variables according to the active theme'srespectReducedMotionpolicy ('strict'collapses to0ms,'soft'halves,'off'ignores) setThemewrapped inuseTransition()so theme switches are low-priority React work that does not block urgent input- OTel telemetry: counter
react_ui.theme.switch_total{from, to}and histogramreact_ui.theme.switch_duration_ms
Usage: Basic example
import { ThemeProvider } from "@suaveplan/react-ui/theme/theme-provider";
import { darkTheme, lightTheme } from "@suaveplan/react-ui/theme";
<ThemeProvider themes={[lightTheme, darkTheme]} defaultTheme="light">
<App />
</ThemeProvider>;
Usage: Advanced example
import { ThemeProvider } from "@suaveplan/react-ui/theme/theme-provider";
import {
darkTheme,
highContrastTheme,
lightTheme,
} from "@suaveplan/react-ui/theme";
<ThemeProvider
themes={[lightTheme, darkTheme, highContrastTheme]}
defaultTheme="system"
storageKey="my-app-theme"
enableSystem={true}
attribute="data-theme"
scriptNonce={cspNonce}
>
<App />
</ThemeProvider>;
API Reference
<ThemeProvider>— props perThemeProviderProps:themes: ReadonlyArray<ThemePlugin>— requireddefaultTheme: string— required; matched againstthemes[i].nameor the literal'system'(whenenableSystem)storageKey?: string— defaults to'suaveplan-theme'enableSystem?: boolean— when true, allowsdefaultTheme: 'system'to map ontoprefers-color-schemeattribute?: string— defaults to'data-theme'scriptNonce?: string— CSP nonce for the inline FOUC scriptchildren: ReactNode
buildFoucScript(input)— pure builder for the inline script body; exposed so tests can assert without renderinghalveDuration(input)— pure helper that halves anXms/Ysduration string; returns'0ms'on parse failtoCssVar(name)— pure helper convertingcardForeground→--card-foreground
Implementation notes
The FOUC script is intentionally compact, self-contained, and uses
String.prototype.replace for all escaping rather than JSON.stringify
so the emitted body cannot smuggle a stray </script> close tag. The
useTransition() wrapper around setTheme is the reason the
isPending flag exists on useTheme() — consumers that show a tiny
spinner during a theme transition read that flag. The Provider tracks
the previous theme name in a useRef so the OTel from attribute is
accurate even when several setTheme calls land back-to-back.