Jul 5, 2026
How CSS custom properties (variables) can carry a design token system without a build step, and where the pattern breaks down.
A design token system — a single source of truth for colours, spacing, typography, and other design decisions — used to require a preprocessor to be useful. You'd define tokens as SCSS variables, run Sass to compile them into plain CSS, and consume the output in your stylesheets.
CSS custom properties make that compile step unnecessary for the token layer itself. Tokens defined in :root are available everywhere in the cascade, composable, and overridable per-component or per-theme without any preprocessing. (This site still has a build step — Vite bundles and prerenders everything — the point is narrower: nothing needs to compile the tokens specifically.)
A minimal colour token layer looks like this:
:root {
--color-primary: #00a24e;
--color-primary-dark: #002d16;
--color-accent: #ffd52b;
--color-bg-dark: #020203;
--color-text: rgb(172, 160, 141);
}These are the raw values. Components reference the semantic names, not the hex values. If the brand colour changes, one value changes.
The layer above raw colour values is semantic tokens — names that describe purpose rather than appearance:
:root {
--color-link: var(--color-accent);
--color-link-hover: var(--color-primary-light);
--color-border: rgb(136, 125, 108);
}A component that needs a link colour uses --color-link, not --color-accent. This means theming can change which primitive a semantic token points to without touching component styles.
Custom properties can reference other custom properties. This makes component-level overrides clean:
.dark-card {
--color-bg-glass: rgba(5, 5, 10, 0.9);
background-color: var(--color-bg-glass);
}The component overrides a single token; everything inside it that uses that token gets the updated value automatically.
Because custom properties follow the cascade, a value set on a parent element overrides the :root default for that subtree only:
.theme-light {
--color-text: #1a1a1a;
--color-bg-dark: #f5f5f0;
}Dark mode toggle, light component variant, print styles — all handled by overriding tokens at the right scope. No class duplication, no duplicated property declarations.
Tailwind 4 ships with native CSS custom property support. The @theme block maps token names to Tailwind utilities:
@theme {
--color-accent: #ffd52b;
--color-primary: #00a24e;
}After this, text-accent, bg-primary, and border-accent are valid Tailwind utilities. The token layer and the utility layer share the same source of truth.
This is the approach this site uses. Tailwind handles layout and spacing utilities; custom properties carry the design tokens; scoped component styles use both.
Tailwind 4 is built this way on purpose, not as a convenience feature bolted onto the old version. It was rebuilt on Lightning CSS as a standalone engine that handles nesting, custom properties, and vendor prefixing itself — the jobs Sass, Less, and Stylus used to do. Configuration moved from a JavaScript file to the CSS-native @theme block for the same reason. Tailwind isn't just "compatible with" custom properties; as of v4 it no longer supports running alongside a preprocessor at all — pairing it with Sass causes real pipeline conflicts, not just duplicated effort.
This isn't a Tailwind-only move, either. Bootstrap 5's source is still SCSS, but a lot of its compiled output resolves $variables straight into CSS custom properties set at various scopes — because Bootstrap also needs runtime theming (light/dark mode, component-level overrides), and compile-time SCSS variables can't do that on their own. Once a project needs values that change in the browser rather than at build time, you end up needing real custom properties regardless of what generates your CSS.
SCSS variables are checked at compile time. If you reference $color-typo when the variable is named $color-type, the build fails. Custom properties fail silently — the property resolves to its initial value (usually empty or inherited) and you get a rendering bug, not a build error.
Tooling is improving (the CSS Custom Properties Linter for stylelint helps), but it's not as tight as SCSS compilation.
Native CSS nesting now supports & the same way Sass does, so nesting itself isn't a gap anymore. What's still missing is mixins, functions, loops, and conditional logic (@each, @if) — anything that generates or transforms CSS programmatically rather than just organizing selectors. If a token system needs to compute variants from a base set rather than just declare them, custom properties alone won't get you there.
calc()SCSS lets you write $spacing-base * 2. Custom properties don't support arithmetic directly:
/* Won't work */
--spacing-double: var(--spacing-base) * 2;
/* Must use calc() */
--spacing-double: calc(var(--spacing-base) * 2);This is a minor inconvenience in most cases but can get verbose in complex spacing systems.
Inspecting an element with many inherited custom properties in the browser devtools can be overwhelming. Inherited properties from :root all show up on every element. It's not a dealbreaker but it slows down debugging until you know what you're looking for.
CSS custom properties are the right tool for a design token layer when:
SCSS variables are still worth reaching for when you need compile-time checking, complex arithmetic, or a token system that needs to generate non-CSS output (native app themes, design tool integrations).
For a project where CSS is the only output target, custom properties win.
This also isn't a niche call. Sass adoption has been declining year over year in newer projects since around 2022, while native nesting and custom-property usage keep climbing — greenfield projects increasingly skip a preprocessor entirely, and older codebases mostly keep theirs because ripping it out has no business value, not because it's still the better starting point.