GenerateSaaS

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.

KeyTypeDefaultNotes
default"light" | "dark" | "system""system"Mode used before the user picks one
switcherbooleantrueShow 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")}
>
  • enableSystem is on only when "system" is in options; the remaining values become the explicit themes set, so removing a mode in config removes it from the switcher.
  • next-themes injects its own anti-FOUC script and persists the choice; <html> carries suppressHydrationWarning (set in app/[locale]/layout.tsx).
  • The switcher is a cycle button, not a dropdown. ThemeSwitcher (components/shared/theme-switcher.tsx) advances through config.theme.options in order on each click.
  • Force one mode by setting default and reducing options (e.g. options: ["dark"]), then switcher: false to hide the toggle.
HookSourceUse it for
useTheme()hooks/use-theme.tsUI 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).

ValueBehaviorSSR
staticLightRaysStatic light-ray backdrop (default)Yes
lightRaysAnimated rays - a dynamic(..., { ssr: false }) client chunk, kept out of other deployments so motion never ships unless usedNo
radialGradientSoft radial gradientYes

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, … - in theme.css.
  • The dark custom variant is declared in globals.css.
  • Edit the token values in theme.css to 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.

On this page