We maintain a white-label dashboard that ships to a dozen customers, each wanting its own colors, a dark mode, and a layout that does not fall apart between a 13-inch laptop and a wall-mounted TV. Our first version had a Sass map per customer, compiled into separate stylesheets, plus a pile of media queries for font sizes. Every new customer meant a new build artifact, and every tweak to spacing meant touching six breakpoints.
We replaced most of it with features that every evergreen browser has supported for a while: custom properties, light-dark(), color-mix(), clamp(), min() and max(). This post walks through the structure we ended up with, why each piece is there, and the mistakes we made on the way.
Two layers of tokens: primitives and semantics
The single most useful decision was splitting tokens into two layers. Primitive tokens describe raw values and never appear in component CSS. Semantic tokens describe purpose and are the only thing components read.
:root {
/* primitives: raw palette, never used directly by components */
--blue-600: oklch(0.52 0.17 262);
--blue-100: oklch(0.94 0.03 262);
--gray-950: oklch(0.18 0.01 260);
--gray-50: oklch(0.98 0.005 260);
/* semantics: what components consume */
--color-accent: var(--blue-600);
--color-surface: var(--gray-50);
--color-text: var(--gray-950);
--radius-control: 0.5rem;
}
.button-primary {
background: var(--color-accent);
color: var(--color-surface);
border-radius: var(--radius-control);
}
A customer theme then overrides only the semantic layer, scoped to an attribute on the root element:
[data-theme="harbor"] {
--color-accent: oklch(0.55 0.13 200);
--radius-control: 0.25rem;
}
Because custom properties cascade and inherit like any other property, one attribute change on <html> re-themes the whole page with no rebuild. We load a single stylesheet for everyone and set the attribute server-side from the tenant record.
We use oklch() for primitives because lightness is perceptually even across hues, so a "600" in blue and a "600" in green look equally heavy. That makes contrast checks far more predictable when customers pick their own accent hue.
Dark mode with color-scheme and light-dark()
For dark mode we stopped duplicating every token under a .dark class. The light-dark() function returns its first argument when the computed color-scheme of the element is light and its second when it is dark:
:root {
color-scheme: light dark; /* follow the OS preference */
--color-surface: light-dark(var(--gray-50), var(--gray-950));
--color-text: light-dark(var(--gray-950), var(--gray-50));
--color-accent: light-dark(var(--blue-600), oklch(0.72 0.14 262));
}
/* user override from the settings menu */
[data-scheme="light"] { color-scheme: light; }
[data-scheme="dark"] { color-scheme: dark; }
color-scheme also switches native form controls and scrollbars, which a class-based approach never did for us. The user override simply pins color-scheme, and every light-dark() token follows.
For derived shades, color-mix() saves defining a dozen extra tokens:
:root {
--color-accent-hover: color-mix(in oklch, var(--color-accent), black 12%);
--color-accent-soft: color-mix(in oklch, var(--color-accent), transparent 85%);
}
This worked on the live site, where the theme attribute sits on <html>. In the theme editor preview, where the attribute sits on a wrapper div, the hover and soft variants ignored the new accent. That was our first bug, explained below.
Fluid type and spacing with clamp()
clamp(MIN, PREFERRED, MAX) picks the preferred value but never goes below the minimum or above the maximum. With a preferred value that mixes rem and viewport units, sizes scale smoothly between two widths instead of jumping at breakpoints.
The formula we use: pick a size at a small viewport and at a large viewport, then solve for a straight line.
- At 360px wide we want 1rem (16px) body text; at 1440px we want 1.25rem (20px).
- Slope = (20 - 16) / (1440 - 360) = 0.0037 per px, which is 0.37vw.
- Intercept = 16 - 0.0037 * 360 = 14.67px, which is about 0.917rem.
:root {
--step-0: clamp(1rem, 0.917rem + 0.37vw, 1.25rem);
--step-1: clamp(1.2rem, 1.07rem + 0.56vw, 1.56rem);
--step-2: clamp(1.44rem, 1.24rem + 0.86vw, 1.95rem);
--space-s: clamp(0.75rem, 0.69rem + 0.28vw, 1rem);
--space-m: clamp(1rem, 0.86rem + 0.56vw, 1.5rem);
--space-l: clamp(1.5rem, 1.22rem + 1.11vw, 2.5rem);
}
body { font-size: var(--step-0); }
h1 { font-size: var(--step-2); }
.card { padding: var(--space-m); gap: var(--space-s); }
Keep a rem term in the preferred value. A pure vw size ignores the user's browser font-size setting and fails WCAG resize-text expectations; the rem part keeps zoom and user preferences working.
Layout without media queries using min() and max()
Many of our breakpoints existed only to stop something from being too wide or too narrow. min() and max() express that directly.
.page {
/* never wider than 72rem, always 1rem gutter on small screens */
width: min(72rem, 100% - 2rem);
margin-inline: auto;
}
.grid {
display: grid;
/* columns at least 16rem, or full width if the container is smaller */
grid-template-columns: repeat(auto-fit, minmax(min(16rem, 100%), 1fr));
gap: var(--space-m);
}
.sidebar {
/* at least 14rem, otherwise a quarter of the container */
flex-basis: max(14rem, 25%);
}
The min(16rem, 100%) inside minmax() is the important trick: without it, a 16rem minimum overflows a 300px phone viewport and causes horizontal scroll.
For components that live in variable-width slots, we pair this with container query units (cqi) so a card scales with its slot, not the viewport:
.widget { container-type: inline-size; }
.widget h3 { font-size: clamp(1rem, 0.8rem + 2cqi, 1.4rem); }
Typed tokens with @property
By default a custom property is just an untyped token stream, so the browser cannot animate it or validate it. Registering it with @property fixes both:
@property --color-accent {
syntax: "<color>";
inherits: true;
initial-value: oklch(0.52 0.17 262);
}
.button-primary {
transition: background-color 200ms;
}
If a tenant config ships an invalid value such as --color-accent: blue-ish, the declaration of a registered property is rejected, so the element keeps the inherited or initial color. An unregistered property would accept the junk, and every var(--color-accent) would become invalid at computed-value time, leaving the button transparent. Registering removed that whole class of tickets.
Reading and setting tokens from JavaScript
Charts and canvas widgets need the same colors. Read the computed value instead of copying hex codes into JavaScript:
function token(name, el = document.documentElement) {
return getComputedStyle(el).getPropertyValue(name).trim();
}
const accent = token("--color-accent");
chart.update({ color: accent });
// live preview in the theme editor
document.documentElement.style.setProperty("--color-accent", "oklch(0.6 0.15 30)");
Watch out for tokens defined with light-dark() or color-mix(): an unregistered custom property computes to its token text, so getPropertyValue() can hand back the raw light-dark(...) string, which a charting library cannot parse. Registering the token as <color> makes the browser compute it to a color. We register every color token that JavaScript reads and have a unit test that asserts each one parses.
Common mistakes
| Mistake | Why it hurts | Do this instead |
|---|---|---|
Deriving tokens with color-mix() on :root and overriding the base on a child element |
The derived value was computed on :root and inherited as-is; the child override does not recompute it |
Define derived tokens on the same selector that sets the base, or on * scoped to the theme root |
Pure vw font sizes |
Ignores user font settings and browser zoom behaves badly | Always keep a rem term inside clamp() |
| Components reading primitive tokens | A theme cannot change them without touching components | Components read semantic tokens only |
Fallback in every var() call |
Hides missing tokens and spreads defaults across files | Define defaults once on :root, or use @property initial values |
minmax(16rem, 1fr) in grids |
Overflows on narrow screens | minmax(min(16rem, 100%), 1fr) |
| Dark mode via inverted filter | Inverts images and brand colors | color-scheme plus light-dark() tokens |
The first row was the bug we hit: tenants could change --color-accent but hover states stayed blue. Custom properties resolve var() references at the element where they are declared, then inherit the result. In the preview wrapper, the derived tokens had already been resolved on <html> with the default accent. Moving the color-mix() declarations into the [data-theme] block, next to the override, fixed it.
Checking contrast for every theme
Fluid sizes and tenant colors multiply the combinations, so we test rather than eyeball. A small Playwright script loads each theme in light and dark schemes and runs axe-core's color-contrast rule; a failing combination blocks the tenant config from being saved. Since oklch() lightness tracks perceived brightness, our editor also clamps tenant accents to a lightness range we know passes 4.5:1 on our surfaces, which catches most problems before the test does.
Key takeaways
- Split tokens into primitives and semantics; themes override semantics only.
- Use
color-schemewithlight-dark()instead of duplicating tokens for dark mode. clamp()with aremplusvwpreferred value gives fluid type without breakpoints and respects user font settings.min(),max()andminmax(min(X, 100%), 1fr)remove most layout media queries.- Register important tokens with
@propertyfor validation, animation and readable computed values. - Declare derived tokens where their inputs are overridden, or they will not follow the theme.
References
- MDN, Using CSS custom properties: https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties
- MDN, clamp(): https://developer.mozilla.org/en-US/docs/Web/CSS/clamp
- MDN, light-dark(): https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/light-dark
- MDN, color-mix(): https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/color-mix
- MDN, @property: https://developer.mozilla.org/en-US/docs/Web/CSS/@property
- W3C, CSS Values and Units Module Level 4 (comparison functions): https://www.w3.org/TR/css-values-4/
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.