Learning Track
Filter content by your role to see the most relevant sections
Theming Strategies
Support dark mode, brand variations, and density modes without touching components: alias tokens with per-mode resolution, cascade-layer precedence, brand accent remapping, and the contract that keeps themes from breaking the system.
Why This Matters
Every product eventually asks for themes: a dark mode because users expect it, a second brand because the company acquired one, a dense variant because power users begged. The only question is whether those arrive as data the system already understands or as forks that touch every component. Theming is not a feature you add; it is a property you earn from how the token layers were built—or discover you never had.
The mechanism this system uses is worth studying precisely because it is boring: aliases that resolve differently per mode, a cascade with declared precedence, and brand files that are three fields long. Dark mode, ten brands, and four densities all ride the same three moves, and no component knows any of them exist.
This page covers the strategy level—how the layers compose into themes. The token mechanics underneath are covered in the tokens pages, and the deepest worked playground lives at the theming deep-dive (/blueprints/foundations/tokens/theming).
Core Concepts: Three Axes, One Cascade
Axis 1: Modes (Light/Dark)
Mode theming lives in the semantic layer's alias structure. Every mode-relevant token carries design.paths.light and design.paths.dark references—foreground.primary is mode.dark in light theme and mode.light in dark, the inversion that keeps text legible on both surfaces. The generated stylesheet emits these twice: as .light/.dark classes an application can pin, and inside an @media (prefers-color-scheme: dark) block so the default follows the OS. Precedence between "what the OS says" and "what the user pinned" is an application decision; the tokens only guarantee both exist.
Axis 2: Brands
A brand file is the smallest possible theme: which core family the brand.primary ramp points at, plus a density. Ten ship today, and every one is three fields:
// ui/designTokens/brands/*.tokens.json — the complete theme
default: { accent: "red", density: "default" } // site identity
canary: { accent: "yellow", density: "default" }
corporate: { accent: "blue", density: "compact" }
forest: { accent: "green", density: "spacious" }
midnight: { accent: "violet", density: "tight" }
monochrome: { accent: "neutral", density: "tight" }
ocean: { accent: "teal", density: "spacious" }
rose: { accent: "red", density: "spacious" }
slate: { accent: "blue", density: "compact" }
sunset: { accent: "orange", density: "default" }Because semantic tokens reference brand.primary.500—a position, not a hue—swapping the accent re-resolves every action, link, and highlight in the product. The generated brand layer emits [data-brand="ocean"] scoped blocks (light and dark variants each), and applying a brand is setting one attribute on the root element. Nothing else in the system changes.
Axis 3: Density
Density is the axis teams forget until a data product needs it. Semantic spacing resolves through the density scales (tight/compact/default/spacious slots), so [data-density] rescales padding, gaps, and stack spacing product-wide. Note that brands bundle a density—ocean is spacious, midnight is tight—which is a default, not a cage: the axes compose because they live in separate cascade layers and can be overridden independently.
The Cascade Does the Resolution
All three axes resolve through one declared precedence:
@layer core, semantic, theme, brand, density;Core values can never win against semantics; themes override semantics; brands override themes; density sits on top. That single line is the theming architecture—no specificity wars, no !important, no load-order luck. A new theme isadditive: emit another scoped block; existing rules are never edited, which is what makes ten brands cheap to maintain and the eleventh cheap to add.
The Contract Themes Cannot Break
Theming has a hard boundary: themes change values, never structure. A theme may re-resolve foreground.primary to any ramp step; it may not remove the token, rename it, or change which components consume it. The semantic role names are the public API—stable across every mode, brand, and density—and every consumer (including the scoped component tokens with their literal fallbacks) is compiled against that API. A theme that needs a new role is not a theme; it is a system change with its own review.
System Roles: Who Owns Which Axis
Design Impact
Designers own the brand pairings—which accent, which default density—and the mode equivalences that can't be derived: which neutral step a border becomes in dark mode, whether a shadow gains a hairline. These are judgment calls encoded once per theme, reviewed as theme data.
Engineering Impact
Engineers own the resolution guarantees: themes emit before first paint (no flash of wrong theme), the pinned-versus-OS precedence behaves, and nothing in application code branches on if (isDarkMode)—that conditional is the smell that theming failed and logic is compensating.
Accessibility Impact
Accessibility owns the per-theme audit: contrast pairs are claims per mode per brand, and the tenth brand is where "we check contrast" quietly becomes "we checked it once." The pair ledger from the color foundation grows multiplicatively with themes; budget the audit when budgeting the theme.
Design & Code Interplay
In the design tool, the three axes appear as Figma variable modes: a light/dark mode pair on every semantic variable, and brand collections that mirror the accent remapping. A designer previews ocean-in-dark by switching two mode toggles, never by re-painting—because the variables reference ramp positions exactly as the JSON does. Theme review happens on the mode matrix, not on screenshots of one lucky combination.
In code, the generated layers and their scoping tell the whole story:
/* Generated: app/designTokens.scss (structure) */
@layer core, semantic, theme, brand, density;
@layer theme {
.dark { /* class-pinned dark */ }
@media (prefers-color-scheme: dark) {
:root { /* OS-following dark */ }
}
}
@layer brand {
[data-brand='ocean'] {
--semantic-color-action-background-primary-default:
var(--core-color-palette-teal-500);
}
.dark[data-brand='ocean'] { /* dark-mode ocean */ }
}
@layer density {
[data-density='tight'] {
--semantic-spacing-stack:
var(--core-spacing-density-tight-sm);
}
}Applying a theme is data, and changing your mind is data:
<html data-brand="ocean" data-density="compact">
<!-- pin class: light or dark, or omit and follow the OS -->Applied Example: Shipping the Eleventh Brand
The marketing team needs a "aurora" brand for a product launch—green-accented, dark-leaning. Walk the entire cost:
- Create the brand file:
brands/aurora.tokens.json={ name: "aurora", accent: "green", density: "compact" }— three fields, done. The green ramp already exists, contrast-anchored like every family. - Rebuild tokens: the generator emits the
[data-brand="aurora"]blocks (light and dark) into the brand layer. Diff review: two scoped blocks, additive only. - Audit the pairs: the contrast ledger gains a column—aurora's action-on-white and accent-as-text pairs against the same thresholds. Green.500 is a different hex than red.500; the ≈4.9:1 claim must be re-derived, not assumed.
- Apply:
data-brand="aurora"on the launch pages. Zero component edits, zero application branches, and removing the brand after the launch is deleting one attribute.
Total system cost: three fields, one build, one audit column. That ratio is the entire argument for layered theming—and the measure of how far a theme-less system would have had to travel.
Constraints & Trade-offs
- Theme count vs audit cost: every theme multiplies the contrast matrix and visual QA surface. Ten brands are affordable because the audit is token-pair based; thirty would demand generated audits, not reviewed ones.
- Bundled vs independent axes: brands defaulting a density is ergonomic but couples the axes in users' minds; keeping the layers separate preserves override freedom at the cost of one more concept to teach.
- Attribute scoping vs build-time themes: runtime
[data-brand]switching ships one stylesheet with all themes (larger cache, instant switch); build-time theming ships smaller sheets per brand (more artifacts, no runtime switch). This system chose runtime; a marketing-site-only product might rationally choose builds. - OS-following vs pinned modes: the media query gives correct-by-default behavior; the classes give user control. Shipping both costs a small precedence decision in the app shell; shipping either alone costs a complaint category.
Common Pitfalls & Failure Modes
1. Theme conditionals in application code
// Bad: Logic compensating for failed theming
if (isDarkMode) iconColor = 'white';
// Good: The token already resolved; consume it
color: var(--ds-card-color-foreground-default);2. Hardcoded values that ignore themes
/* Correct in exactly one theme */
.hero { background: #0a65fe; }
/* Good: Resolves per theme */
.hero { background:
var(--semantic-color-action-background-primary-default); }3. Auditing one theme and shipping ten
The pairs are per-mode per-brand claims. The fix is the ledger discipline from the color foundation, run as data, not the memory of having once checked.
4. Themes that change structure
A "theme" that renames tokens, adds roles, or repoints consumers is a system change wearing a theme's costume. Route it through system review; the cascade is not the tool for it.
Verification Checklist
Additional Resources
- Theming deep-dive — the worked playground with live theme-switching cards (
/blueprints/foundations/tokens/theming) - Color Foundations — ramps, roles, and the per-mode contrast ledger (
/blueprints/foundations/color) - Spacing & Sizing — the density scales the third axis resolves through (
/blueprints/foundations/spacing) - The sources —
ui/designTokens/brands/*.tokens.json, the@layerorder in the generatedapp/designTokens.scss
Related Concepts
Reflection Questions
Apply This Concept
Your CTO asks for a "high-contrast accessibility theme." Which axis is it — a mode, a brand, or something else — and what does the answer imply about the contrast matrix and the audit you owe it?
Reflect
A teammate defends a hardcoded hero color because "the design is brand-specific." What will break, in what order, and which layer should own the value instead?
