Theming
Control the default mode, switcher, options, and decorative background via config.theme.
Theming is driven by config.theme (no enabled flag - it's always on) and rendered through next-themes, with shared shadcn/ui tokens from @repo/styles. Recolor your brand by editing the design tokens; control modes and the switcher from config.
config.theme
config.theme has four keys, all with sensible defaults.
| Key | Type | Default | Notes |
|---|---|---|---|
default | "light" | "dark" | "system" | "system" | Mode used before the user picks one |
switcher | boolean | true | Show the theme toggle; set false to hide it |
options | ("light" | "dark" | "system")[] | ["light", "dark", "system"] | Selectable modes - the switcher cycles through this array in order |
background | "lightRays" | "staticLightRays" | "radialGradient" (optional) | "staticLightRays" | Decorative background (see below) |
The default/options types are ThemePreference ("light" | "dark" | "system"), exported from @repo/config.
How next-themes is wired
ThemeProvider (apps/web-next/components/theme-provider.tsx) wraps next-themes and translates config into props.
// apps/web-next/components/theme-provider.tsx
<NextThemesProvider
attribute="class"
defaultTheme={config.theme?.default ?? "system"}
enableSystem={THEME_OPTIONS.includes("system")}
themes={[...THEME_OPTIONS].filter((option) => option !== "system")}
>enableSystemis on only when"system"is inoptions; the remaining values become the explicitthemesset, so removing a mode in config removes it from the switcher.next-themesinjects its own anti-FOUC script and persists the choice;<html>carriessuppressHydrationWarning(set inapp/[locale]/layout.tsx).- The switcher is a cycle button, not a dropdown.
ThemeSwitcher(components/shared/theme-switcher.tsx) advances throughconfig.theme.optionsin order on each click. - Force one mode by setting
defaultand reducingoptions(e.g.options: ["dark"]), thenswitcher: falseto hide the toggle.
| Hook | Source | Use it for |
|---|---|---|
useTheme() | hooks/use-theme.ts | UI controls - returns { theme, cycleTheme }; cycleTheme advances config.theme.options and plays the View Transitions radial-reveal (skipped for reduced-motion, mobile, or no startViewTransition) |
useTheme() | components/theme-provider.tsx (re-export of next-themes) | Just reading/setting the raw value - returns { theme, setTheme, … }, no cycle logic |
Background effects
config.theme.background selects the decorative backdrop rendered by <ThemeBackground> on the marketing, onboarding, clean, and error layouts. Unset or unknown values render nothing (null).
| Value | Behavior | SSR |
|---|---|---|
staticLightRays | Static light-ray backdrop (default) | Yes |
lightRays | Animated rays - a dynamic(..., { ssr: false }) client chunk, kept out of other deployments so motion never ships unless used | No |
radialGradient | Soft radial gradient | Yes |
Recoloring with @repo/styles
Brand color lives in design tokens, not component code.
@repo/styles(tooling/styles) ships the Tailwind v4 shadcn tokens ---background,--primary,--radius, … - intheme.css.- The
darkcustom variant is declared inglobals.css. - Edit the token values in
theme.cssto rebrand; every shadcn component picks them up.
The lightRays background is ssr: false, so it never prerenders. Keep staticLightRays or radialGradient if you want the background in the prerendered HTML.
Frequently asked questions
How do I ship a dark-only app?
Set config.theme.default: "dark", options: ["dark"], and switcher: false. The toggle disappears and enableSystem is automatically off because "system" is not in options.
Where do I change my brand color?
Edit the token values (e.g. --primary) in theme.css in @repo/styles (tooling/styles). Don't recolor components individually.
Does the theme flash on load?
No. next-themes injects an anti-FOUC script and <html> uses suppressHydrationWarning, so the persisted choice applies before paint.