All WordPress HTML Templates Forms & Webhooks AI & Tools
HTML Templates

Design Tokens for Small Teams: A Practical CSS Variables System

A practical design tokens system for small teams: two layers of CSS custom properties, light-dark() theming, naming that survives a rebrand, and stylelint

A real working moment illustrating the theme of an article about design tokens. Wide 16:9 banner, one strong focal point, magazine editorial quality, authentic and unstaged.

A handoff we took over last spring had 2,400 lines of hand-written CSS and 61 unique hex values in it. Eleven of those were greys that differed by one or two percent lightness, invisible side by side, each one introduced by a different person on a different deadline. Changing the brand blue meant a find-and-replace across nine files and a prayer.

That project didn’t need a design system. It needed about 40 lines of design tokens and a rule that component CSS is never allowed to contain a raw colour. We shipped that in an afternoon, and the redesign six months later took two days instead of two weeks.

This is the token system that has actually survived on client work: two layers, plain CSS custom properties, no build step required, and a short list of things you should deliberately refuse to tokenise.

Key Takeaways

  • Two layers are enough for teams under roughly eight people: primitives (the raw palette and scales) and semantic tokens (what components reference). The three and four tier pyramids from large design system talks add indirection you will not maintain.
  • Name semantic tokens after their job, never their appearance. --color-surface-raised survives a rebrand; --color-light-grey becomes a lie the moment dark mode ships.
  • light-dark() plus color-scheme removes the duplicated dark theme block entirely, and it’s been in all three engines since mid-2024. Keep a [data-theme] override only if you need a manual toggle.
  • Tokenise colour, space, radius, type scale, shadow and duration. Do not tokenise one-off layout widths, grid definitions or z-index values you use once.
  • Enforce it with tooling, not goodwill: stylelint-declaration-strict-value on colour and spacing properties, plus a hex-count grep in CI. A token system with no linter decays in about three sprints.

Two layers, and why the pyramid is overkill

The canonical advice splits tokens into global, alias, and component-specific tiers. On a 60-person product org with four platforms, that’s correct. On a five-person studio shipping marketing sites and WordPress themes, the third tier is where the system dies. Nobody wants to add --button-primary-background-color-hover before they can style a hover state. They’ll write #1a56db and move on.

So: two layers.

Primitives are the raw values: a numbered colour ramp, a spacing scale, a type scale. They’re allowed to be ugly and descriptive. They are never referenced in component CSS.

Semantic tokens point at primitives and describe intent. These are the only things your buttons, cards and forms are allowed to touch.

:root {
  / ---- Layer 1: primitives. Appearance-named, never used in components. ---- /
  --blue-100: oklch(0.95 0.03 258);
  --blue-500: oklch(0.55 0.19 258);
  --blue-600: oklch(0.48 0.18 258);
  --neutral-0:   oklch(1 0 0);
  --neutral-100: oklch(0.97 0.004 258);
  --neutral-300: oklch(0.88 0.006 258);
  --neutral-700: oklch(0.45 0.01 258);
  --neutral-900: oklch(0.21 0.012 258);
  --red-500:  oklch(0.58 0.21 25);

  / 4px base, skipping values nobody reaches for /
  --space-1: 0.25rem;
  --space-2: 0.5rem;
  --space-3: 0.75rem;
  --space-4: 1rem;
  --space-6: 1.5rem;
  --space-8: 2rem;
  --space-12: 3rem;
  --space-16: 4rem;

  / ---- Layer 2: semantic. The only tokens components may reference. ---- /
  --color-surface: var(--neutral-0);
  --color-surface-raised: var(--neutral-100);
  --color-border: var(--neutral-300);
  --color-text: var(--neutral-900);
  --color-text-muted: var(--neutral-700);
  --color-action: var(--blue-500);
  --color-action-hover: var(--blue-600);
  --color-action-subtle: var(--blue-100);
  --color-danger: var(--red-500);

  --radius-sm: 4px;
  --radius-md: 8px;
  --radius-pill: 999px;
  --shadow-raised: 0 1px 2px oklch(0 0 0 / 0.06), 0 4px 12px oklch(0 0 0 / 0.08);
  --duration-fast: 120ms;
  --duration-base: 220ms;
  --ease-out: cubic-bezier(0.33, 1, 0.68, 1);
}

Why OKLCH for primitives? When you need a hover state that’s 8 percent darker, OKLCH gives you perceptually even steps and HSL does not. A 10 percent lightness drop in HSL hits yellow and blue completely differently. You’ll notice it by the fourth button variant.

Count the semantic colour tokens above: nine. That’s typically enough for an entire marketing site. If you find yourself at 40, you’re describing components, not intent.

A real working moment that fits an article section about

Naming that survives the redesign

The naming test is simple. Would the token name still be true if the brand changed from blue to green, or if the whole thing flipped to dark? --color-light-grey fails both. --color-border-subtle passes both.

The pattern we use is --{category}-{role}-{variant}, read left to right from general to specific:

  • --color-text-muted, not --muted-text-color. Grouping by category means autocomplete in VS Code becomes a browsable menu: type --color- and you see the whole palette.
  • Numbers only in primitives. Semantic tokens use words, because --color-action-hover tells you when to use it and --color-action-2 does not.
  • Skip the --c- and --sp- abbreviations. Gzip does not care, and you will care at 11pm.

One position worth arguing with: don’t namespace tokens with your brand or project name (--acme-color-action) unless you’re shipping a distributable theme where collisions are real. On a client site it’s noise on every single line.

Writing the design tokens CSS file

Keep all tokens in exactly one file, loaded before everything else. Ours is always src/css/_tokens.css, and it’s the only file in the repo allowed to contain a literal colour. Everything downstream is a reference:

.btn {
  padding: var(--space-3) var(--space-6);
  border-radius: var(--radius-md);
  background: var(--color-action);
  color: var(--neutral-0); / exception: text on action surfaces, see note /
  transition: background var(--duration-fast) var(--ease-out);
}
.btn:hover { background: var(--color-action-hover); }

.card {
  background: var(--color-surface-raised);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  padding: var(--space-6);
  box-shadow: var(--shadow-raised);
}

That --neutral-0 in the button is the kind of leak that spreads. Add a semantic token for it (--color-text-on-action) the first time you see it, because in dark mode it may not stay white.

Two mechanics worth knowing properly. First, custom properties inherit, so you can rescope a whole subtree without writing a single override selector:

/ An inverted section: every child picks up the new values automatically /
.section--inverted {
  --color-surface: var(--neutral-900);
  --color-surface-raised: oklch(from var(--neutral-900) calc(l + 0.05) c h);
  --color-text: var(--neutral-0);
  --color-border: oklch(from var(--neutral-0) l c h / 0.14);
  background: var(--color-surface);
  color: var(--color-text);
}

That relative colour syntax (oklch(from ...)) landed across Chrome, Safari and Firefox during 2023 to 2024. It’s useful for derived values, but keep it for subtle shifts rather than generating your entire palette. Auto-derived palettes look auto-derived.

Second, register tokens you intend to animate. An unregistered custom property is a string to the browser, so transitions on it snap rather than tween:

@property --gradient-angle {
  syntax: '<angle>';
  inherits: false;
  initial-value: 0deg;
}

Dark mode without a duplicated file

The old pattern was a @media (prefers-color-scheme: dark) block that redeclared every semantic token. It works, but it doubles the surface area for drift. There’s a better default now:

:root {
  color-scheme: light dark; / also fixes native form controls and scrollbars /

  --color-surface:        light-dark(var(--neutral-0),   var(--neutral-900));
  --color-surface-raised: light-dark(var(--neutral-100), oklch(0.26 0.012 258));
  --color-border:         light-dark(var(--neutral-300), oklch(0.32 0.012 258));
  --color-text:           light-dark(var(--neutral-900), var(--neutral-0));
  --color-text-muted:     light-dark(var(--neutral-700), oklch(0.72 0.01 258));
  --color-action:         light-dark(var(--blue-500),    oklch(0.68 0.16 258));
}

/ Manual toggle: flip color-scheme, the light-dark() values follow /
[data-theme="light"] { color-scheme: light; }
[data-theme="dark"]  { color-scheme: dark; }

Each token’s light and dark value sits on one line, so a reviewer can see both at once. The toggle sets color-scheme rather than redefining tokens. One source of truth per token.

The caveat: light-dark() only works inside property values, so it can’t conditionally swap a whole box-shadow stack in older browsers, and it needs color-scheme declared on an ancestor. If you support anything pre-2024 in a measurable share of traffic, keep the media query fallback. Check your analytics before deciding, not your instincts.

Fluid scales, and what not to tokenise

Type and space are where tokens pay for themselves on responsive work. Define the type scale once with clamp() and stop writing breakpoint-specific font sizes:

:root {
  --text-sm:   clamp(0.875rem, 0.85rem + 0.1vw, 0.9375rem);
  --text-base: clamp(1rem, 0.96rem + 0.2vw, 1.125rem);
  --text-lg:   clamp(1.125rem, 1.05rem + 0.4vw, 1.375rem);
  --text-h2:   clamp(1.75rem, 1.4rem + 1.8vw, 2.75rem);
  --text-h1:   clamp(2.25rem, 1.6rem + 3.2vw, 4rem);

  --measure: 68ch; / readable line length, used by prose containers /
}

Now the refusals, which matter as much as the inclusions. Do not tokenise: one-off layout widths, grid-template definitions, z-index (keep a short commented list in the stylesheet instead, it reads better than six indirections), or anything used exactly once. A token used once is a variable with extra steps.

Also resist tokenising breakpoints. Custom properties don’t work in media query conditions, and @custom-media still needs PostCSS. If you’re already running a build, fine. If you’re shipping plain files, four hard-coded breakpoints in a comment block are honest. We go into when a build step earns its keep in build tooling for static templates.

Living with Bootstrap, Tailwind and theme.json

Most small teams aren’t starting from empty. Bootstrap 5 already exposes its own custom properties (--bs-primary, --bs-body-color and friends), so the integration is to point Bootstrap’s variables at your semantic tokens rather than maintain two palettes:

:root {
  --bs-body-bg: var(--color-surface);
  --bs-body-color: var(--color-text);
  --bs-border-color: var(--color-border);
  --bs-primary: var(--color-action);
  / Bootstrap needs the RGB triplet for its rgba() mixes /
  --bs-primary-rgb: 37, 99, 235;
}

That -rgb duplication is annoying and real. Keep it next to the token it mirrors so both get updated in the same commit. This is roughly how theming works in Canvas: a single token file drives the Bootstrap layer, and the 200 plus demos inherit it without touching component CSS.

Tailwind 4 moved to CSS-first configuration, so your tokens become the theme directly via @theme, which removes the JS config as a second source of truth. For WordPress block themes, theme.json generates its own variables (--wp--preset--color--primary), and the honest answer is that theme.json wins. Define the palette there and map your semantic tokens onto the generated presets, not the reverse, or the editor will show colours the front end doesn’t have. There’s more on where those overrides live in our piece on template hierarchy in block themes.

Enforcement, or it rots

Every token system we’ve seen fail, failed the same way: someone was in a hurry and the linter wasn’t watching. Two cheap controls fix 90 percent of it.

// .stylelintrc.js
module.exports = {
  plugins: ['stylelint-declaration-strict-value'],
  rules: {
    'scale-unlimited/declaration-strict-value': [
      ['/color$/', 'fill', 'stroke', 'background', 'box-shadow',
       'padding', 'margin', 'gap', 'border-radius'],
      {
        ignoreValues: ['transparent', 'currentColor', 'inherit', 'none', '0', 'auto'],
        // tokens file is the one place literals are allowed
        disableFix: true
      }
    ]
  },
  ignoreFiles: ['**/_tokens.css']
};

And a drift check you can run in any CI job, blunter but useful for catching what the linter’s property list misses:

# Fail the build if a raw hex escapes into component CSS
count=$(grep -rhoE '#[0-9a-fA-F]{3,8}\b' src/css --include='*.css' \
  --exclude='_tokens.css' | wc -l)
[ "$count" -eq 0 ] || { echo "Raw hex values found: $count"; exit 1; }

On governance: one named person owns the token file. Not a committee. Adding a semantic token should require a one-line justification in the PR description. Adding a primitive should be rare enough that it’s a conversation. We average maybe three new primitives per year across a template with 1400 plus pages, which tells you the scale is the stable part.

Frequently Asked Questions

Do I need a tool like Style Dictionary or Tokens Studio?

Only if tokens have to reach more than one platform, for example a website plus an iOS app plus Figma. For web-only work on a small team, a hand-written CSS file is faster to read, faster to debug and has zero build dependency. Adopt the JSON pipeline the day you need a second output format, not before.

Is there a performance cost to using hundreds of CSS custom properties?

The declaration cost is negligible, but deep chains of references that get invalidated on a frequently-changing property can cause measurable style recalculation. The pattern to avoid is animating an inherited custom property on a high-level element, because every descendant that references it gets invalidated. Keep animated tokens scoped with inherits: false via @property.

How do I handle component-level variations without a third token layer?

Scope semantic tokens on the component root instead of inventing new token names. Set --color-surface and --color-text on .card--promo and every child adapts through inheritance. You get the flexibility of component tokens without adding names to the global namespace.

Should designers edit the token file directly?

Yes, if they’re comfortable in a code editor, and the primitives layer is usually safe for them. The practical setup is designers propose primitive changes in a PR and the developer who owns the file reviews the semantic mappings. The handoff friction disappears once both sides are editing the same 60 lines.

What about tokens for icons and SVG?

Use currentColor on your SVG strokes and fills so icons inherit the text token automatically, and keep a small size scale (--icon-sm, --icon-md) rather than hard-coded pixel widths. That one change removes most icon colour bugs in dark mode. Our breakdown of SVG sprites and icon performance covers the delivery side.

If you’re staring at a legacy stylesheet right now, don’t plan a system. Run the grep, sort the unique colours, and you’ll find that 60 values collapse into about 10 real ones. Write those as primitives, add the eight or nine semantic tokens your components actually need, wire up the stylelint rule in the same PR, and migrate files opportunistically as you touch them. Half a day of work, and the next rebrand stops being a project.

bootstrap 5 css variables theming design system css for small teams design tokens design tokens css custom properties light-dark() css dark mode tokens semantic vs primitive design tokens stylelint declaration strict value tokens two layer design token architecture