Stylus design tokens: compile-time mixins or native CSS custom properties for theming?
0 reputation · 11 May 2025, 08:31 UTC
0 reputation · 11 May 2025, 08:31 UTC
I'm structuring a design-token layer in Stylus (current stable release) and need to pick one strategy before the codebase grows. The two documented options I'm weighing are transparent mixins that expand token values into every rule at compile time, versus emitting native CSS custom properties and letting the browser resolve them with var().
The concrete constraint is theming: we need a dark-mode variant and per-brand overrides. With mixins, each theme would require recompiling a separate stylesheet, but the output works in older browsers and stays static. With custom properties, a single compiled file can switch themes at runtime by changing values on a root selector, but Stylus variables used inside calc() or conditional logic won't react to runtime changes since they resolve at compile time.
I'm also unsure how scoping interacts here: Stylus variables follow hierarchical override rules, while custom properties follow CSS cascade and inheritance, so mixing the two could produce confusing precedence.
Which approach keeps the generated CSS smaller when tokens are referenced hundreds of times? Are there documented pitfalls when passing a custom property reference through a Stylus mixin that performs arithmetic on it?
Given your two hard requirements — a dark-mode variant and per-brand overrides — native CSS custom properties are the better fit. They let one compiled stylesheet serve every theme by redefining values on a root or scoped selector, while mixins force a separate compiled stylesheet per theme. The size question also favors custom properties once tokens are referenced hundreds of times.
A compile-time mixin expands the token's full value into every rule. If a token is used 500 times, its value appears 500 times in the output. Custom properties emit the value once and 500 short var(--token) references. For typical token values (hex colors, rem sizes), the custom-property output is smaller, and the gap widens as values get longer. Verify with your own token set rather than trusting a rule of thumb:
stylus tokens-mixins.styl -o mixins.css
stylus tokens-props.styl -o props.css
gzip -c mixins.css | wc -c
gzip -c props.css | wc -cGzip narrows the difference because repeated mixin expansions compress well, so compare gzipped sizes, not raw.
With custom properties, dark mode is a selector, not a build:
:root
--bg: #ffffff
--fg: #111111
html[data-theme="dark"]
--bg: #111111
--fg: #eeeeee
body
background var(--bg)
color var(--fg)Toggling data-theme on <html> switches the theme with no recompilation. Stylus 0.64.x (current stable line — confirm with stylus --version) compiles this without extra tooling, and @media (prefers-color-scheme: dark) works the same way. With mixins, each theme is a separate build artifact and switching requires shipping and swapping stylesheets.
This is the real trap. Stylus variables resolve at compile time; var(--space) is an opaque string to Stylus. If a mixin does Stylus-level math on it, the multiplication runs against the literal string and produces invalid CSS:
// BAD: Stylus tries to compute var(--space) * 2 at compile time
double($s)
padding $s * 2
// GOOD: arithmetic deferred to the browser via calc()
double($s)
padding calc(%s * 2 % $s)The safe rule: treat custom properties as opaque values inside Stylus and keep all arithmetic inside CSS calc(). Related pitfall: interpolation like --color: #{$brand} resolves $brand at compile time, so the custom property gets a static color, not a live reference to another token.
Your concern is correct. Stylus variables follow lexical/hierarchical override at build time; custom properties follow CSS cascade and inheritance at runtime. They are independent: overriding a Stylus variable never affects a custom property of the same name, and Stylus block scoping does not isolate custom properties — they inherit through the DOM unless shadowed by a nearer ancestor. To avoid confusion, pick one layer for each job: Stylus variables for build-time constants (breakpoints, file paths), custom properties for anything theme-dependent. Never mirror the same token in both.
If you must support browsers without custom-property support (IE11, legacy Edge), mixins or a polyfill are mandatory — check your browser matrix against current support data before committing. Mixins also suit values that must feed compile-time logic (loops, conditionals, math) that genuinely cannot move into calc().
Exact size savings depend on your token values and reference count — run the gzip comparison above. Stylus behavior described here assumes the 0.64 stable line; verify version-specific syntax against the installed release's documentation.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.