CSS Custom Properties: why native variables are more powerful than you think

An article that goes beyond the basic introduction to CSS Custom Properties. Rather than presenting them as a simple replacement for Sass variables, the article argues that they are a runtime communication mechanism — between CSS and JavaScript, between components and their context — and shows concrete patterns that a preprocessor cannot replicate.

Drop me a line

CSS Custom Properties: why native variables are more powerful than you think

For years, using variables in CSS required a preprocessor. You installed Sass or Less, learned its syntax, set up a compilation pipeline, and in return you got $color-primary and $spacing-base. It was a reasonable deal.

Today that deal has changed. CSS Custom Properties — native language variables — do not just replace what you did with Sass: they give you capabilities no preprocessor can offer. And browser compatibility is no longer an excuse: they have been available since 2016 and support is universal.


Basic syntax

Custom properties are declared with the -- prefix and consumed with the var() function.

:root {
  --color-primary: #3b82f6;
  --spacing-md: 1rem;
  --font-heading: 'Playfair Display', serif;
}

.btn {
  background-color: var(--color-primary);
  padding: var(--spacing-md);
  font-family: var(--font-heading);
}

The :root selector is equivalent to the html element but with higher specificity, and it is the conventional place to define the system’s global variables.

var() also accepts a fallback value if the variable is not defined:

color: var(--color-accent, #ff6b6b);

What sets them apart from Sass variables

Here is the difference that changes everything: custom properties exist at runtime, not at compile time.

Sass variables disappear once the CSS is compiled. They are an authoring tool, useful for whoever writes the code but invisible to the browser. Custom properties, on the other hand, live in the DOM, participate in the cascade, and can be read and modified with JavaScript.

This is not a minor detail. It is what makes patterns impossible with Sass possible.


Why use them: three reasons beyond DRY

1. Theming without JavaScript (or almost without it)

The best-known use case: dark mode. With custom properties, you only need to redefine the variables in an alternative context.

:root {
  --bg: #ffffff;
  --text: #111827;
  --surface: #f3f4f6;
}

[data-theme="dark"] {
  --bg: #0f172a;
  --text: #f8fafc;
  --surface: #1e293b;
}
<html data-theme="dark">

All components that consume --bg and --text adapt automatically. You do not need two stylesheets, you do not need alternative classes on every component, you do not need any special logic. The theme is applied at a single point and the cascade does the rest.

With JavaScript you only need one line to toggle:

document.documentElement.dataset.theme =
  document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';

2. Variables participate in the cascade and inheritance

This is the most underrated feature. A custom property can be overridden at any level of the DOM tree and all descendants will inherit the new value.

.card {
  --card-accent: var(--color-primary);
}

.card--warning {
  --card-accent: #f59e0b;
}

.card__badge {
  background: var(--card-accent);
  border: 2px solid var(--card-accent);
}

Here card__badge does not need to know whether it is inside a normal or warning card. It only consumes --card-accent, and the correct value arrives through inheritance. The component stays decoupled from its variant.

This pattern allows you to create components that are truly agnostic of their context, something structurally impossible with Sass variables because values are resolved before the DOM exists.

3. Dynamic values with JavaScript

Because custom properties are part of the DOM, you can read and write them in real time:

// Read
const value = getComputedStyle(document.documentElement)
  .getPropertyValue('--spacing-md');

// Write
document.documentElement.style.setProperty('--spacing-md', '1.5rem');

// On a specific element
element.style.setProperty('--card-accent', '#10b981');

This opens the door to interactions that would otherwise require direct inline style manipulation or dynamic classes: sliders that change font size in real time, parallax effects that update a variable with scroll position, user-customized themes saved in localStorage.

// Parallax with custom property
window.addEventListener('scroll', () => {
  document.documentElement.style.setProperty(
    '--scroll-y',
    `${window.scrollY}px`
  );
});
.hero__bg {
  transform: translateY(calc(var(--scroll-y) * 0.4));
}

Useful patterns for design systems

Semantic tokens over primitive tokens

A very robust pattern is to define two layers of variables: primitives (concrete values) and semantics (the meaning of those values).

/* Primitive layer */
:root {
  --blue-500: #3b82f6;
  --blue-600: #2563eb;
  --gray-900: #111827;
  --gray-100: #f3f4f6;
}

/* Semantic layer */
:root {
  --color-interactive: var(--blue-500);
  --color-interactive-hover: var(--blue-600);
  --color-background: var(--gray-100);
  --color-text-primary: var(--gray-900);
}

When you switch from blue to green in your design system, you only touch one line in the primitive layer. The semantic layer and all components that consume it do not need to change.

Variables as component arguments

.avatar {
  --avatar-size: 2.5rem;
  --avatar-border: none;

  width: var(--avatar-size);
  height: var(--avatar-size);
  border-radius: 50%;
  border: var(--avatar-border);
}

.avatar--lg {
  --avatar-size: 4rem;
}

.avatar--bordered {
  --avatar-border: 2px solid var(--color-interactive);
}

The component is configured through its own variables. Any consumer can override them without additional modifiers.


What custom properties do not replace

Sass still makes sense for complex math functions, mixins, loops that generate code, and compile-time conditionals. If your project uses them intensively, both tools coexist well: you define custom properties in Sass and serve them as static CSS.

For most modern projects, however, custom properties together with calc(), clamp(), and relative units cover practically everything a preprocessor used to provide.


Conclusion

CSS Custom Properties are not just “native variables”. They are a communication mechanism between CSS and JavaScript, between components and their context, between interface states and their visual presentation. When you treat them as what they are — values that live in the DOM and participate in the cascade — you start solving design problems that previously required much more complex architectures.

If you still use them only as a substitute for Sass variables, you are missing the best part.