.dev
Storefront templates

Theming

Dark, light and system modes on CSS tokens — brand color conversion, the corner radius system, and the design tokens custom components must use.

One custom component has to look right on every store (each with its own brand colors) in both color modes. That works because themes never hardcode colors: Lithium converts the store's brand into CSS variables, flips them under a .dark class, and your code only ever references the tokens.

Three layers

1. Template default    data.props.theme.mode   → "light" | "dark" | "system"
2. Runtime class       next-themes             → html class="dark" (or not)
3. CSS tokens          :root / .dark variables → hsl(var(--...))
LayerWho sets itEffect
props.theme.modeTheme author / merchantDefault mode when the store loads
useTheme()Visitor (or a toggle in your JSX)Session override to light / dark / system
CSS variablesLithiumThe actual colors — .dark swaps surfaces and brand

The template default lives in the kit's root props.json:

{
  "theme": {
    "mode": "light",
    "rounded": 100,
    "font": "ibm_plex_sans_arabic"
  }
}

Options are "light", "dark", "system". The default only seeds first-time visitors — once a shopper has toggled a preference (stored by next-themes), their choice wins on later visits.

How brand colors become tokens

Merchants pick brand colors in the store settings, stored as Flutter color integers (0xAARRGGBB) on store.decoration. On every page Lithium converts them:

store.decoration.primary   → HSL channels → :root { --primary: h, s%, l% }
store.decoration.onPrimary →             → :root { --primary-foreground: ... }
secondary / onSecondary    →             → --secondary / --secondary-foreground

Dark variants are derived, not designed: each brand color is passed through an inversion that keeps contrast — near-black colors become pure white, near-white become pure black, everything else flips lightness with a slight saturation boost — and written under .dark { --primary: ... }.

Two consequences for your code:

  1. Never read store.decoration.primary directly — it is a raw Flutter int, not a CSS color. If a color int arrives through your own props, convert it with the dartColorToCssColor scope helper.
  2. Never invent a parallel dark palette — write against the tokens and dark mode is free.

Token cheat sheet

Tokens store bare HSL channels, so always wrap them in hsl(...):

TokenUse
--background / --foregroundPage surfaces and body text
--card / --card-foregroundCards, panels
--popover / --popover-foregroundMenus, dropdowns
--muted / --muted-foregroundSoft fills, secondary text
--accent / --accent-foregroundHover fills
--border / --input / --ringBorders, input outlines, focus rings
--primary / --primary-foregroundBrand CTAs (from store decoration)
--secondary / --secondary-foregroundSecondary brand
--destructive / --destructive-foregroundErrors, danger
--corners-*, --roundedRadii (mode-independent, see below)
.my-block {
  background: hsl(var(--card));
  color: hsl(var(--card-foreground));
  border: 1px solid hsl(var(--border));
  border-radius: var(--corners-card);
}

A softer border that still tracks the mode:

border: 1px solid color-mix(in srgb, hsl(var(--foreground)) 12%, transparent);

Corner radius system

Merchants control roundness without touching code, through two root props:

PropEmitted asMeaning
props.corners.card--corners-card (px)Cards, panels — default 4
props.corners.fields--corners-fields (px)Inputs — default 4
props.corners.buttons.large/medium/small--corners-buttons-large/-medium/-small (px)Buttons — defaults 16 / 12 / 8
props.theme.rounded (0–100)--rounded (unitless, value ÷ 100)Global multiplier on the Tailwind radius scale used by built-ins

Custom components should size radii with var(--corners-card), var(--corners-fields) and var(--corners-buttons-*) so one merchant slider restyles the whole theme consistently.

Toggling from custom JSX

useTheme() (next-themes) is injected into the custom component scope — no import:

function App() {
  const { resolvedTheme, setTheme } = useTheme();
  const isDark = resolvedTheme === "dark";

  return (
    <button
      type="button"
      aria-label="Toggle theme"
      onClick={() => setTheme(isDark ? "light" : "dark")}
      style={{
        color: "hsl(var(--foreground))",
        background: "hsl(var(--muted))",
        borderRadius: "var(--corners-buttons-medium)",
      }}
    >
      {isDark ? "Light" : "Dark"}
    </button>
  );
}
FieldMeaning
themeStored preference: light / dark / system
resolvedThemeWhat is actually painted: light or dark
setTheme(mode)Set the preference
systemThemeOS preference when theme is system

Branch UI on resolvedTheme (icons, illustrations); for colors prefer CSS variables over JS conditionals — they update without a re-render.

Tailwind dark: utilities are unreliable inside react-live custom components (the class pipeline does not see runtime code strings). Native registry components use them freely; in custom JSX, style with hsl(var(--...)) tokens or a small style block instead.

The theme's own design system

Tokens answer "which color"; they don't answer "how bold, how fast, how rounded should this theme feel". Every theme package ships a design-system.md at its root — the theme's written visual contract: brand context, foundations (type, spacing, elevation), motion budget, component states, order-form chrome, dark/light mapping, currency display, RTL rules, and theme-specific do/don'ts. Contributors (and AI assistants) read it before any visual change, and marketplace review expects it to be filled in — see theme conventions.

Pitfalls

BadGood
color: store.decoration.primaryhsl(var(--primary))
Hardcoded #fff / #000 for surfaceshsl(var(--background)) / hsl(var(--foreground))
A hand-made "dark palette" table in the themeRide the native tokens — dark is derived
Conditional brand hex in JS for dark CTAsKeep --primary; branch only for assets (logos, images)
Fixed border-radius: 12px everywherevar(--corners-*) so merchants can restyle
Mode stored in a page or component propRoot props.theme.mode; session changes via setTheme

Checklist

  • Surfaces and text on --background / --foreground (or card / muted variants)
  • Brand CTAs on --primary / --primary-foreground
  • Radii on --corners-*
  • Toggle (if any) built on useTheme() + resolvedTheme
  • Template default set in props.theme.mode when the theme should open dark or system
  • Both modes previewed before publishing (draft preview + setTheme("dark"))

On this page