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(--...))| Layer | Who sets it | Effect |
|---|---|---|
props.theme.mode | Theme author / merchant | Default mode when the store loads |
useTheme() | Visitor (or a toggle in your JSX) | Session override to light / dark / system |
| CSS variables | Lithium | The 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-foregroundDark 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:
- Never read
store.decoration.primarydirectly — it is a raw Flutter int, not a CSS color. If a color int arrives through your own props, convert it with thedartColorToCssColorscope helper. - 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(...):
| Token | Use |
|---|---|
--background / --foreground | Page surfaces and body text |
--card / --card-foreground | Cards, panels |
--popover / --popover-foreground | Menus, dropdowns |
--muted / --muted-foreground | Soft fills, secondary text |
--accent / --accent-foreground | Hover fills |
--border / --input / --ring | Borders, input outlines, focus rings |
--primary / --primary-foreground | Brand CTAs (from store decoration) |
--secondary / --secondary-foreground | Secondary brand |
--destructive / --destructive-foreground | Errors, danger |
--corners-*, --rounded | Radii (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:
| Prop | Emitted as | Meaning |
|---|---|---|
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>
);
}| Field | Meaning |
|---|---|
theme | Stored preference: light / dark / system |
resolvedTheme | What is actually painted: light or dark |
setTheme(mode) | Set the preference |
systemTheme | OS 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
| Bad | Good |
|---|---|
color: store.decoration.primary | hsl(var(--primary)) |
Hardcoded #fff / #000 for surfaces | hsl(var(--background)) / hsl(var(--foreground)) |
| A hand-made "dark palette" table in the theme | Ride the native tokens — dark is derived |
| Conditional brand hex in JS for dark CTAs | Keep --primary; branch only for assets (logos, images) |
Fixed border-radius: 12px everywhere | var(--corners-*) so merchants can restyle |
| Mode stored in a page or component prop | Root 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.modewhen the theme should open dark or system - Both modes previewed before publishing (draft preview +
setTheme("dark"))