MAISON CODE .
/ CSS · Design Systems · Frontend · Tailwind · Architecture

CSS Variables: The Engine of Modern Design Systems

SASS variables die at compile time. CSS Variables live in the browser. A technical guide to Semantic Layering, Dynamic Theming, and Component Isolation.

AB
Alex B.
CSS Variables: The Engine of Modern Design Systems

For a decade, SASS was King. We defined $brand-color: #ff0000;. We compiled it. But SASS variables have a fatal flaw: They die at compile time. Once the CSS hits the browser, the variable is gone. It is just a static hex code. You cannot change it with JavaScript. You cannot scope it to a specific DOM node.

Enter CSS Custom Properties (Variables). --brand-color: #ff0000;. These are not just “variables”. They are properties of the DOM. They cascade. They are alive. At Maison Code Paris, we use them as the foundational layer of every Design System we build.

1. The Semantic Layering Strategy

Beginners map variables directly to colors. --blue: #0000ff; Experts map variables to Intent.

We use a 3-layer architecture:

Layer 1: Primitives (The Palette)

This is your raw paint box. It has no meaning, just values.

:root {
  --palette-blue-100: #ebf8ff;
  --palette-blue-500: #4299e1;
  --palette-blue-900: #2a4365;
  --palette-neutral-900: #1a202c;
  --palette-white: #ffffff;
}

Layer 2: Semantics (The Token)

This maps the palette to a specific purpose.

:root {
  --bg-primary: var(--palette-white);
  --bg-secondary: var(--palette-blue-100);
  --text-body: var(--palette-neutral-900);
  --action-primary: var(--palette-blue-500);
  --action-primary-hover: var(--palette-blue-900);
}

Layer 3: Component (The Usage)

Checking out a component.

.btn-primary {
  background-color: var(--action-primary);
  color: var(--bg-primary);
}

Why? If the brand decides “Primary Action is now Purple”, you change one line in Layer 2. You do not grep/sed the entire codebase.

2. Dynamic Theming (Dark Mode)

Because CSS variables are resolved at runtime, implementing Dark Mode is trivial. You simply redefine Layer 2 inside a data attribute.

[data-theme="dark"] {
  --bg-primary: var(--palette-neutral-900);
  --bg-secondary: var(--palette-black);
  --text-body: var(--palette-white);
}

When you toggle the attribute on <body>, the entire site repaints instantly (at 60fps) because the browser just swaps the pointers. No stylesheet reload required.

3. Scoping and Isolation

Variables obey the Cascade. This allows for powerful Contextual Styling. Imagine a “Dark Section” in the middle of a light page.

.section-inverted {
  --text-body: var(--palette-white);
  --bg-primary: var(--palette-neutral-900);
}

Any component (Card, Button, Text) placed inside .section-inverted will inherently adopt the “Dark” values, even if the rest of the page is Light. The component code doesn’t change. It just consumes the variable from its nearest parent.

4. JavaScript Interaction: Mouse Tracking

You can read/write variables from JS. This enables high-performance UI effects without React re-renders.

The “Follow Cursor” Spotlight Effect:

document.addEventListener('mousemove', (e) => {
  const x = e.clientX;
  const y = e.clientY;
  document.body.style.setProperty('--mouse-x', `${x}px`);
  document.body.style.setProperty('--mouse-y', `${y}px`);
});
.spotlight {
  background: radial-gradient(
    circle at var(--mouse-x) var(--mouse-y),
    rgba(255, 255, 255, 0.2),
    transparent 150px
  );
}

This runs entirely on the CSS Compositor thread (mostly).

5. Integrating with Tailwind CSS

We love Tailwind. But hardcoding hex values in tailwind.config.js is an anti-pattern. Link Tailwind to your Semantic Layer.

// tailwind.config.js
module.exports = {
  theme: {
    colors: {
      // Use the CSS Variable, not the Hex
      primary: 'var(--action-primary)',
      background: 'var(--bg-primary)',
      text: 'var(--text-body)',
    }
  }
}

Now bg-primary generates .bg-primary { background-color: var(--action-primary) }. You get the utility workflow, but you keep the runtime flexibility.

6. Performance Pitfalls

  1. Scope Root Changes: changing a variable on :root triggers a style recalculation for the entire DOM tree. It is fast, but doing it on scroll event is risky. Prefer scoping changes to specific containers.
  2. calc() Complexity: width: calc(var(--a) * var(--b) + 10px) forces the browser to do math every frame if the variables change. Use sparingly.

7. The Performance of calc() vs Precomputed

Browser Math is fast, but not free. width: calc(var(--a) * var(--b)) runs on every layout recalculation. If you have 10,000 nodes doing this, 60fps drops to 30fps. Optimization: If the value is static for a component, precompute it in JS or SASS if possible. Use CSS Variables for Values that change at runtime (Theme, Dimensions, user config). Don’t use them just to avoid typing numbers.

8. Using Variables for Animation States

Instead of animating transform, animate a variable.

.card {
  transform: translateY(var(--y, 0)) scale(var(--s, 1));
  transition: --y 0.2s, --s 0.2s;
}
.card:hover {
  --y: -10px;
  --s: 1.05;
}

This is cleaner than redefining the whole transform string. It also allows independent animations (e.g., JS updating --y while CSS updates --s). It reduces the “CSS String Parsing” overhead for the browser.

9. Fallback Strategies

For critical libraries, always provide a fallback. color: var(--text-body, black); If the user loads your component but forgets to include the Design System CSS, it still renders as black, not invisible.

9. Container Queries: Contextual Variables

Media Queries (@media (min-width: 768px)) query the Screen. Container Queries (@container (min-width: 300px)) query the Parent. This changes how we think about variables. A Card can say: “If my container is small, --padding: 1rem. If my container is large, --padding: 2rem.” This makes components truly portable. You can drop the “Product Card” into a Sidebar (small) or Main Grid (large), and it adapts its own internal logic without external overrides.

10. Typed CSS Object Model (Houdini)

What if you could type-check your CSS Variables? CSS.registerProperty({ name: '--brand-color', syntax: '<color>', initialValue: 'black', inherits: true }) This is Houdini. It tells the browser: “—brand-color IS A COLOR”. If you try to set it to “10px”, the browser ignores it. More importantly, the browser can now animate it properly because it knows it’s a color (interpolating RGB values), not just a string.

12. Houdini: The Deep Dive

Houdini isn’t just about types. It’s about Performance. When you animate a standard variable, the browser does string manipulation on every frame. "10px" -> "11px" -> "12px". With CSS.registerProperty, it does fast float interpolation. 10.0 -> 11.0 -> 12.0. This moves the work from the Main Thread to the Compositor Thread. The animation stays smooth even if React is blocking the main thread with a heavy hydration task.

13. The @property Rule (Future Proofing)

We mentioned Houdini. It is now stable in Chrome.

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

Now you can animate gradients! keyframe rotate { to { --gradient-angle: 360deg; } } background: conic-gradient(from var(--gradient-angle), red, blue); This was impossible before. You had to use JS/Canvas. Now it is native CSS. This unlocks 60fps complex animations on the compositor.

Why Maison Code Discusses This

At Maison Code, we ship Design Systems, not just websites. We define your brand tokens in JSON (Figma). We export them to CSS Variables automatically. We ensure that your Dark Mode is not an afterthought, but a first-class citizen. We build architectures that allow you to rebrand your entire site in 5 minutes by changing a few hex codes. We value maintainability as much as aesthetics.

14. CSS Variables Checklist

Before you ship your Design System:

  1. Namespace: Do you prefix? --ds-color-primary vs --color-primary.
  2. Fallback: Do you have a fallback value? var(--color, #000).
  3. Print: Do your variables work in Print Mode (@media print)?
  4. Contrast: Check accessibility of your Dark Mode variatons.
  5. Performance: Avoid calc() inside scroll handlers.
  6. Inheritance: Do you use inherits: true for @property?
  7. JS Sync: Is your JS reading the correct variable?
  8. Linting: Use stylelint to enforce variable usage over hex.
  9. Documentation: Is there a Storybook showing all tokens?
  10. Versioning: How do you handle breaking changes in tokens?

15. Conclusion

CSS Variables are the single most important tool for scaling Front-End architecture. They bridge the gap between “Design” (Figma Tokens) and “Code” (CSS). If you aren’t using them, you aren’t building a System. You’re just painting pages. Stop hardcoding hex values. Start thinking in Tokens.


Design System chaos?

Is your CSS a mess of !important?

Hire our Architects.