CSS Custom Properties for Theming: Dark Mode and @property
AI generated
CSS · Design System · Theming · Dark Mode
CSS Custom Properties for Theming
Design Systems with --var and @property

Hardcoded color values that need updating in twenty places are a symptom of a missing theming system. CSS Custom Properties lay the foundation for maintainable design systems: a single source for every value, cascading overrides at the component level, and dark mode without a single line of JavaScript.

13 min read --var · :root · @property · prefers-color-scheme · Design Tokens CSS3 · Chrome 85+ · Firefox 89+ · Safari 15.4+

1. What sets CSS Custom Properties apart from preprocessor variables

CSS Custom Properties, often called CSS variables, solve a different problem than Sass or Less variables. Sass variables are compile-time constants: they get replaced by their values during the build process. The final CSS no longer contains variables, only resolved values. CSS Custom Properties, by contrast, are true runtime variables. They exist in the browser, can be changed at runtime with JavaScript, respond to media queries and CSS selectors, and cascade through the DOM tree exactly like normal CSS properties.

This difference is fundamental for theming systems. With Sass variables, switching a theme without reloading the page is impossible, because the value has already been compiled. With CSS Custom Properties, a single line of JavaScript (document.documentElement.style.setProperty('--color-primary', '#7c3aed')) or a CSS selector change is enough to update the entire theme. Dark mode can be expressed entirely in CSS, with no JavaScript needed to toggle a class. That makes CSS Custom Properties the foundation of every modern, maintainable design system.


/* CSS Custom Properties: the complete theming foundation */

/* 1. Design Token Layer: single source of truth */
:root {
  /* Color Palette: raw values, not semantic */
  --violet-50:  #f5f3ff;
  --violet-100: #ede9fe;
  --violet-200: #ddd6fe;
  --violet-500: #8b5cf6;
  --violet-700: #6d28d9;
  --violet-900: #4c1d95;

  /* Semantic Layer: purpose over value */
  --color-primary:    var(--violet-700);
  --color-primary-fg: #ffffff;
  --color-surface:    #ffffff;
  --color-surface-raised: #f8fafc;
  --color-text:       #0f172a;
  --color-text-muted: #64748b;
  --color-border:     #e2e8f0;

  /* Spacing Scale */
  --space-1:  0.25rem;
  --space-2:  0.5rem;
  --space-4:  1rem;
  --space-8:  2rem;
  --space-16: 4rem;

  /* Typography */
  --font-size-sm:   0.875rem;
  --font-size-base: 1rem;
  --font-size-lg:   1.125rem;
  --font-size-xl:   1.25rem;
  --font-size-4xl:  2.25rem;
  --font-weight-normal: 400;
  --font-weight-bold:   700;

  /* Borders and Radius */
  --radius-sm: 0.25rem;
  --radius-md: 0.5rem;
  --radius-xl: 1rem;
  --radius-full: 9999px;
}

2. Syntax and scoping: --var and :root

CSS Custom Properties are declared with a double-hyphen prefix: --my-color: #7c3aed. The name is case-sensitive, so --Color and --color are two different properties. They are consumed with the var() function: color: var(--my-color). Scoping follows the CSS cascade model: a CSS Custom Property defined on :root is available throughout the entire document. A property defined on a specific element applies only to that element and its descendants.

This cascading behavior is the decisive advantage over Sass variables for theming. A CSS Custom Property defined on :root, such as --button-bg: var(--color-primary), can be overridden for a specific component simply by giving the same property a new value on the component element: .card { --button-bg: var(--color-surface) }. Every button inside that card automatically picks up the overridden variable, without a single more specific selector rule.

3. Fallback values and defensive coding

The var() function accepts an optional fallback value as a second argument: color: var(--text-color, #0f172a). If --text-color is undefined or invalid, the fallback value is used instead. Fallbacks can be nested: var(--text-color, var(--color-text, #0f172a)). This expression first tries --text-color, then --color-text, then the literal value. That enables defensive CSS Custom Properties implementations that keep working even when higher-level theming layers are missing.

One important detail: the fallback value is only evaluated once the variable cannot be resolved. That means it also kicks in when a variable is set to an invalid value. If --button-bg is set to none, an invalid value for background-color, the fallback takes over. With @property, this behavior can be controlled more precisely: a typed CSS Custom Property ignores values that do not match the declared type and falls back to its initial-value instead. That makes theming systems built with @property more robust against invalid values.

4. Dark mode without JavaScript: prefers-color-scheme

Dark mode with CSS Custom Properties requires no JavaScript at all. The @media (prefers-color-scheme: dark) media query detects the user's system setting and overrides the semantic color variables on :root. The result is a complete theme switch without any DOM manipulation: every component that references semantic CSS Custom Properties such as --color-surface or --color-text automatically switches colors the moment the user changes their system setting. The system reacts instantly, with no page reload and no JavaScript.

For a manual theme toggle, where the user switches between light and dark with a button click, a single CSS class on <html> or <body> is enough: html.dark { --color-surface: #0f172a; --color-text: #f1f5f9; }. JavaScript only toggles the class; all theming logic stays in CSS. This pattern is far more maintainable than JavaScript-based theming, where colors are set programmatically. CSS Custom Properties draw a clean boundary between logic (toggling a class) and presentation (color values).


/* Dark Mode via prefers-color-scheme: no JavaScript required */

:root {
  /* Light mode defaults */
  --color-surface:        #ffffff;
  --color-surface-raised: #f8fafc;
  --color-text:           #0f172a;
  --color-text-muted:     #64748b;
  --color-border:         #e2e8f0;
  --color-primary:        #7c3aed;
  --color-primary-hover:  #6d28d9;
  --shadow-sm: 0 1px 3px rgba(0,0,0,0.08);
}

/* System dark mode: override semantic tokens only */
@media (prefers-color-scheme: dark) {
  :root {
    --color-surface:        #0f172a;
    --color-surface-raised: #1e293b;
    --color-text:           #f1f5f9;
    --color-text-muted:     #94a3b8;
    --color-border:         #334155;
    --color-primary:        #a78bfa;
    --color-primary-hover:  #c4b5fd;
    --shadow-sm: 0 1px 3px rgba(0,0,0,0.4);
  }
}

/* Manual toggle: class on <html> overrides media query */
html.theme-dark {
  --color-surface:        #0f172a;
  --color-surface-raised: #1e293b;
  --color-text:           #f1f5f9;
  --color-text-muted:     #94a3b8;
  --color-border:         #334155;
  --color-primary:        #a78bfa;
}

/* Components use only semantic tokens: automatically themed */
.card {
  background: var(--color-surface-raised);
  border: 1px solid var(--color-border);
  color: var(--color-text);
  border-radius: var(--radius-xl);
  padding: var(--space-4);
  box-shadow: var(--shadow-sm);
}

.btn-primary {
  background: var(--color-primary);
  color: var(--color-primary-fg, #fff);
  padding: var(--space-2) var(--space-4);
  border-radius: var(--radius-md);
}

5. Component theming: variables at the element level

CSS Custom Properties at the component level enable a theming architecture where every component defines its own interface of local variables. Instead of a long list of global colors, a button component defines local CSS Custom Properties such as --btn-bg, --btn-fg and --btn-radius, which default to global tokens: --btn-bg: var(--color-primary). Whoever uses the component can override these local variables without relying on internal selectors: .my-context { --btn-bg: var(--color-secondary) }.

This pattern, often referred to as a component's CSS API, creates a clean boundary between implementation detail (internal selectors and properties) and the public theming API (documented custom properties). Web component libraries such as Shoelace and Open UI apply this pattern systematically: every component documents its custom property API, and consumers can fully customize its appearance without touching implementation details. That is the core of scalable component architecture with CSS Custom Properties.

6. @property: typed CSS Custom Properties

@property is a CSS at-rule that registers a CSS Custom Property with a type, an initial value and an inheritance rule. Without @property, CSS Custom Properties are untyped: the browser treats their value as an arbitrary string and does not interpolate it. With @property, a property can be typed as syntax: "<color>", syntax: "<length>", syntax: "<number>", or with complex syntax strings such as "<length> | <percentage>".

Typing has three consequences. First, invalid values fall back to initial-value instead of propagating further. Second, typed CSS Custom Properties can be used inside calc() expressions without explicit unit multiplication: calc(var(--spacing) * 2) works correctly when --spacing is typed as <length>. Third, and most importantly, typed CSS Custom Properties can be animated. The browser can interpolate between two values because it knows the type. An untyped string cannot be interpolated; a typed color or length can.


/* @property: typed CSS Custom Properties */

/* Typed color: enables transition/animation */
@property --color-accent {
  syntax: "<color>";
  inherits: true;
  initial-value: #7c3aed;
}

/* Typed number: enables animation and calc() */
@property --progress {
  syntax: "<number>";
  inherits: false;
  initial-value: 0;
}

/* Typed length: safe in calc() expressions */
@property --card-padding {
  syntax: "<length>";
  inherits: false;
  initial-value: 1rem;
}

/* Use in a component */
.progress-bar {
  --progress: 0;           /* initial: 0% */
  width: calc(var(--progress) * 1%);
  background: var(--color-accent);
  transition: --progress 0.4s ease, --color-accent 0.3s ease;
  height: 4px;
  border-radius: var(--radius-full, 9999px);
}

/* Animate to 75%: browser interpolates because type is <number> */
.progress-bar.loaded {
  --progress: 75;
}

/* Color transitions work only with @property <color> type */
.theme-switcher:hover {
  --color-accent: #4a1d96;  /* smoothly transitions from #7c3aed */
}

7. @property for animatable properties

The animatability of typed CSS Custom Properties with @property opens up a whole new category of CSS animation effects. Without @property, you can reference a CSS Custom Property in transition or @keyframes, but the browser cannot interpolate the value, it jumps instantly from start to end. With @property and the right type, the browser can interpolate between two color values, two length values or two number values, exactly as it would with a native CSS property.

One particularly elegant use case is gradient animation. Without typed CSS Custom Properties, a CSS gradient cannot be animated, because gradients are not an interpolable CSS property on their own. With @property, individual color values inside the gradient can be defined as typed properties and animated separately. The result is a smoothly animated gradient, an effect that would otherwise only be achievable with JavaScript or canvas rendering. The implementation consists of two @property declarations, two CSS variable references inside the background attribute, and one @keyframes animation targeting the variables.

8. Design tokens with CSS Custom Properties

Design tokens are the atomic units of a design system: colors, spacing, font sizes and other base values that should stay consistent across the entire system. CSS Custom Properties are the natural implementation format for design tokens in the browser. The typical architecture has three layers: the bottom layer holds raw values (--violet-700: #6d28d9), the middle layer holds semantic aliases (--color-primary: var(--violet-700)), and the top layer holds component-specific tokens (--btn-bg: var(--color-primary)).

This token system can be exported directly from design tools such as Figma. Tools like Style Dictionary or Tokens Studio convert Figma variables into CSS Custom Properties. That creates an unbroken chain from the design draft all the way to the CSS implementation, without manual copying. When the designer changes the primary color in Figma, the change is automatically translated into the CSS token file and lands in the stylesheet through CI/CD. The browser sees the same CSS Custom Property structure, only the values change, never the property names.

9. Custom Properties vs. Sass variables compared

Both systems have their strengths. CSS Custom Properties are runtime-dynamic, cascade, and work without a build step. Sass variables are static, get compiled, and come with a mature toolchain. For theming systems that depend on runtime adjustment, CSS Custom Properties are indispensable. For static design decisions, both can coexist.

Feature Sass Variables CSS Custom Properties Winner
Runtime changes Not possible, compile-time only Yes, via setProperty() in JS CSS Custom Properties
Dark mode Only with a separate CSS build Native via @media dark CSS Custom Properties
Animatability Not animatable Typeable via @property CSS Custom Properties
Browser compatibility Compiled CSS, IE11-capable IE11 not supported Sass (legacy support)
Cascade scoping None, static value Yes, cascades through the DOM CSS Custom Properties

The best strategy for modern projects combines both systems: Sass variables for build-time configuration (breakpoints, grid definitions), and CSS Custom Properties for every theming value that should behave dynamically at runtime (colors, spacing, dark mode). Tailwind CSS v4 follows this path consistently: all design tokens are emitted as CSS Custom Properties on :root and can be overridden directly in the stylesheet.

Mironsoft

Design systems, theming architectures and Tailwind CSS projects

Build a theming system instead of hardcoding colors?

We build scalable CSS theming architectures with Custom Properties, design token systems and automated Figma export, for projects that stay maintainable today and extendable tomorrow.

Design Token System

Three-layer token architecture with Figma export and CSS Custom Properties

Dark Mode

CSS-only dark mode with prefers-color-scheme and manual toggle

Hyva / Tailwind

Magento 2 themes with Tailwind CSS v4 and Custom Properties as the token layer

10. Summary

CSS Custom Properties are the foundation of every modern, maintainable CSS theming system. :root as the global token scope, semantic layers over raw values, and component-specific local API variables together form a scalable architecture. Dark mode works without JavaScript, using @media (prefers-color-scheme: dark) and class toggling. @property extends the system with typed properties that are animatable and have more robust fallback mechanisms. The three-layer token architecture, raw, semantic, component-specific, keeps design decisions consistent and maintainable.

The decisive difference from Sass variables remains runtime dynamism: CSS Custom Properties can be changed with JavaScript, cascade through the DOM, and respond to CSS selectors. That makes them indispensable for responsive, accessible and maintainable design systems. Tailwind CSS v4 demonstrates this pragmatically: every design token is a CSS Custom Property, every customization happens through a CSS override on :root, no config file, no build tool, just CSS.

CSS Custom Properties: the essentials at a glance

Three-layer token system

Raw values leads to semantic aliases leads to component-specific properties. Each layer references the one below it.

Dark mode without JavaScript

@media (prefers-color-scheme: dark) overrides semantic tokens on :root. Class toggle for manual switching.

@property typing

syntax, inherits, initial-value make Custom Properties animatable and type-safe. Required for gradient animations.

Component API

Local --component variables as the public theming interface. Consumers override properties, not selectors.

11. FAQ: CSS Custom Properties for theming

1What are CSS Custom Properties?
Runtime variables with a -- prefix, used via var(). Live in the browser, cascade, and are changeable with JavaScript, unlike Sass compile-time constants.
2Custom Properties vs. Sass variables?
Sass: compile-time, static, no browser DOM. CSS Custom Properties: runtime, cascading, changeable with JavaScript.
3Dark mode with Custom Properties?
Semantic color variables on :root, overridden inside @media (prefers-color-scheme: dark). All components react automatically. No JavaScript.
4What does @property do?
Registers a custom property with a type (syntax), inheritance (inherits) and an initial value. Typed properties can be animated.
5Fallback values in var()?
var(--my-var, fallback), used when the variable is undefined or invalid. Nestable: var(--a, var(--b, literal-value)).
6Change Custom Properties with JavaScript?
document.documentElement.style.setProperty('--var', 'value') for global properties. element.style.setProperty() for local ones.
7Animate Custom Properties?
Only with @property and the correct syntax type. Without typing, values jump instantly instead of interpolating.
8What is component theming?
A component defines local --component variables as its public API. Consumers override these, not internal selectors.
9Tailwind CSS v4 and Custom Properties?
Tailwind v4 emits all design tokens as custom properties on :root. Customization happens via @theme or a direct CSS override, no config file needed.
10Build a design token system?
Three layers: raw values leads to semantic aliases leads to component-specific tokens. Each layer references the one below it.