Theming

SaaSForge Core uses Tailwind CSS v4 with CSS custom properties in the OKLCH color space. Light and dark palettes both ship out of the box, switching is handled by next-themes. Every color you see on screen ultimately resolves to a variable in src/app/globals.css.

This guide covers: how the theme is wired, how to change colors, swap fonts, tune dark mode, and verify your changes.

How the theme is wired

Three files do the work:

FileRole
src/app/globals.cssDefines all CSS variables for :root (light) and .dark (dark)
src/components/theme/ThemeProvider (wraps the app) + theme toggle button
src/config/ui/branding.tsLogo paths and theme defaults (default mode, toggle visibility)

There is no tailwind.config.js: Tailwind v4 reads everything from CSS via @import "tailwindcss" and @plugin directives at the top of globals.css.

The OKLCH color space

Every color is declared as oklch(L C H):

  • L (Lightness): 0–1 (0 = black, 1 = white)
  • C (Chroma / saturation): 0 to ~0.4
  • H (Hue): 0–360 degrees

OKLCH gives perceptually-uniform colors: equal lightness values look equally bright across hues, which keeps button/text contrast consistent when you swap palettes. Use oklch.com or Huetone to pick palettes.

Variables you'll edit most

Inside :root and .dark blocks of src/app/globals.css:

--background          --foreground          /* page background + body text */
--card                --card-foreground     /* cards, modals, popovers */
--popover             --popover-foreground
--primary             --primary-foreground  /* primary buttons, links */
--secondary           --secondary-foreground
--muted               --muted-foreground    /* disabled, placeholder text */
--accent              --accent-foreground   /* hover/active highlights */
--destructive         --destructive-foreground
--border              --input               --ring   /* form chrome */
--sidebar-*                                  /* dashboard sidebar palette */
--chart-1 .. --chart-5                       /* dashboard chart series */
--font-sans           --font-serif          --font-mono
--radius                                     /* global border radius */
--shadow-2xs .. --shadow-2xl                 /* elevation system */

Changing the brand palette

The shipped palette is warm terracotta/amber. To change to, for example, a blue palette:

  1. Pick or generate a hue with oklch.com. For blue, hue ≈ 240.
  2. Update light mode :root in src/app/globals.css:
    --primary:        oklch(0.55 0.20 240);
    --ring:           oklch(0.55 0.20 240);
    --sidebar-primary: oklch(0.55 0.20 240);
    --sidebar-ring:    oklch(0.55 0.20 240);
    --chart-1:         oklch(0.55 0.20 240);
    --accent-foreground: oklch(0.40 0.18 240);
    
  3. Match dark mode .dark (slightly higher L for contrast on dark backgrounds):
    --primary:         oklch(0.65 0.20 240);
    --ring:            oklch(0.65 0.20 240);
    --sidebar-primary: oklch(0.65 0.20 240);
    --sidebar-ring:    oklch(0.65 0.20 240);
    --chart-1:         oklch(0.65 0.20 240);
    
  4. Save → hot reload → check both light and dark.

For a multi-color palette (chart series, secondary, accent), repeat the same approach with related hues. Keep L values within ±0.1 of the originals for the existing depth/contrast to hold.

One-shot generators

For a consistent full palette, use a generator:

  • tweakcn.com: visual editor for shadcn/ui themes, exports CSS-vars block ready to paste.
  • shadcn theme generator: official, OKLCH output supported in newer releases.

Swapping fonts

Three font slots exist:

--font-sans:  Poppins, sans-serif;
--font-serif: "Libre Baskerville", serif;
--font-mono:  "IBM Plex Mono", monospace;

To replace, e.g., the sans font:

  1. In src/app/layout.tsx change the next/font import:
    import { Inter } from "next/font/google";
    const sans = Inter({ subsets: ["latin"], variable: "--font-sans" });
    
  2. Apply the variable on <html>:
    <html lang="en" className={sans.variable}>
    
  3. Remove the matching line in globals.css (the variable is now set by the loader) or update to:
    --font-sans: var(--font-sans), sans-serif;
    

Verify by inspecting <body>: the font-family should resolve to the new font.

Dark mode

Dark mode uses next-themes with the class strategy: the provider toggles a .dark class on <html>, and the .dark { ... } block in globals.css overrides every variable.

Default theme

src/components/theme/ThemeProvider.tsx configures defaultTheme="system" (respects OS preference). Change to "light" or "dark" to lock new visitors to a default before they toggle.

Hiding the toggle

In src/config/ui/branding.ts, set themeConfig.showToggle: false to remove the toggle from the header.

Adding a custom variant

Tailwind v4 lets you define extra variants alongside dark. E.g., a high-contrast mode:

/* globals.css */
@variant high-contrast (&:where(.high-contrast, .high-contrast *));

.high-contrast {
  --foreground: oklch(0 0 0);
  --background: oklch(1 0 0);
  /* ... */
}

Then add <html className="high-contrast"> (or toggle via JS) to opt in.

Border radius and shadows

--radius: 0.375rem controls the global rounding. Most components use multiples of it (rounded-md, rounded-lg). Lower it for sharper UI, raise it for a softer brand.

Shadow tokens (--shadow-2xs through --shadow-2xl) ship as multi-layer offsets keyed off the brand hue: keep the structure, swap the OKLCH color to match new palettes for cohesive elevation.

Verification checklist

After any theme change, click through these pages and toggle dark mode on each:

  • / (marketing landing)
  • /pricing
  • /sign-in, /sign-up
  • /dashboard (after login): check chart colors render correctly
  • /dashboard/products: table chrome, badges, dropdowns
  • /dashboard/settings: form inputs, switches

Watch for: low-contrast text, badge-on-card readability, focus rings still visible, charts using your new palette. Also check the favicon background against the new primary color.

Related guides