Skip to main content

Brand your app with custom colors and fonts

Every color, font, and border-radius in the UI resolves through CSS custom properties defined in globals.css. Tailwind v4's @theme inline block maps utilities onto those properties at runtime, so a plain CSS override in a later-loaded stylesheet retints the entire UI without recompiling Tailwind.

The theme.css convention

Your app ships with a src/app/theme.css file imported after globals.css in the root layout:

src/app/layout.tsx
import './globals.css' // framework-owned — never edit
import './theme.css' // your brand overrides

globals.css is framework-owned and may be rewritten by upgrades. theme.css is yours — overrides here survive every framework update.

Quick start: change the primary color

Add this to theme.css:

src/app/theme.css
:root {
--primary: oklch(0.45 0.2 250);
--primary-hover: oklch(0.38 0.2 250);
--primary-foreground: oklch(0.985 0 0);
}

.dark {
--primary: oklch(0.7 0.18 250);
--primary-hover: oklch(0.75 0.18 250);
--primary-foreground: oklch(0.15 0.02 250);
}

That retints every primary button, active tab, link, and bg-primary/… tint across the entire app — light and dark mode.

Or use the CLI

npx saasframe theme init --primary="#2563eb"

The CLI derives hover, foreground, and dark-mode variants automatically and validates WCAG 2.1 contrast (hard failure below 4.5:1 for text-on-primary).

Safe-to-override tokens (identity)

These tokens express your brand identity. Override them freely in theme.css.

TokenDefault (light)Effect
--primaryoklch(0.205 0 0) (near-black)Primary buttons, active tabs, links, bg-primary/… tints
--primary-hoveroklch(0.145 0 0)Primary button hover state
--primary-foregroundoklch(0.985 0 0) (near-white)Text and icons on primary surfaces
--brand-indigo#4f46e5 (dark: #6366f1)Primary brand accent (hero, gradient, marketing)
--brand-cyan#06b6d4 (dark: #22d3ee)Secondary brand accent
--brand-violet#BC9AFF (theme-invariant)AI touchpoints, custom-view pills
--radius0.625rem (10px)Master radius — cascades to the full scale (see below)
--font-geist-sansSystem UI stackApp UI font
--font-geist-monoSystem mono stackCode/technical content font

Radius cascade

@theme inline derives the full scale from the single --radius knob:

--radius-sm = calc(var(--radius) - 4px)
--radius-md = calc(var(--radius) - 2px)
--radius-lg = var(--radius)
--radius-xl = calc(var(--radius) + 6px)

Override --radius alone and every rounded-sm/md/lg/xl re-rounds consistently. Values below 4px clamp --radius-sm to zero — the CLI warns below 0.25rem.

Font overrides

Overriding the token switches the family; loading the actual webfont is your responsibility:

src/app/layout.tsx
import { Inter } from 'next/font/google'

const inter = Inter({
subsets: ['latin'],
variable: '--font-geist-sans',
})

Then set className={inter.variable} on the <html> tag. The loaded font's CSS variable feeds --font-geist-sans, which @theme inline maps to the font-sans utility.

Never override (semantic contracts)

These tokens are load-bearing — components, status badges, alerts, and the layering system depend on their values.

SurfaceWhy it is a contract
--status-{error,success,warning,info,neutral,pink}-{bg,text,border,icon}Semantic status roles consumed by Alert, StatusBadge, Toast, and StatusMap across every module. Recoloring "error" to a brand hue breaks the meaning, not just the look.
--accent-indigo / --accent-indigo-foregroundSelection contract: Checkbox, Radio, Switch ON state and native accent-color. Deliberately not --primary so selected controls stay distinguishable from primary actions.
--z-index-* scaleCross-component layering guarantees. Changing any value desynchronizes portaled components (tooltips, popovers, modals, selects).
--shadow-focus anatomyDual-ring focus recipe is an accessibility affordance. The --focus-ring-* inputs are calibrated per light/dark — do not override.
Social brand tokens (--social-*)Third-party brand colors for OAuth buttons. Must match the provider's actual brand guidelines.

How it works under the hood

Two mechanics make plain-CSS theming work without rebuilding Tailwind:

  1. @theme inline indirection. Tailwind v4 utilities compile against --color-primary: var(--primary) etc. A runtime CSS custom property override in any later-loaded stylesheet retints compiled utilities.

  2. Cascade ordering. theme.css loads after globals.css, so its :root and .dark declarations win on specificity ties. This is why the import order in layout.tsx matters.

Next steps

  • Run npx saasframe theme init --help for all CLI options (radius, font, secondary color).
  • See the Design System Rules for the full token reference.