# Theming Systems

> Use when building design tokens, adding dark mode or multi-brand theming, wiring a theme switcher, or supporting forced-colors, density, or RTL.

- Skill: `yogvidwankhede/theming-systems` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add yogvidwankhede/theming-systems`
- Raw SKILL.md: https://api.skillmd.com/api/skills/yogvidwankhede/theming-systems/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: yogvidwankhede (https://skillmd.com/u/yogvidwankhede)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/yogvidwankhede/theming-systems

---


<!--
  Generated by Vishwakarma. Do not edit this file directly.
  Edit the source skill and run `vishwakarma sync` to regenerate.
-->

# Theming Systems

A theme is not a palette. It is a **mapping from role to value**, plus the machinery that
swaps one mapping for another without the user seeing the seam. Get the mapping wrong and
adding a theme means editing components. Get the machinery wrong and every cold load shows
the wrong theme for 200ms.

## 1. Three layers

```css
--grey-50: oklch(0.97 0.004 260);   /* primitive: what it is. Theme-invariant. */
--brand-600: oklch(0.58 0.16 255);
--surface: var(--grey-50);          /* semantic:  what it means. One set per theme. */
--accent: var(--brand-600);
--btn-primary-bg: var(--accent);    /* component: where it is used. The override seam. */
```

Primitives are a vocabulary and never appear in a component. The semantic layer *is* the
theme — the only layer a theme file rewrites, and the only level at which a contract like
"`--fg` clears 4.5:1 on `--surface` in every theme" is statable.

The component layer earns its keep for one reason: **it is the only place a one-off can live
without a hardcoded value or a polluted global vocabulary.** When the pricing page's primary
button must use the success hue, without it you either hardcode `#1f8a4c` or add
`--accent-alt` globally — a global name for a local problem.
Create component tokens when a second consumer or an override appears, not before, and alias
semantic tokens from them, never primitives. A flat set of renamed hex values is the
primitive layer with the other two missing, and will not survive dark mode.

## 2. How custom properties resolve, and the trap

Custom properties are inherited and substituted at computed-value time, so a `var()` resolves
against the element that *declares* it, not the one that *uses* it:

```css
:root { --accent: var(--brand-600); --btn-bg: var(--accent); }
[data-brand="acme"] { --accent: var(--acme-600); }   /* button does not change */
```

`--btn-bg` computed on `:root`, where `--accent` was still the default, and the subtree
inherits that already-substituted value. **A token that must vary per subtree has to be
referenced below the override point, not resolved above it** — declare component tokens on
the component's own selector, `.btn { background: var(--btn-bg, var(--accent)) }`, or
redeclare every derived token in the block that overrides its input.

Two further mechanics. A `var()` with no value invalidates the *whole declaration* at
computed-value time, so the property resolves to `unset` — for `color`, inherited rather than
ignored, which can hand an element a foreground identical to its background. And an
`@property` registration with `inherits: false` can never be a theme token.

## 3. Switching without a flash

Resolution order is fixed: **explicit stored choice, then system preference, then product
default.** Anything else means a user who chose light gets dark at sunset.

It must run in a **parser-blocking inline `<script>` in `<head>`, above the stylesheets.**
Not `defer`, not `type="module"`, not external, not a framework effect — those all execute
after first paint, so the correction is a repaint of a page the user has already seen. Inline
specifically, because an external synchronous script blocks the parser for a network round
trip, trading the flash for a blank screen. Under a strict CSP give it a nonce or hash rather
than moving it. Exact script in the flash-free reference.

## 4. `color-scheme` is not optional

```css
:root { color-scheme: light dark; }
[data-theme="dark"] { color-scheme: dark; }
```

It controls what CSS cannot reach: the canvas background before your stylesheet arrives,
scrollbars, the default rendering of `<select>`, date and colour pickers, checkboxes,
spellcheck underlines, and the used values of the system colour keywords. Without it a dark
page gets light scrollbars and a blinding native date picker, and `light-dark()` does not
resolve. Update it when the user overrides the system, and add
`<meta name="color-scheme" content="light dark">` so the canvas is right before CSS arrives.

## 5. Three states, never a boolean

A boolean `isDark` collapses "the user chose light" and "the user has not chosen" into one
bit, so the first touch of a toggle opts that user out of their system preference
permanently. Store `'light' | 'dark' | null`, null meaning follow the system, and expose a
three-option control: System, Light, Dark. In system mode, re-resolve on `change` from
`matchMedia('(prefers-color-scheme: dark)')`, gating on the mode inside the listener so it
never has to be re-subscribed.

## 6. Multi-brand and runtime injection

Brand and theme are orthogonal: a brand swaps the **primitive** layer, a theme swaps the
**semantic** layer. Compose them as independent root attributes,
`[data-brand="acme"][data-theme="dark"]`, not one file per combination — that set grows as
brands times themes and diverges wherever nobody looks.

For runtime tenant themes, write primitives into a **single stylesheet** — one `<style>`, or
a `CSSStyleSheet` in `adoptedStyleSheets` — not inline custom properties across many nodes,
which sit atop the cascade and destroy the override seam. Validate every injected value: it
lands in a declaration context, so an unvalidated one is a stylesheet injection.

## 7. Dark is a design, not a transform

Elevation reverses — higher surfaces become *lighter*, because a shadow has nothing left to
darken into. Accent chroma drops 25-40% as lightness rises, because saturated colour on
near-black glows. Both extremes are wrong: L 0.18 darkest surface, L 0.94 brightest
foreground. And foreground-on-accent often flips white to dark once the accent lightens —
the missed check.

## 8. Forced colours is a different problem

`@media (forced-colors: active)` means the user agent has replaced your colours with a small
user-chosen palette: author backgrounds are overridden, `box-shadow` is dropped, background
images may be removed. **Everything expressed only through background colour or elevation
disappears** — selected rows, active tabs, status chips, card boundaries, shadow-based focus
rings. The interface does not get more contrast; it gets more ambiguous.

Re-express state in what survives — borders, outlines, underlines — and use the system
keywords `Canvas`, `CanvasText`, `ButtonFace`, `ButtonText`, `ButtonBorder`, `Field`,
`Highlight`, `HighlightText`, `LinkText`, `GrayText` and `AccentColor`, which resolve to the
user's own palette. Reserve `forced-color-adjust: none` for elements whose colour *is* the
content — swatches, chart series, brand marks — and own their contrast yourself.
`prefers-contrast: more` is a separate signal asking you to raise your own contrast; it does
not imply forced colours.

## 9. Density and direction are theme axes too

Density is a scale factor over spacing and size tokens, not a second component library:
`--density: 1 | 0.85 | 1.15`, with `--control-h: calc(2.5rem * var(--density))`. It works
only if nothing hardcodes padding or height, and compact must not push targets below 24 by 24
CSS pixels (WCAG 2.2 SC 2.5.8) or type below 12px.

Direction is the same shape. Use logical properties throughout — `margin-inline-start`,
`padding-block`, `inset-inline-end`, `border-start-start-radius`, `text-align: start` — so
`dir="rtl"` on the root is the whole change. Directional icons (back, next) mirror;
representational ones (a clock, a check, a logo) do not.

## The failures, named

- **Theme flash.** Preference read in an effect or async store. Fatal on every cold load.
- **Hardcoded colours.** One `#fff` defeats the whole system, silently.
- **`filter: invert(1)` dark mode.** Rotates every hue 180 degrees; wrecks photos and logos.
- **Ignoring forced colours.** A careful palette becomes an unreadable flat grid.
- **Transitioning the switch.** Hundreds of concurrent colour transitions read as a wash.
- **Assuming symmetry.** Contrast passing in light rarely passes in dark unchecked.

## Rules

### MUST NOT — Do not reference palette primitives or literal colour values from component styles; reference semantic or component tokens only.

*Why:* A component bound to a primitive has no meaning layer left to remap, so every theme, rebrand or contrast fix becomes a component edit. A single literal value also fails silently: it looks correct in the theme it was authored against and is only discovered when someone reports an unreadable element in the other theme.

### MUST NOT — Do not implement a dark theme with filter: invert(), hue-rotate(), or any global pixel transform.

*Why:* Inversion operates on rendered values rather than on roles, so it rotates every hue by 180 degrees — the brand colour becomes its complement and error red becomes cyan — inverts photographs and logos, double-inverts any descendant that compensates, and cannot express the elevation reversal a dark theme requires.

### MUST — Define a semantic token layer named by role, distinct from the primitive palette, and redefine only that layer per theme.

*Why:* A theme is a remapping from meaning to value, so it needs a layer that holds meaning. A flat set of renamed hex values has no such layer, which means a second theme can only be produced by editing every consumer. Role names also stay true across themes, while appearance-derived names become false the moment a dark theme exists.

Incorrect:

```css
:root {
  --color-light-grey: #f4f4f5;
  --color-primary-blue: #3b6fd4;
}
```

Correct:

```css
:root {
  --grey-100: #f4f4f5;
  --brand-600: #3b6fd4;
  --surface: var(--grey-100);
  --accent: var(--brand-600);
}
```

### MUST — Declare derived tokens at or below the level where their inputs may be overridden, never resolving them once on :root when a subtree must vary.

*Why:* Custom properties are substituted at computed-value time, so a var() reference resolves against the element that declares it. A token computed on :root inherits its already-substituted value into every subtree, and overriding its input further down changes nothing.

*Source:* [CSS Custom Properties for Cascading Variables Module Level 1](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_cascading_variables/Using_CSS_custom_properties)

Incorrect:

```css
:root { --accent: var(--brand-600); --btn-bg: var(--accent); }
[data-brand="acme"] { --accent: var(--acme-600); } /* button unchanged */
```

Correct:

```css
:root { --accent: var(--brand-600); }
[data-brand="acme"] { --accent: var(--acme-600); }
.btn-primary { background: var(--btn-bg, var(--accent)); }
```

### MUST — Give every token a value on the root, or supply a fallback at every var() reference.

*Why:* A var() whose custom property has no value makes the entire declaration invalid at computed-value time, so the property resolves to unset rather than being skipped. For an inherited property such as color that means the element silently takes its parent value, which can produce foreground and background that are the same colour.

### MUST — Define the same set of semantic token names in every theme, with no token present in one theme and absent in another.

*Why:* A token missing from one theme does not fall back to a sensible default; it falls back to whatever the root declared, which is the other theme value, producing an element that is correct in one theme and inverted in the other. Missing tokens are invisible until the specific component is rendered in the specific theme.

### MUST — Resolve the active theme in a parser-blocking inline script placed in <head> above the stylesheets, not in a deferred, external, or framework-lifecycle script.

*Why:* Deferred, module, async and effect-based code all execute after first paint, so the browser has already painted the default theme and the correction is a visible repaint. An external synchronous script blocks the parser for a network round trip, trading the flash for a blank screen and adding a request to the critical path.

*Exceptions:*
- Authenticated pages where the mode is stored in a cookie and rendered server-side — but the inline script is still required to resolve the system-preference branch.

### MUST — Resolve the theme as explicit stored choice first, system preference second, product default last.

*Why:* An explicit choice is the only signal that expresses intent about this product specifically. Consulting the system first overrides a deliberate decision whenever the OS switches, which for a schedule-based system theme means the app silently changes twice a day against the user’s wishes.

### MUST — Store the theme preference as light, dark, or absent-meaning-system, and expose a three-option control rather than a boolean toggle.

*Why:* A boolean cannot distinguish "the user chose light" from "the user has not chosen", so the first interaction with a toggle permanently opts the user out of their operating system preference with no way back. The three-state form keeps "follow the system" reachable and makes the current state legible.

Incorrect:

```ts
type ThemePref = boolean // isDark
localStorage.setItem('dark', String(isDark))
```

Correct:

```ts
type ThemeMode = 'light' | 'dark' | 'system'
mode === 'system'
  ? localStorage.removeItem('theme-mode')
  : localStorage.setItem('theme-mode', mode)
```

### MUST — Declare color-scheme on the root for every theme, and update it when the user overrides the system preference.

*Why:* color-scheme is the only mechanism that reaches user-agent rendering: the canvas background, scrollbars, native form controls, date and colour pickers, spellcheck underlines, and the used values of system colour keywords. Without it a dark page renders light scrollbars and light native widgets, and light-dark() does not resolve at all.

*Source:* [CSS Color Adjustment Module Level 1, color-scheme](https://developer.mozilla.org/en-US/docs/Web/CSS/color-scheme)

Incorrect:

```css
[data-theme="dark"] { --surface: #16181d; --fg: #e8e8ea; }
```

Correct:

```css
:root { color-scheme: light dark; }
[data-theme="dark"] { color-scheme: dark; --surface: #16181d; --fg: #e8e8ea; }
```

### MUST — Re-check the foreground colour placed on an accent after adjusting that accent for a dark theme.

*Why:* A dark-theme accent is lighter than its light-theme counterpart, which often inverts the correct text polarity on top of it. Carrying white text across unchanged produces a primary button around 2:1, and because the button still looks confident nobody reports it until an audit.

### MUST — Ensure every state and boundary conveyed by background colour or shadow is also conveyed by a border, outline, or text change under forced-colors: active.

*Why:* In forced colours mode the user agent replaces author background colours with a small user-chosen palette and drops box-shadow entirely, so selection, active tabs, status chips, card edges and shadow-based focus rings all collapse into an undifferentiated surface. The interface does not become high-contrast; it becomes ambiguous.

*Source:* [CSS Color Adjustment Module Level 1, forced colors mode](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/forced-colors)

### MUST — Run the contrast audit independently against every theme rather than inferring one theme’s compliance from another’s.

*Why:* The WCAG 2 contrast formula is not symmetric under lightness reversal: its fixed 0.05 flare term inflates ratios among dark pairs, so a pair that passes marginally in a light theme usually fails its dark counterpart. Disabled states derived by opacity are worst affected and often drop below the 3:1 non-text floor.

### SHOULD NOT — Do not apply forced-color-adjust: none broadly to preserve a design; restrict it to elements whose colour is itself the content.

*Why:* Opting out of forced colours discards the guarantee the user asked for and hands responsibility for their contrast back to a palette they have already rejected as unusable. It is legitimate only where colour carries information that cannot be re-encoded — colour swatches, chart series, brand marks — and then the element’s own contrast must be supplied.

*Source:* [CSS Color Adjustment Module Level 1, forced-color-adjust](https://developer.mozilla.org/en-US/docs/Web/CSS/forced-color-adjust)

### SHOULD NOT — Do not add colour transitions to a universal selector for the theme switch; switch instantly or use a single view transition.

*Why:* Hundreds of independent colour transitions starting at once composite unevenly, so the page appears to melt rather than switch, and any element that mounts during the transition arrives part-way through its own fade. A view transition cross-fades one snapshot instead, which is a single coherent animation.

*Exceptions:*
- A scoped transition on a small number of large surfaces, gated behind prefers-reduced-motion.

### SHOULD — In dark themes express elevation through increasing surface lightness in steps of roughly 0.035 L, and demote shadows to contact cues.

*Why:* A shadow works by darkening the surface beneath an element. Against an already-dark surface there is little range left to darken into, so the cue is invisible and the theme loses its entire depth vocabulary at once. A lighter nearer surface is both visible and physically consistent.

### SHOULD — Inside a forced-colors block, express colours with system keywords such as Canvas, CanvasText, ButtonBorder, Highlight, and GrayText rather than with theme tokens.

*Why:* System colour keywords resolve to the user’s own chosen forced palette, so they remain internally consistent and meet that user’s contrast needs by construction. Theme tokens in the same position are either overridden anyway or, where they survive, produce pairs whose contrast is unknown because the surrounding colours are no longer yours.

### SHOULD — Express brand and theme as independent root attributes that compose, rather than as one theme file per brand-and-theme combination.

*Why:* A brand swaps the primitive layer and a theme swaps the semantic layer, so they are independent. Materialising the product of the two axes means every semantic change must be replicated across brands times themes files, and the copies diverge in exactly the combinations nobody renders during review.

### SHOULD — Inject runtime themes as a single stylesheet or adopted CSSStyleSheet, validating each value, rather than setting inline custom properties across many elements.

*Why:* Inline styles sit at the top of the cascade and cannot be overridden by a more specific rule, which removes the override seam the token layers exist to provide. An injected token value is also substituted into a declaration context, so an unvalidated tenant-supplied string is a stylesheet injection.

### SHOULD — Use logical properties and values for spacing, borders, radii, and alignment so that direction is a root attribute rather than a stylesheet fork.

*Why:* Physical properties encode a writing direction into every rule, so right-to-left support becomes a parallel stylesheet that must be maintained in step with the original. Logical properties resolve against the element’s writing mode, which makes dir="rtl" on the root the complete change.

Incorrect:

```css
.card { padding-left: 16px; margin-right: 8px; text-align: left; }
```

Correct:

```css
.card { padding-inline-start: 16px; margin-inline-end: 8px; text-align: start; }
```

### SHOULD — Implement density as a scale factor applied to spacing and control-size tokens, not as alternative component variants, and keep targets at or above 24 by 24 CSS pixels.

*Why:* Density variants defined per component multiply the surface area of every future change and drift apart. A single scale factor keeps them consistent by construction, but only if no component hardcodes padding or height, and the target-size floor must be enforced independently because the factor will otherwise shrink hit areas below usable size.

*Source:* [WCAG 2.2 Success Criterion 2.5.8 (Target Size (Minimum))](https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html)

### SHOULD — Supply dark-theme variants of logos, illustrations, and diagrams as separate assets selected by source or state, rather than filtering the light asset.

*Why:* A filter applies to every pixel, so it also inverts photographic content inside a mark and rotates the brand hue to its complement. Selecting a separate source keeps both versions under the designer’s control and lets the choice follow the application’s theme state rather than only the system preference.

## Before reporting completion

Run these checks against your own output. Answer each question explicitly rather than
assuming the answer, because the point of the exercise is to notice what you did not
notice while building.

### Confirm the token layers exist and are intact. (blocking)

- Does any component style, utility class, or inline style reference a palette primitive or a literal colour value?
- Is there a semantic layer named by role, or are the tokens primitives with semantic-sounding names?
- Is any derived token declared on :root whose input is overridden further down the tree? If so, that override currently does nothing.
- Does every token referenced anywhere have a value on the root, or a fallback at the reference?

### Confirm the theme resolves before first paint. (blocking)

- Is the theme resolution an inline script in <head> above the stylesheets, with no defer, async, or type="module"?
- Is the localStorage read wrapped so a throw cannot abort the script?
- Does the resolution order put an explicit stored choice ahead of prefers-color-scheme?
- Is color-scheme set on the root for the resolved theme, including when the user has overridden the system?
- Does the stored preference distinguish "chose light" from "has not chosen", and does the control offer three options?
- Load the page with a cold cache, throttled, with the system in dark and no stored preference: is any light frame painted?

### Confirm every theme is complete and independently audited. (blocking)

- Do all themes define exactly the same set of semantic token names?
- Was contrast measured against each theme separately rather than assumed from the default theme?
- Do foreground-on-accent, hover, and selected states clear 4.5:1 in every theme?
- Do control borders, switch tracks, and focus indicators clear 3:1 in every theme?
- Do disabled controls remain perceivable in the darkest theme, or were they derived by lowering opacity?

### Confirm the interface survives forced colours. (blocking)

- With forced colours active, is every selected, active, checked, or highlighted state still distinguishable?
- Do card, panel, and input boundaries still exist once background colours and box-shadow are removed?
- Is the focus indicator drawn as an outline or border rather than a box-shadow?
- Is forced-color-adjust: none used anywhere other than on elements whose colour is itself the content?
- Does the design respond to prefers-contrast: more, and is that handled separately from forced colours?

### Confirm the dark theme was constructed rather than transformed.

- Do surfaces get lighter with elevation, in steps of at least 0.03 in lightness?
- Is the darkest surface above L 0.15 and the brightest foreground below L 0.97?
- Was accent chroma reduced and lightness raised, and was the foreground on the accent re-checked afterwards?
- Do logos, illustrations, screenshots, and charts have real dark variants rather than filters?
- Is anything drawn to canvas or generated as SVG in JavaScript redrawn on theme change, not just restyled?

### Confirm density and direction are theme axes, not forks.

- Is density a scale factor over spacing and size tokens, or a set of per-component variants?
- At the most compact density, is every interactive target still at least 24 by 24 CSS pixels?
- Does any rule use a physical property — left, right, margin-left, padding-right, text-align: left — where a logical one exists?
- Under dir="rtl", do directional icons mirror while representational ones do not?

### Evaluate the token set and theme machinery against the project Design Contract.

Evaluate the output against the project Design Contract (theming section).

Run `vishwakarma audit` if the project has the CLI available.

## Further reference

These are not loaded by default. Read one only when its question is the question you
currently have.

- `references/flash-free-theme-switching.md` — What is the exact inline script, storage schema, and switcher wiring that resolves a theme before first paint, and why does each part have to be that way?
- `references/dark-token-set.md` — What values do I actually assign to each semantic token in a dark theme, and how do shadows, borders, images, and state colours change?

