GenerateSaaS

Theming

Set the default color mode, the switcher, the selectable modes, and the decorative background from config.theme, and rebrand colors in the shared token sheet.

Theming is driven by config.theme (always on, no enabled flag) and rendered through next-themes, with shared shadcn tokens from @repo/styles. Modes and the switcher come from config; brand color comes from the design tokens.

config.theme

Four keys, all with defaults. default and options are typed ThemePreference ("light" | "dark" | "system"), exported from @repo/config.

KeyTypeDefaultNotes
default"light" | "dark" | "system""system"Mode used before the user picks one
switcherbooleantrueShow the theme toggle; false hides it
optionsThemePreference[]["light", "dark", "system"]Selectable modes; the switcher cycles them in array order
background"lightRays" | "staticLightRays" | "radialGradient""staticLightRays"Optional decorative backdrop

Ship a single mode by setting default and reducing options (e.g. options: ["dark"]), then switcher: false to hide the toggle.

next-themes wiring

ThemeProvider (components/theme-provider.tsx) translates config into next-themes props.

// apps/web/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 turns 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, so the theme never flashes; <html> carries suppressHydrationWarning.
  • ThemeSwitcher (components/shared/theme-switcher.tsx) is a cycle button, not a dropdown - each click advances config.theme.options. The swap is instant.
  • useTheme() from hooks/use-theme.ts returns { theme, cycleTheme } for UI controls; the re-export from components/theme-provider.tsx returns raw next-themes { theme, setTheme, … } with no cycle logic.

Recoloring with @repo/styles

Brand color lives in design tokens, never in component code.

  • tooling/styles/theme.css ships the Tailwind v4 shadcn tokens (--background, --primary, --accent, --border, --ring, --radius, …) as oklch(...) values under :root.
  • Override the same tokens inside the .dark block for dark mode; the dark custom variant is declared in globals.css.
  • Every shadcn component consumes bg-primary, text-muted-foreground, and friends, so one token edit recolors the whole app.
  • --primary-ink is the brand accent for TEXT on a light surface. A brand --primary bright enough for buttons is often too light for accent words, links and labels, so set --primary-ink to a darker step of the same hue that reaches 4.5:1 on --background. In the .dark block it usually equals --primary. Fills, borders and rings keep using --primary.

Background effects

config.theme.background selects the backdrop that <ThemeBackground> renders on the marketing, onboarding, clean, and error layouts. An unset or unknown value renders nothing.

ValueBehaviorPrerendered
staticLightRaysStatic light-ray backdrop, half strength in light mode (default)Yes
lightRaysAnimated rays, half strength in light mode, loaded as a dynamic(..., { ssr: false }) client chunk so motion never ships unless usedNo
radialGradientSoft radial gradientYes

On this page