Themes
Seven complete looks driven by one environment variable, because nothing hard-codes a colour.
Every surface reads CSS variables. A theme is a set of values for those variables, in light and dark, plus a corner radius. See them side by side at saas-starter.com/themes.html.
# The seven
| Id | Character | Radius |
|---|---|---|
default | Neutral slate, blue accent | 0.5rem |
ink | High contrast, green as a signal | 0.75rem |
paper | Warm off-white and terracotta | 0.375rem |
mono | No chroma, no rounding | 0 |
violet | Cool indigo, soft surfaces | 1rem |
sunset | Amber and rose on cream | 0.875rem |
forest | Desaturated greens, dense | 0.5rem |
# Switching
# .env NEXT_PUBLIC_THEME="forest"
That is the whole change. The dashboard, the admin area, the marketing pages and every component you later add from the shadcn registry all follow.
It is a build-time variable
NEXT_PUBLIC_ values are baked into the bundle. Changing the theme needs a rebuild, not a restart. An unknown value falls back to default rather than rendering unstyled.
# How it works
Two independent axes:
- -- Light / dark — next-themes, class strategy,
.darkon<html>. - -- Which theme —
data-theme="<name>"on<html>.
They are orthogonal on purpose: every theme has to work in both schemes.
[data-theme="ink"]:not(.dark) { --background: #ffffff; ... }
[data-theme="ink"].dark { --background: #0a0b0d; ... }
The specificity is the fiddly part
:root and .dark both score (0,1,0). A bare [data-theme="x"] scores the same and would win by source order — in dark mode too, which is wrong. Writing every block as :not(.dark) / .dark scores (0,2,0) and settles it whatever the order. Keep that shape.
Everything sits inside @layer base so Tailwind utilities still override it. A token declared outside a layer beats every utility class.
# Adding one
- -- Copy a block in
src/styles/themes.css, rename it, change the values. - -- Define every token in both schemes. A token missing from one block silently falls back to the default theme — that is how a theme ends up half-applied.
- -- Add the entry to
THEMESinsrc/lib/themes.tsso the picker and the docs know about it. - --
npm test.
The full token set every theme owes both schemes:
--background --foreground --card --card-foreground --popover --popover-foreground --primary --primary-foreground --secondary --secondary-foreground --muted --muted-foreground --accent --accent-foreground --destructive --destructive-foreground --border --input --ring --radius
# shadcn registry
The components in src/components/ui/ are hand-written on the shadcn/ui token contract, not generated by the shadcn CLI. What that buys you:
npx shadcn@latest add dialog # drops in already themed
Because globals.css defines the canonical token names and maps them in an @theme inline block onto the utility names registry components expect. Add a token to one block and you must map it in the other, or a registry component using it renders unstyled.
# Emails
Email clients do not support CSS variables. src/emails/_layout.tsx holds a brand object — the one place a literal colour is correct. Change it when you rebrand; preview with npm run email:dev.
# The checks
33 tests hold the registry and the stylesheet together:
- -- every registered theme has both colour schemes
- -- every block defines every token
- -- every theme sets its own radius
- -- the selectors use the specificity that survives dark mode
- -- no theme is declared in one place and not the other
A half-applied theme renders without erroring, which is exactly why it needs a test rather than a review.