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

IdCharacterRadius
defaultNeutral slate, blue accent0.5rem
inkHigh contrast, green as a signal0.75rem
paperWarm off-white and terracotta0.375rem
monoNo chroma, no rounding0
violetCool indigo, soft surfaces1rem
sunsetAmber and rose on cream0.875rem
forestDesaturated greens, dense0.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, .dark on <html>.
  • -- Which themedata-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 THEMES in src/lib/themes.ts so 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.