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:
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:
: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.
| Token | Default (light) | Effect |
|---|---|---|
--primary | oklch(0.205 0 0) (near-black) | Primary buttons, active tabs, links, bg-primary/… tints |
--primary-hover | oklch(0.145 0 0) | Primary button hover state |
--primary-foreground | oklch(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 |
--radius | 0.625rem (10px) | Master radius — cascades to the full scale (see below) |
--font-geist-sans | System UI stack | App UI font |
--font-geist-mono | System mono stack | Code/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:
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.
| Surface | Why 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-foreground | Selection contract: Checkbox, Radio, Switch ON state and native accent-color. Deliberately not --primary so selected controls stay distinguishable from primary actions. |
--z-index-* scale | Cross-component layering guarantees. Changing any value desynchronizes portaled components (tooltips, popovers, modals, selects). |
--shadow-focus anatomy | Dual-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:
-
@theme inlineindirection. Tailwind v4 utilities compile against--color-primary: var(--primary)etc. A runtime CSS custom property override in any later-loaded stylesheet retints compiled utilities. -
Cascade ordering.
theme.cssloads afterglobals.css, so its:rootand.darkdeclarations win on specificity ties. This is why the import order inlayout.tsxmatters.
Next steps
- Run
npx saasframe theme init --helpfor all CLI options (radius, font, secondary color). - See the Design System Rules for the full token reference.