Choosing Between Less Variables, CSS Custom Properties, and a Hybrid Token Strategy for Theming
A decision guide comparing Less compile-time variables, native CSS custom properties, and a hybrid token approach for theming. Includes a comparison table, trade-off analysis, and a concrete Less 4-compatible implementation with validation steps.
13 Feb 2026, 15:03 UTC

The Decision You Face
You're configuring a Less-based design system and need to decide how to handle design tokens—colors, spacing, typography scales—so they work for both static compilation and runtime theme switching. The choice affects bundle size, build complexity, and whether product teams can toggle themes without a redeploy.
Three approaches exist: pure Less variables (@name), native CSS custom properties (--name), or a hybrid that emits Less variables as custom properties on :root. Each has distinct constraints around interpolation, math evaluation, browser support, and output footprint.
Constraints That Shape the Choice
- Interpolation needs: Less variables can be interpolated into selectors (
.@{prefix}-button), property names, URLs, and@importpaths via@{name}. Custom properties cannot drive these Less-only constructs. - Runtime theming: Custom properties cascade, inherit, and are readable/writable from JavaScript. Less variables are inlined at compile time and disappear from the emitted CSS.
- Math behavior: Less 4 changed default division handling. Bare expressions like
12px/2are no longer evaluated unless parenthesized ((12px/2)) or the--math=alwaysflag is set. This affects token calculations regardless of which approach you pick. - Output size: Unused Less variables leave zero footprint. Custom properties are always shipped, even when unused.
- Browser support: Custom properties require a support matrix check if legacy targets (IE11 and below) remain in scope.
Quick Comparison
| Criterion | Less Variables Only | Custom Properties Only | Hybrid (Less → :root) |
|---|---|---|---|
| Compile-time interpolation | Full support | None | Full support via Less vars |
| Runtime theme switching | Requires rebuild | Native (JS/DevTools) | Native via emitted props |
| JS read/write | No | Yes | Yes |
| Unused token footprint | Zero | All shipped | Only emitted tokens shipped |
| Math evaluation | Less engine (version-sensitive) | Browser calc() | Less for static, calc() for dynamic |
| Selector/property interpolation | Yes (@{var}) | No | Yes via Less vars |
| Conditional emission | Mixin guards (when) | N/A | Mixin guards + custom props |
Trade-offs in Practice
Less Variables Only
Choose this when your themes are fixed at build time, you need selector or import-path interpolation, and you want the smallest possible CSS output. A design system that ships one theme per brand, with no user-facing theme toggle, fits here. The downside: any theme change requires a full rebuild and redeploy.
Custom Properties Only
Choose this when JavaScript-driven theming is a hard requirement (user preference, OS dark-mode sync, white-label per tenant) and your token math is simple enough to live in calc() or be computed in JS. You lose Less interpolation capabilities, so patterns like .@{namespace}-component or dynamic @import "@{theme}/variables" must be refactored.
Hybrid Approach
Choose this when you need both: static Less interpolation for component architecture and runtime overrides for theming. Define canonical tokens once as Less variables, then emit a curated subset as custom properties on :root through a single mixin. Less code consumes the compile-time variables for math and interpolation; the browser receives overridable tokens for color schemes, spacing scales, or font stacks.
Concrete Implementation: Hybrid Token System
The following pattern works with Less 3.5+ (map-style lookups) and Less 4 (with explicit math mode). Pin your version first:
# Run in project root
npx lessc --version
# Example output: lessc 4.2.0 (Less Compiler) [JavaScript]
1. Central Token Map (Less 3.5+ namespaced ruleset)
// tokens.less
@tokens: {
// Color palette
color-primary-500: #2563eb;
color-primary-600: #1d4ed8;
color-neutral-100: #f3f4f6;
color-neutral-900: #111827;
// Spacing scale
space-1: 4px;
space-2: 8px;
space-3: 12px;
space-4: 16px;
// Typography
font-sans: system-ui, -apple-system, sans-serif;
font-mono: ui-monospace, SFMono-Regular, monospace;
// Semantic aliases for theming
bg-primary: @color-primary-500;
text-on-primary: #ffffff;
bg-surface: @color-neutral-100;
text-primary: @color-neutral-900;
};
// Helper to read tokens with bracket syntax
.token(@key) {
@value: @tokens[@key];
@return: @value;
}
2. Emit Custom Properties Selectively
// theme.less
@import "tokens";
// Flag to control dark-theme emission (compile-time)
@emit-dark-theme: true;
// Mixin emits a token map as custom properties
.emit-custom-properties(@map, @prefix: "") {
@keys: extract(keys(@map), 1);
.loop(@i) when (@i > 0) {
.loop(@i - 1);
@key: extract(@keys, @i);
@val: @map[@key];
@prop: ~"--@{prefix}@{key}";
@{prop}: @val;
}
.loop(length(@keys));
}
:root {
.emit-custom-properties(@tokens, "token-");
// Semantic aliases that consumers actually use
--color-bg-primary: @tokens[bg-primary];
--color-text-on-primary: @tokens[text-on-primary];
--color-bg-surface: @tokens[bg-surface];
--color-text-primary: @tokens[text-primary];
--font-sans: @tokens[font-sans];
--font-mono: @tokens[font-mono];
--space-1: @tokens[space-1];
--space-2: @tokens[space-2];
--space-3: @tokens[space-3];
--space-4: @tokens[space-4];
}
// Conditional dark theme block (compile-time guard)
.dark-theme-block() when (@emit-dark-theme = true) {
[data-theme="dark"] {
--color-bg-primary: @tokens[color-primary-600];
--color-bg-surface: @tokens[color-neutral-900];
--color-text-primary: @tokens[color-neutral-100];
}
}
.dark-theme-block();
3. Consume Tokens in Components
// button.less
@import "tokens";
// Compile-time interpolation for BEM-style selector
.@{token-namespace}button {
// Static math uses Less variables (parenthesized for Less 4)
padding: (@tokens[space-2] * 1.5) (@tokens[space-3] * 2);
font-family: @tokens[font-sans];
font-size: 14px;
// Runtime-themable values use custom properties
background-color: var(--color-bg-primary);
color: var(--color-text-on-primary);
border: none;
border-radius: 4px;
cursor: pointer;
&:hover {
// Less math for static adjustment, result inlined
background-color: darken(@tokens[color-primary-500], 5%);
}
}
// Namespace variable for interpolation
@token-namespace: "ds-";
4. Compile and Verify Output
# Compile with explicit math mode (Less 4 default is strict)
npx lessc --math=always button.less button.css
# Inspect emitted custom properties
grep -A 20 ':root' button.css
# Expected: --token-color-primary-500: #2563eb; etc.
# And semantic: --color-bg-primary: #2563eb;
# Dark theme block present only when @emit-dark-theme: true
Validation Path
- Pin and verify version: Run
npx lessc --versionin your project. Document the major version in your build config. - Compile a fixture: Create a minimal
test.lessimporting your token file and a component. Compile via CLI and via your bundler loader (webpack less-loader, vite-plugin-less, etc.). Diff the outputs—they must match exactly. - Check division behavior: Add
width: (12px/2);andheight: 12px/2;to the fixture. With--math=alwaysboth become6px. Without the flag, the second stays12px/2in output. Confirm your project's math mode matches expectations. - Runtime theme test: Open the compiled page in a browser. In DevTools, change
--color-bg-primaryon:rootto#dc2626. The button background should update instantly without recompilation. - JS integration test: Execute
document.documentElement.style.setProperty('--color-bg-primary', '#059669')in console. Verify the theme shifts. This confirms custom properties are wired for programmatic theming.
Limitations and Gotchas
- Math mixing: Arithmetic that combines Less variables and custom properties (e.g.,
calc(var(--space-1) * 2)) is evaluated by the browser, not Less. Less cannot validate the result. Inspect emitted CSS to confirmcalc()expressions are well-formed. - Unused custom properties: The hybrid emits every token listed in the
:rootblock. Audit the list periodically; remove tokens no longer referenced by any component. - Less 4 migration: Existing codebases upgrading from 3.x often hit silent division changes. Run the fixture diff (step 2) before and after upgrade. Add
--math=alwaysto your build script if you need the old behavior globally. - Interpolation scope:
@{variable}works in selectors, property names, URLs, and import paths. It does not work insidecalc()orvar()—those are passed through to the browser verbatim. - Browser support: If you must support IE11, the hybrid approach requires a custom property polyfill (e.g.,
ie11-custom-properties) and you lose runtime theming in that browser. Less-only output works everywhere Less compiles.
Decision Checklist
| If... | Then choose... |
|---|---|
| No runtime theming, need selector interpolation, minimal output | Less variables only |
| Runtime theming via JS, minimal build-time math, can drop Less interpolation | Custom properties only |
| Need both static interpolation and runtime overrides | Hybrid (Less → :root) |
| Multiple fixed themes (brand A, brand B) built at once | Less variables + mixin guards per theme |
| White-label per tenant with dynamic token values | Hybrid + JS token injection at runtime |
Start with the hybrid if you're unsure—it preserves the option to add runtime theming later without refactoring token definitions. The token map in tokens.less becomes your single source of truth, consumed by Less at compile time and exposed to the browser via the :root emission mixin.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.