CSS Custom Property Design Tokens: Naming Conventions, Structure, and Dark Mode
A practical guide to structuring CSS design tokens with custom properties — covering two-layer naming, file organization, and dark-mode patterns that work without JavaScript.
CSS Custom Property Design Tokens: Naming Conventions, Structure, and Dark Mode Patterns
CSS custom properties (also called CSS variables) have crossed 97.8% global browser support as of 2025 (Can I Use, 2025), which makes them the obvious backbone for a design token system. The challenge is not whether to use them — it is how to name them, how to structure the files, and how to wire dark mode so it never requires JavaScript to apply the correct theme. After re-architecting a mid-size component library last year, I landed on a set of conventions that keep tokens readable, rebrand-safe, and WCAG-auditable on every build.
Two Token Layers: Primitive and Semantic
The most common mistake I see in codebases is a single flat list of named colors:
:root {
--blue-500: #3b82f6;
--blue-600: #2563eb;
--button-bg: #3b82f6;
}
The problem is that --button-bg is a hard-coded color, not a reference to a token. When the brand blue changes, you update both --blue-500 and --button-bg manually — and somewhere else a developer has written var(--blue-500) directly in a component.
The two-layer pattern solves this cleanly:
Layer 1 — Primitive tokens hold the raw values. They describe what a value is, not where it is used:
/* primitives.css */
:root {
--color-blue-400: #60a5fa;
--color-blue-500: #3b82f6;
--color-blue-600: #2563eb;
--color-gray-100: #f3f4f6;
--color-gray-900: #111827;
--radius-sm: 0.25rem;
--radius-md: 0.375rem;
}
Layer 2 — Semantic tokens reference primitives and describe where a value is used. Component code only ever consumes semantic tokens:
/* semantic.css */
:root {
--color-primary: var(--color-blue-500);
--color-primary-hover: var(--color-blue-600);
--color-surface: var(--color-gray-100);
--color-text-default: var(--color-gray-900);
}
A rebrand now touches only the primitive file. Every semantic token updates automatically because the resolved chain is still valid.
Naming Conventions That Survive a Rebrand
The naming pattern I find most durable follows the structure --[category]-[property]-[variant]-[state]. Each segment is optional once you move past category and property:
| Segment | Examples | |---------|----------| | category | color, radius, spacing, shadow, motion | | property | primary, surface, text, border | | variant | subtle, strong, inverse | | state | hover, active, disabled, focus |
Applied to a button:
:root {
/* color – intent – state */
--color-action-default: var(--color-blue-500);
--color-action-hover: var(--color-blue-600);
--color-action-disabled: var(--color-blue-300);
/* border radius – size scale */
--radius-control-sm: var(--radius-sm);
--radius-control-md: var(--radius-md);
}
One rule I enforce strictly: never embed a primitive name into a semantic token. Writing --color-button-blue couples the semantic layer to the primitive color name — as soon as the brand shifts to indigo, that name becomes a lie.
File Structure That Scales Past 50 Tokens
For small projects a single tokens.css file works fine. Once you cross 50 tokens, a flat file becomes hard to navigate. I use the following layout:
tokens/
├── primitives/
│ ├── color.css ← raw hex/oklch values only
│ ├── spacing.css ← 0, 0.25rem, 0.5rem … 4rem
│ └── motion.css ← duration, easing
├── semantic/
│ ├── color.css ← references primitives, no raw values
│ ├── typography.css
│ └── elevation.css
└── index.css ← @import order: primitives first, semantic second
index.css does nothing but import in the right order. Primitives load first so custom property references resolve. When you switch to a build tool that inlines CSS (Vite, Parcel), this explicit order still matters because CSS custom properties are not hoisted like Sass variables.
If your team exports tokens to JSON for Figma Tokens or Style Dictionary as well, keep primitives as a flat JSON file and generate both the CSS primitive layer and the Figma library from the same source. That single-source-of-truth step alone saved the team I was on about two hours per sprint that had previously gone to reconciling Figma swatches against production hex values.
Dark Mode Without JavaScript
The cleanest dark-mode pattern I know requires zero JavaScript on load. It combines prefers-color-scheme for the default OS preference with a data-theme attribute on <html> for an explicit user toggle:
/* semantic/color.css — light defaults */
:root {
--color-surface: var(--color-gray-50);
--color-text-default: var(--color-gray-900);
--color-primary: var(--color-blue-500);
}
/* OS-level dark mode */
@media (prefers-color-scheme: dark) {
:root {
--color-surface: var(--color-gray-950);
--color-text-default: var(--color-gray-50);
--color-primary: var(--color-blue-400);
}
}
/* Explicit user toggle — overrides OS preference */
[data-theme="dark"] {
--color-surface: var(--color-gray-950);
--color-text-default: var(--color-gray-50);
--color-primary: var(--color-blue-400);
}
[data-theme="light"] {
--color-surface: var(--color-gray-50);
--color-text-default: var(--color-gray-900);
--color-primary: var(--color-blue-500);
}
On first load the @media block handles dark mode correctly with no JavaScript at all — no flash of wrong theme. When the user toggles explicitly, a single document.documentElement.setAttribute('data-theme', 'dark') call persists to localStorage and overrides the media query for that session.
The key insight here is that [data-theme] has higher specificity than :root inside a media query only because of selector order — [data-theme] must come after the media block in the cascade. If you put it before, a dark-OS user who manually selects light mode will get dark colors anyway. Source order matters.
Auditing Your Token Contrast Before You Ship
A token system with good naming and dark-mode coverage can still ship inaccessible color pairs. The WCAG 2.x standard requires a minimum 4.5:1 contrast ratio for normal text (AA) and 3:1 for large text or UI components. Manually checking every foreground-background combination in a token set with twenty colors and two themes would take hours — and would need to be re-run after every palette change.
I run the check automatically at PR review time by pasting the :root block into the CSS Design Token Contrast Matrix tool. It extracts every hex color from a raw CSS custom-properties block, builds a full foreground × background matrix, and marks each cell AA, AAA, AA-large, or FAIL. For the token set above, a typical paste looks like:
:root {
--color-gray-50: #f9fafb;
--color-gray-900: #111827;
--color-gray-950: #030712;
--color-blue-400: #60a5fa;
--color-blue-500: #3b82f6;
}
The matrix immediately flags --color-blue-500 on --color-gray-50 (4.02:1 — fails AA for body text), while --color-blue-400 on --color-gray-950 passes at 5.8:1. That kind of instant feedback makes it practical to choose primitive colors that work across both themes rather than patching up failing pairs after the fact.
When you also need to work with color format conversions — for example, checking whether a Figma-exported oklch(0.6 0.18 260) value maps to the same perceptual blue as #3b82f6 — the CSS Color Format Converter handles hex, RGB, HSL, and OKLCH in both directions and outputs a ready-to-paste CSS variable block.
Summary
The two-layer token architecture — primitives plus semantics — is the one structural decision that pays off every subsequent time the brand or theme changes. Naming tokens by category, property, variant, and state rather than by literal color makes every intent transparent. Splitting files by token category keeps large systems navigable. And wiring dark mode through prefers-color-scheme plus a data-theme attribute means zero-JavaScript theming on first paint with a clean opt-out for user preferences.
The only step left after building your token system is verifying that your semantic pairs actually pass WCAG — and that is the one step worth automating from day one.
Made by Toolora · Updated 2026-06-30