Branding & Theming
Recolor the entire store with brand tokens, presets, dark mode, and a runtime theme switcher.
The storefront's color identity flows from four CSS custom properties — the --brand* tokens. Change them in one place and every button, heading, badge, and highlight that reads a brand utility re-colors to match. (Transactional emails are not themed: they carry their own inline colors, because a mail client has no access to your stylesheet.)
The --brand* tokens
Four tokens define the entire brand palette. They live in src/app/globals.css and are declared as space-separated HSL triplets (H S% L%) so they can be dropped straight into hsl(var(--brand)) without conversion.
:root {
/* Brand theming tokens (space-separated HSL). Recolor the whole store
by editing these four values, or by applying a preset's triplets from
siteConfig.theme.presets to the --brand* variables. */
--brand: 226 57% 21%;
--brand-foreground: 210 40% 98%;
--brand-accent: 24.6 95% 53.1%;
--brand-accent-foreground: 0 0% 100%;
}| Token | Role | Default (light) |
|---|---|---|
--brand | Deep brand color — headings, dark surfaces, primary brand buttons | 226 57% 21% (deep navy) |
--brand-foreground | Readable text/icon color on top of --brand | 210 40% 98% |
--brand-accent | Vivid accent — primary CTA buttons and highlights | 24.6 95% 53.1% (vivid orange) |
--brand-accent-foreground | Readable text/icon color on top of --brand-accent | 0 0% 100% |
These tokens are wired into Tailwind as two color families in tailwind.config.ts, so you use them as ordinary utility classes:
brand: {
DEFAULT: "hsl(var(--brand))",
foreground: "hsl(var(--brand-foreground))",
},
"brand-accent": {
DEFAULT: "hsl(var(--brand-accent))",
foreground: "hsl(var(--brand-accent-foreground))",
},That means classes like bg-brand, text-brand-foreground, bg-brand-accent, and text-brand-accent-foreground all resolve back to the tokens above. Recolor the tokens and every one of those utilities updates.
The tokens hold only the color channels (H S% L%), not a full hsl(...) call. Always consume them wrapped, e.g. hsl(var(--brand)) or via the brand / brand-accent Tailwind classes.
Recoloring the store
Edit the four --brand* values directly in src/app/globals.css. Set them under :root for light mode and under .dark for dark mode. This is the most direct change and needs no other wiring.
:root {
--brand: 340 82% 30%;
--brand-foreground: 0 0% 100%;
--brand-accent: 340 82% 52%;
--brand-accent-foreground: 0 0% 100%;
}Because dark mode reads the same tokens, restyling is a one-place change — you never have to hunt through components.
Theme presets
The boilerplate ships three ready-made palettes in siteConfig.theme in src/config/site.ts. Each preset is a ThemePreset whose four fields map 1:1 onto the --brand* tokens.
theme: {
/** Active preset name — informational; the source of truth is globals.css. */
active: "default",
presets: {
/** Ships out of the box: deep navy brand + vivid orange accent. */
default: {
brand: "226 57% 21%",
brandForeground: "210 40% 98%",
brandAccent: "24.6 95% 53.1%",
brandAccentForeground: "0 0% 100%",
},
/** Green-forward alternative. */
emerald: {
brand: "160 84% 20%",
brandForeground: "150 40% 98%",
brandAccent: "160 84% 39%",
brandAccentForeground: "0 0% 100%",
},
/** Purple-forward alternative. */
violet: {
brand: "263 60% 26%",
brandForeground: "270 40% 98%",
brandAccent: "262 83% 58%",
brandAccentForeground: "0 0% 100%",
},
} satisfies Record<string, ThemePreset>,
},| Preset | Brand | Accent | Character |
|---|---|---|---|
default | 226 57% 21% | 24.6 95% 53.1% | Deep navy + vivid orange (out of the box) |
emerald | 160 84% 20% | 160 84% 39% | Green-forward |
violet | 263 60% 26% | 262 83% 58% | Purple-forward |
The ThemePreset fields map onto tokens as follows:
| Preset field | CSS token |
|---|---|
brand | --brand |
brandForeground | --brand-foreground |
brandAccent | --brand-accent |
brandAccentForeground | --brand-accent-foreground |
Always check your accent color's contrast against its foreground for WCAG AA (4.5:1 for normal text, 3:1 for large text and UI components) before shipping a custom palette. A vivid accent used for CTA button text can easily fall short — verify --brand-accent against --brand-accent-foreground with a contrast checker.
How the active preset is applied (no flash, no dynamic render)
ThemeStyle renders in the root layout at src/app/[locale]/layout.tsx:
{/* Injects the active brand preset's --brand* CSS variables. */}
<ThemeStyle />It emits two things, and neither depends on who is asking:
- Every preset, as attribute-scoped CSS —
:root[data-theme="crimson"] { … }for each one. - A pre-paint script that reads the
themecookie and setsdata-themeon<html>.
So the browser has all the palettes before it paints, and one attribute picks which applies. No flash of the wrong brand color, and — because the markup is byte-identical for every visitor — no per-request work.
This used to read the cookie during the server render and inject only the
active preset. It worked, but a cookies() call in the root layout makes
every route beneath it render per request, including pages with no
personalisation at all. Moving the choice into CSS removed one of the two
reasons the storefront could not be statically rendered (the other is
currency — see ROADMAP.md).
Emitting the CSS
presetStylesheet() (in src/lib/theme/presets.ts) walks the configured
presets and serializes each into the four --brand* declarations:
export function presetStylesheet(): string {
return Object.entries(siteConfig.theme.presets)
.filter(([name]) => name !== DEFAULT_PRESET_NAME)
.map(([name, preset]) => {
const vars = presetToCssVars(preset as ThemePreset);
const scope = `:root[data-theme="${name}"]`;
return `${scope}, ${scope} .dark { ${vars} }`;
})
.join("\n");
}Three deliberate behaviors:
defaultemits nothing. It has no rule and no attribute, so the tuned per-mode--brand*values inglobals.cssare left completely untouched. The out-of-the-box look is unchanged when no cookie is set.- Non-default presets recolor both modes. Each writes its scope and
scope .dark, so a chosen preset cannot be silently overridden by the.darkblock inglobals.css. Presets carry a single palette, so both modes share the brand colors while every other token keeps its per-mode value. - Switching is one attribute write.
ThemeSwitchersetsdata-themedirectly instead of callingrouter.refresh()— every palette is already on the page, so the change is instant and needs no server round trip.
Because the emitted CSS and script only ever contain config-defined values —
HSL triplets and preset names, never user input — they are safe to inline via
dangerouslySetInnerHTML.
Dark mode
Dark mode is class-based. Tailwind is configured with:
darkMode: "class",so dark styles apply whenever an ancestor carries the .dark class. The dark palette is defined alongside the light one in globals.css, including brand tokens tuned for a dark background:
.dark {
/* Brand tokens for dark mode: the deep-blue brand lightens so headings
and dark surfaces stay legible on a dark background; the accent keeps
its vivid hue. */
--brand: 210 40% 90%;
--brand-foreground: 222.2 47.4% 11.2%;
--brand-accent: 24.6 95% 53.1%;
--brand-accent-foreground: 0 0% 100%;
}Note how the default dark palette lightens --brand (from 226 57% 21% to 210 40% 90%) so headings and dark surfaces stay legible, while --brand-accent keeps its vivid hue. This per-mode tuning only applies for the default preset — as noted above, a non-default preset intentionally writes the same brand colors to both :root and .dark.
The two scopes that already own the palette
Nothing in the app puts .dark on <html>; there is no light/dark toggle. The
shipped look comes from two token scopes further down the tree, and each wins for
the elements inside it — a custom property set on a nearer ancestor is simply
what descendants inherit:
| Scope | Set on | What it redefines |
|---|---|---|
.storefront | the storefront wrapper in src/app/[locale]/layout.tsx | The whole token set, re-tuned to a near-black indigo ground, plus the glass and aurora variables. Of the brand tokens it sets only --brand and --brand-foreground. |
.theme-light | nothing, today | The full light token set, including all four --brand* values. Kept for a subtree you want to lock light; the admin used to be the one caller. |
The admin runs on .storefront too, so the back office reads the same dark
tokens as the shop it manages — its shell and stat tiles are glass, and its
tables are the flat --card surface, because a frosted panel behind a dense
table costs legibility and repaints on every scroll.
The consequence is worth knowing before you ship a preset: on the storefront —
and therefore in the admin — a preset changes the accent (the scope
deliberately leaves --brand-accent alone and lightens --brand itself for
legibility). Recoloring the deep brand means editing .storefront's own
--brand in globals.css, not only the preset.
darkMode: "class" and the .dark palette are still wired, so a light/dark
toggle is a small addition (a client toggle, or next-themes, putting .dark
on <html>) rather than a rewrite — but it only affects surfaces outside those
two scopes until you also relax them.
Runtime theme switcher
ThemeSwitcher (in src/components/theme/ThemeSwitcher.tsx) is a client component that lets a shopper or admin pick a preset from a dropdown. The choice is persisted in the theme cookie and applied live.
Its flow:
The user picks a preset from the dropdown (built from presetNames, i.e. every key in siteConfig.theme.presets).
writeThemeCookie() stores the choice in the theme cookie, scoped to the whole site (path=/, max-age of one year, samesite=lax).
applyPreset() sets data-theme on <html> (or removes it for default, handing control back to globals.css). Every preset's CSS is already on the page, so the palette changes on the next paint with no server round trip. It used to call router.refresh() — which is also what kept the whole route tree dynamic.
function handleSelect(value: string): void {
const next = resolvePresetName(value);
writeThemeCookie(next);
setCurrent(next);
applyPreset(next);
}The component accepts an optional active?: PresetName prop (skips the first-paint cookie read when the parent already knows the answer) and a className for the trigger button, and renders a Palette icon button that opens a radio-group dropdown. The cookie is read with useSyncExternalStore rather than an effect, so the server snapshot and the client snapshot agree and hydration does not flash the default.
ThemeSwitcher ships wired into the storefront header: PreferencesMenu (src/components/preferences/PreferencesMenu.tsx) renders it alongside the locale and currency switchers, and MenuOverlay (src/components/header/MenuOverlay.tsx) repeats the set at the foot of the full-screen menu. Drop it anywhere else you want a theme control — it needs no provider.
Related helpers
These live in src/lib/theme/presets.ts and are safe to import from both server and client code:
| Export | Purpose |
|---|---|
THEME_COOKIE | Name of the cookie that persists the chosen preset ("theme"). |
DEFAULT_PRESET_NAME | The default preset name ("default"). |
PresetName | Union type of valid preset names, e.g. "default" | "emerald" | "violet". |
presetNames | All preset names in declaration order, for building switcher UIs. |
presetToCssVars(preset) | Serializes a preset into the four --brand* declarations. |
resolvePresetName(name) | Validates an arbitrary name, falling back to DEFAULT_PRESET_NAME. |
presetStylesheet() | Every non-default preset as an attribute-scoped rule, for ThemeStyle to inline. |
Adding a new preset
Add the preset to siteConfig.theme.presets in src/config/site.ts. Give it a key and fill in the four HSL triplets. The key becomes a valid PresetName automatically, so it appears in the switcher and passes validation.
presets: {
// ...existing presets
crimson: {
brand: "347 77% 30%",
brandForeground: "0 0% 100%",
brandAccent: "347 89% 54%",
brandAccentForeground: "0 0% 100%",
},
},To make it the shipped default, paste its four triplets over the --brand*
values in src/app/globals.css (:root, .dark, and the .storefront scope,
which is what both the shop and the admin render on). Setting
siteConfig.theme.active is
documentation, not wiring: with no theme cookie the browser sets no
data-theme, so globals.css is what a first-time visitor sees.
Check contrast. Verify brandAccent against brandAccentForeground (and brand against brandForeground) meets WCAG AA before shipping.
No component edits: presetNames, PresetName, resolvePresetName, and the switcher UI all derive from siteConfig.theme.presets, so a new preset is picked up everywhere automatically.