Acme Store Docs
Configuration

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 across the store, admin, and emails re-colors to match. You can edit the tokens directly, swap in one of the shipped presets, or let shoppers/admins pick a preset at runtime with a cookie-backed switcher.

This page explains how the token system works, the presets that ship out of the box, how the active preset is injected server-side (with no flash of the wrong color), how dark mode fits in, and how to add your own preset.

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.

src/app/globals.css
: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%;
}
TokenRoleDefault (light)
--brandDeep brand color — headings, dark surfaces, primary brand buttons226 57% 21% (deep navy)
--brand-foregroundReadable text/icon color on top of --brand210 40% 98%
--brand-accentVivid accent — primary CTA buttons and highlights24.6 95% 53.1% (vivid orange)
--brand-accent-foregroundReadable text/icon color on top of --brand-accent0 0% 100%

These tokens are wired into Tailwind as two color families in tailwind.config.ts, so you use them as ordinary utility classes:

tailwind.config.ts
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

There are three ways to change the brand palette, from simplest to most dynamic.

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.

src/app/globals.css
:root {
  --brand: 340 82% 30%;
  --brand-foreground: 0 0% 100%;
  --brand-accent: 340 82% 52%;
  --brand-accent-foreground: 0 0% 100%;
}

Copy one of the presets from siteConfig.theme.presets onto the --brand* variables. Set siteConfig.theme.active to that preset name so the server injects it, or paste the preset's triplets straight into globals.css. See Theme presets below.

Ship the runtime theme switcher so shoppers or admins pick a preset live. Their choice is stored in the theme cookie and injected on every server render.

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.

src/config/site.ts
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>,
},
PresetBrandAccentCharacter
default226 57% 21%24.6 95% 53.1%Deep navy + vivid orange (out of the box)
emerald160 84% 20%160 84% 39%Green-forward
violet263 60% 26%262 83% 58%Purple-forward

The ThemePreset fields map onto tokens as follows:

Preset fieldCSS 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 injected (no flash)

The active preset is applied server-side by the ThemeStyle Server Component, which renders in the root layout at src/app/[locale]/layout.tsx:

src/app/[locale]/layout.tsx
{/* Injects the active brand preset's --brand* CSS variables. */}
<ThemeStyle />

Because the <style> is emitted during server rendering — before any HTML reaches the browser — there is no flash of the wrong brand color on first paint.

Resolving the active preset

On each request, getActivePresetName() (server-only, in src/lib/theme/server.ts) reads the theme cookie, validates it against the configured presets, and falls back to siteConfig.theme.active when the cookie is missing or invalid:

src/lib/theme/server.ts
export function getActivePresetName(): PresetName {
  const cookieValue = cookies().get(THEME_COOKIE)?.value;
  if (cookieValue != null) {
    return resolvePresetName(cookieValue);
  }
  return resolvePresetName(siteConfig.theme.active);
}

resolvePresetName() (in src/lib/theme/presets.ts) guards against unknown/untrusted names, returning DEFAULT_PRESET_NAME ("default") for anything not registered in siteConfig.theme.presets.

Emitting the CSS variables

ThemeStyle then serializes the resolved preset into the four --brand* declarations via presetToCssVars() and inlines them:

src/components/theme/ThemeStyle.tsx
export function ThemeStyle() {
  const active = getActivePresetName();

  // Default preset => defer entirely to globals.css (per-mode brand tokens).
  if (active === DEFAULT_PRESET_NAME) {
    return null;
  }

  const vars = presetToCssVars(siteConfig.theme.presets[active]);
  const css = `:root { ${vars} } .dark { ${vars} }`;

  return (
    <style
      dangerouslySetInnerHTML={{ __html: css }}
      data-theme-preset={active}
    />
  );
}

Two deliberate behaviors:

  • default injects nothing. When the active preset is default, ThemeStyle returns null, leaving the carefully tuned per-mode --brand* values in globals.css completely untouched. The out-of-the-box look is unchanged when no cookie is set.
  • Non-default presets recolor both modes. The preset's triplets are written to both :root and .dark, so a chosen preset can't be silently overridden by the .dark block in globals.css. Presets carry a single palette (no separate dark variant), so both modes share the same brand colors while every other token keeps its per-mode value.

Because the injected CSS only ever contains static, config-defined HSL triplets (never user input), it is safe to inline via dangerouslySetInnerHTML.

Dark mode

Dark mode is class-based. Tailwind is configured with:

tailwind.config.ts
darkMode: ["class"],

so dark styles apply whenever an ancestor (typically <html>) carries the .dark class. The dark palette is defined alongside the light one in globals.css, including brand tokens tuned for a dark background:

src/app/globals.css
.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 boilerplate defines the .dark palette but does not ship a light/dark toggle wired to <html>. To enable dark mode, add the .dark class to the <html> element (e.g. via a small client toggle or next-themes). Once .dark is present, all --brand* and other tokens switch automatically.

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).

It calls router.refresh(), which re-runs the server tree so ThemeStyle re-reads the cookie and emits the newly chosen preset's --brand* variables. The palette updates live — no full reload.

src/components/theme/ThemeSwitcher.tsx
function handleSelect(value: string): void {
  const next = resolvePresetName(value);
  writeThemeCookie(next);
  setCurrent(next);
  // Re-render the server tree so <ThemeStyle/> emits the new preset.
  router.refresh();
}

The component accepts an optional active?: PresetName prop (to avoid a cookie read on first paint when the parent already knows the active preset via getActivePresetName()), plus a className for the trigger button. It renders a Palette icon button that opens a radio-group dropdown.

ThemeSwitcher is intentionally not wired into the header — it's exported for you to place wherever a theme control belongs (header actions, an admin settings screen, and so on).

These live in src/lib/theme/presets.ts and are safe to import from both server and client code:

ExportPurpose
THEME_COOKIEName of the cookie that persists the chosen preset ("theme").
DEFAULT_PRESET_NAMEThe default preset name ("default").
PresetNameUnion type of valid preset names, e.g. "default" | "emerald" | "violet".
presetNamesAll 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.
getActivePresetName()(In server.ts) Resolves the active preset for the current request.

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.

src/config/site.ts
presets: {
  // ...existing presets
  crimson: {
    brand: "347 77% 30%",
    brandForeground: "0 0% 100%",
    brandAccent: "347 89% 54%",
    brandAccentForeground: "0 0% 100%",
  },
},

(Optional) Make it the default by setting siteConfig.theme.active to your new key. The server will inject it on requests with no theme cookie.

src/config/site.ts
theme: {
  active: "crimson",
  // ...
}

Check contrast. Verify brandAccent against brandAccentForeground (and brand against brandForeground) meets WCAG AA before shipping.

That's the whole change — 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.

On this page