The double-dash prefix is easy to recognize, but the mechanics behind it are frequently misunderstood. CSS custom properties — defined in the CSS Custom Properties for Cascading Variables Module Level 1 — are not just a native substitute for Sass variables. They behave differently at a fundamental level, and those differences are what make patterns like dynamic theming, design token architectures, and runtime JavaScript integration possible.

How Custom Properties Differ from Preprocessor Variables

Sass, Less, and Stylus variables are resolved at compile time. By the time a browser receives the stylesheet, those variables have already been substituted with their literal values. There is no variable in the delivered CSS — only the result.

Custom properties work at the other end of the pipeline. They exist in the computed style of the document, are resolved by the browser’s style engine, and can change value at runtime without recompiling anything. Three practical consequences follow from this:

The cascade applies. A custom property declared on an element can be overridden by a more specific selector, just like color or font-size. This is not a side effect — it is the intended behavior, and it is what makes component-level theming tractable.

Inheritance applies by default. Custom properties inherit through the DOM tree unless explicitly suppressed. A value set on body is available on every descendant element unless a closer ancestor overrides it.

JavaScript can read and write them. Because custom properties live in the computed style, getComputedStyle and element.style.setProperty operate on them directly. No build step, no class toggling required.

Declaration and var() Syntax

A custom property is any property whose name begins with two hyphens:

:root {
  --color-brand: #2563eb;
  --spacing-base: 1rem;
  --font-heading: 'Inter', sans-serif;
}

Custom property names are case-sensitive. --Color-Brand and --color-brand are distinct properties.

Values are referenced with var():

.button {
  background-color: var(--color-brand);
  padding: var(--spacing-base);
}

var() accepts a second argument as a fallback, used when the referenced property is not set or is invalid at the point of use:

.card {
  border-radius: var(--radius-card, 8px);
}

Fallbacks can themselves contain var() references, allowing chains:

color: var(--color-text-primary, var(--color-text, #111));

One common source of confusion: a custom property set to an empty value is considered set. The fallback is only used when the property is entirely absent or the value is the keyword initial.

Cascade and Inheritance: The Core Insight

The specification section that most developers skip is the one that explains custom properties participate in the cascade identically to any other property. Specificity, order of appearance, and the !important flag all apply.

This means a custom property can be scoped to a component simply by declaring it on the component’s root element:

/* Global default */
:root {
  --button-bg: #2563eb;
  --button-color: #fff;
}

/* Variant: the cascade narrows the scope */
.button--danger {
  --button-bg: #dc2626;
}

.button {
  background-color: var(--button-bg);
  color: var(--button-color);
}

The .button--danger selector does not need to re-declare --button-color because the inherited value from :root still reaches it. Only the overridden property changes. This is a more maintainable pattern than writing full variant rulesets that duplicate every property.

Inheritance also means custom properties flow downward through the DOM automatically. A theme class applied to a section element will affect all descendant components without those components needing to know the theme exists:

.theme-ocean {
  --color-brand: #0891b2;
  --color-surface: #ecfeff;
}

Any component inside an element carrying .theme-ocean will pick up these values through normal inheritance.

Scope Patterns: Global, Component, and Local

The :root pseudo-class targets the document root element (<html> in HTML documents) and has the highest specificity among element selectors, making it the conventional place for global defaults. However, :root is not the only valid scope.

Global scope is appropriate for design tokens that should be universally available: brand colors, type scale values, spacing units, motion durations.

Component scope uses the component’s own selector as the declaration context. Values set here override the global defaults for that component’s subtree only:

.sidebar {
  --color-surface: #f8fafc;
  --spacing-base: 0.75rem;
}

Local scope — using & or a specific sub-element selector — narrows further, useful for state variations or slot-specific overrides inside a component.

A pattern worth knowing: custom properties can be declared with no value intentionally, signaling that a component expects the value to be provided by its context. This is sometimes called a “component API” approach, where the component defines which custom properties it responds to without hardcoding defaults at the component level. The fallback in var() then serves as the ultimate default if nothing in the tree sets the value.

Custom Properties and @layer

CSS cascade layers, introduced in the Cascade 5 specification, add a new dimension to how rules are ordered. Custom property declarations participate in layer ordering the same way any declaration does.

A token defined in a base layer can be overridden by the same property in a theme layer without specificity changes:

@layer base {
  :root {
    --color-brand: #2563eb;
  }
}

@layer theme {
  :root {
    --color-brand: #7c3aed;
  }
}

Because the theme layer is declared after base (and thus has higher priority among unlayered styles in the cascade), the purple value wins regardless of selector specificity. This makes @layer a clean mechanism for separating token sources — third-party design systems in a lower layer, project-level overrides in a higher one.

Design Token Architecture

The most durable use of custom properties in production is as a design token system. The conventional three-level hierarchy moves from primitive values through semantic aliases to component-specific bindings.

Primitive tokens encode raw values with no semantic meaning attached:

:root {
  /* Color primitives */
  --blue-500: #3b82f6;
  --blue-600: #2563eb;
  --blue-700: #1d4ed8;

  /* Spacing primitives */
  --space-4: 1rem;
  --space-6: 1.5rem;
}

Semantic tokens reference primitives and assign meaning:

:root {
  --color-action: var(--blue-600);
  --color-action-hover: var(--blue-700);
  --color-action-focus-ring: var(--blue-500);

  --spacing-component-padding: var(--space-4);
}

Component tokens bind semantic values to specific UI contexts:

.button {
  --button-bg: var(--color-action);
  --button-bg-hover: var(--color-action-hover);

  background-color: var(--button-bg);
  padding: var(--spacing-component-padding) calc(var(--spacing-component-padding) * 1.5);
}

This layering means a brand color change touches one primitive token. A semantic meaning change (say, the “action” color shifts from blue to indigo for a rebrand) touches one semantic token. Component tokens remain untouched in either case because they reference semantics, not primitives directly. The web typography and visual hierarchy patterns we cover elsewhere follow the same token discipline for type and spacing scales.

Theming Patterns

Dark Mode

The prefers-color-scheme media query combined with custom properties is the most maintainable dark mode implementation available. Define semantic color tokens in both contexts; the components themselves never change:

:root {
  --color-surface: #ffffff;
  --color-text-primary: #111827;
  --color-border: #e5e7eb;
}

@media (prefers-color-scheme: dark) {
  :root {
    --color-surface: #111827;
    --color-text-primary: #f9fafb;
    --color-border: #374151;
  }
}

A user-controlled theme toggle works the same way, substituting a class on <html> for the media query:

html.theme-dark {
  --color-surface: #111827;
  --color-text-primary: #f9fafb;
  --color-border: #374151;
}

Brand Theming

Multi-tenant applications or white-label products often need to swap brand colors per customer. With custom properties, this becomes a single block of overrides injected per context — either via a class, a data attribute, or dynamically through JavaScript:

[data-brand="acme"] {
  --color-brand: #e11d48;
  --color-brand-subtle: #fff1f2;
}

[data-brand="globex"] {
  --color-brand: #16a34a;
  --color-brand-subtle: #f0fdf4;
}

No component code changes. The CSS cascade architecture article explores how @layer integrates with this kind of multi-source theming to avoid specificity conflicts.

JavaScript Integration

Because custom properties are part of the computed style, JavaScript can interact with them directly using the standard CSSOM API.

Reading a custom property value:

const root = document.documentElement;
const value = getComputedStyle(root).getPropertyValue('--color-brand').trim();
// Returns the resolved string value, e.g. "#2563eb"

Writing a custom property value:

root.style.setProperty('--color-brand', '#7c3aed');

Removing an override (restores the inherited or cascaded value):

root.style.removeProperty('--color-brand');

This API is available on any element, not just the document root. Setting a custom property on a specific element scopes that override to that element’s subtree, matching exactly how cascade inheritance works in CSS.

Practical applications include: persisting a user’s preferred theme to localStorage and restoring it on load, animating token values (when combined with @property), or reading design token values in JavaScript-driven canvas or SVG rendering without duplicating the values outside the stylesheet.

@property: Typed Custom Properties

The @property at-rule, now available in all major browsers as of late 2023, allows custom properties to be declared with explicit type information, an initial value, and an inheritance setting:

@property --hue {
  syntax: '<number>';
  inherits: true;
  initial-value: 220;
}

@property --card-opacity {
  syntax: '<number>';
  inherits: false;
  initial-value: 1;
}

Typed custom properties unlock two things untyped properties cannot do:

CSS transitions and animations. Untyped custom properties transition as discrete values (a hard flip, not an interpolation) because the browser does not know how to interpolate an arbitrary string. A property declared as <number> or <color> can be transitioned and animated smoothly:

@property --highlight-hue {
  syntax: '<number>';
  inherits: false;
  initial-value: 220;
}

.card {
  background-color: hsl(var(--highlight-hue) 70% 50%);
  transition: --highlight-hue 0.3s ease;
}

.card:hover {
  --highlight-hue: 280;
}

Without @property, this transition produces an instant jump. With it, the hue value interpolates across the transition duration.

Reliable initial values. An untyped custom property with no set value resolves to an empty token, which can invalidate declarations that use it. @property provides a guaranteed fallback at the property definition level, separate from the var() fallback mechanism.

The inherits: false setting is also significant for component isolation: it prevents a value set on a parent from bleeding into a child component that defines the same property for its own purposes.

Common Mistakes

Using var() inside calc() with unit mismatches. A custom property holding a unitless number cannot be used where a length is expected unless the multiplication provides the unit: calc(var(--spacing-multiplier) * 1rem). A property holding 16px cannot be multiplied by 1rem — units combine. Keeping primitives unitless when they represent scalars and semantic tokens fully-unitized avoids most of these errors.

Expecting var() to work in media query conditions. Custom properties resolve against an element’s computed style. Media queries are not element-scoped — they have no element context. @media (max-width: var(--breakpoint-md)) does not work and is explicitly excluded by the specification. Use @custom-media (from the Media Queries Level 5 draft) or define breakpoints as constants in JavaScript if cross-context sharing is needed.

Treating custom properties as encapsulated. They are not. A custom property declared on :root is available everywhere in the document. If a component declares --size for internal use, any ancestor that also declares --size will override it. Namespacing with a component prefix (--button-size, --card-size) is the conventional solution.

Understanding these mechanics — particularly that custom properties are full participants in the cascade — changes how you reach for them. They are not just a way to avoid repeating a hex code. They are a structural tool for building stylesheets that adapt to context without requiring explicit coordination between every layer of the UI.