Skip to content

CSS Custom Properties

beginner11 min read

Variables That Live in the Browser

Sass variables are dead after compilation -- they resolve to static values at build time and disappear. CSS custom properties? They're alive at runtime. They inherit through the DOM tree, respond to media queries, update with JavaScript, and cascade like any other property. This makes them fundamentally different from preprocessor variables and unlocks patterns that Sass literally can't touch.

Mental Model

Think of CSS custom properties as genetic traits. A parent element defines a trait (--color: blue). Every descendant inherits it automatically unless they override it with their own value. Just like genetics, you can trace any element's variable value by walking up its ancestor chain until you find the definition. The "gene" is the variable, the "expression" is the computed value at each element.

Defining and Using Custom Properties

:root {
  --color-primary: oklch(0.55 0.19 240);
  --color-text: #1a1a2e;
  --spacing-md: 1rem;
  --radius-lg: 12px;
  --font-body: 'Inter', system-ui, sans-serif;
}

.card {
  color: var(--color-text);
  padding: var(--spacing-md);
  border-radius: var(--radius-lg);
  font-family: var(--font-body);
}

Fallback Values

.card {
  /* Fallback if --card-bg is not defined */
  background: var(--card-bg, white);

  /* Nested fallback */
  color: var(--card-text, var(--color-text, #333));

  /* WRONG: Fallback is everything after the first comma */
  background: var(--gradient, linear-gradient(red, blue));
  /* This works! The fallback is "linear-gradient(red, blue)" */
}
Common Trap

The fallback in var() is everything after the first comma — including additional commas. var(--font, Arial, sans-serif) has a fallback of Arial, sans-serif, not just Arial. This is actually useful for font stacks but can be confusing for other values.

Inheritance and Scoping

Custom properties inherit just like color or font-size:

:root {
  --text: black;
}

.dark-section {
  --text: white; /* Override for this subtree */
}

/* Both use var(--text), but resolve to different values */
.dark-section p { color: var(--text); } /* white */
.light-section p { color: var(--text); } /* black (inherited from :root) */

Component-Level Scoping

/* The component defines its own custom property API */
.button {
  --btn-bg: var(--color-primary);
  --btn-text: white;
  --btn-padding: 0.75rem 1.5rem;
  --btn-radius: 8px;

  background: var(--btn-bg);
  color: var(--btn-text);
  padding: var(--btn-padding);
  border-radius: var(--btn-radius);
}

/* Variants override the component's variables */
.button--danger {
  --btn-bg: var(--color-danger, #ef4444);
}

.button--ghost {
  --btn-bg: transparent;
  --btn-text: var(--color-primary);
}

/* External override from a parent context */
.dark-theme .button {
  --btn-bg: oklch(0.35 0.15 240);
}

Runtime vs Compile-Time: CSS Variables vs Sass

This is a distinction worth really nailing down.

FeatureCSS Custom PropertiesSass Variables
ResolvedRuntime (in browser)Compile time (build)
InheritYes — through DOM treeNo — lexical scope only
Media queriesRespond to @mediaCannot change at runtime
JavaScriptRead/write via CSSOMNot accessible
CascadeYes — subject to specificityNo — last assignment wins
CalculationsLimited (no string concat)Full programmatic logic
/* This is impossible with Sass — runtime media response */
:root {
  --grid-cols: 1;
}

@media (min-width: 640px) { :root { --grid-cols: 2; } }
@media (min-width: 1024px) { :root { --grid-cols: 3; } }

.grid {
  display: grid;
  grid-template-columns: repeat(var(--grid-cols), 1fr);
}

Theming System

This is where custom properties really shine. Let's build a theme switcher.

Light/Dark Theme with Custom Properties

:root {
  /* Light theme (default) */
  --bg: #ffffff;
  --bg-surface: #f8f9fa;
  --text-primary: #1a1a2e;
  --text-secondary: #6b7280;
  --border: #e5e7eb;
  --shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}

@media (prefers-color-scheme: dark) {
  :root {
    --bg: #0f0f1a;
    --bg-surface: #1a1a2e;
    --text-primary: #e5e7eb;
    --text-secondary: #9ca3af;
    --border: #374151;
    --shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
  }
}

/* Manual override via data attribute */
[data-theme="dark"] {
  --bg: #0f0f1a;
  --bg-surface: #1a1a2e;
  --text-primary: #e5e7eb;
  --text-secondary: #9ca3af;
  --border: #374151;
  --shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
}

JavaScript Integration

// Read a custom property
const primary = getComputedStyle(document.documentElement)
  .getPropertyValue('--color-primary');

// Set a custom property
document.documentElement.style.setProperty('--color-primary', '#ff6600');

// Set on a specific element (scoped)
card.style.setProperty('--card-bg', 'lightblue');

// Toggle theme
document.documentElement.setAttribute('data-theme',
  currentTheme === 'light' ? 'dark' : 'light'
);
The @property rule — typed custom properties

By default, custom properties are strings with no type information. The @property rule lets you define type, initial value, and inheritance:

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

/* Now the browser can ANIMATE this property */
.spinner {
  transform: rotate(var(--rotation));
  transition: --rotation 0.5s ease;
}
.spinner:hover {
  --rotation: 360deg; /* Smooth rotation animation */
}

Without @property, the browser can't animate custom properties — it doesn't know they represent angles, colors, or lengths. With @property, smooth transitions work because the browser understands the value type.

Execution Trace
Define
:root { --color: blue }
Custom property set on the root element
Inherit
.card inherits --color from :root
Custom properties inherit like color or font-size
Override
.dark-section { --color: white }
Descendants of .dark-section see white
Resolve
var(--color) resolves per element
Each element walks up its ancestor chain
Fallback
var(--missing, red) resolves to red
Fallback used when property is not defined in any ancestor

Production Scenario: Design Token System

This is how the pros structure it in production:

/* Layer 1: Primitive tokens (raw values) */
:root {
  --gray-50: oklch(0.97 0 0);
  --gray-100: oklch(0.93 0 0);
  --gray-900: oklch(0.15 0 0);
  --blue-500: oklch(0.55 0.19 240);
  --red-500: oklch(0.55 0.20 25);
}

/* Layer 2: Semantic tokens (purpose-driven) */
:root {
  --color-bg: var(--gray-50);
  --color-text: var(--gray-900);
  --color-primary: var(--blue-500);
  --color-danger: var(--red-500);
  --color-border: var(--gray-100);
}

/* Layer 3: Component tokens (scoped) */
.button {
  --btn-bg: var(--color-primary);
  --btn-text: white;
}

.input {
  --input-border: var(--color-border);
  --input-focus: var(--color-primary);
}
What developers doWhat they should do
Defining all variables on :root when they're component-specific
Component-scoped variables are easier to maintain and don't pollute the global namespace
Scope component variables to the component selector. Use :root only for global tokens.
Expecting var(--undefined) to use the property's initial value
var(--undefined) is 'guaranteed-invalid'. Use var(--prop, fallback) for safety.
An undefined custom property makes the entire value invalid, triggering the CSS property's inherited or initial value
Trying to concatenate strings with custom properties
var(--prefix)value doesn't work. CSS custom properties are substituted as-is.
Custom properties can't do string operations. Use them for complete values, or combine with calc() for numbers.
Using Sass variables where runtime responsiveness is needed
Sass variables are resolved at build time and can't change in the browser
Use CSS custom properties for anything that changes based on theme, viewport, or user interaction
Quiz
A custom property --gap is defined on :root as 1rem and overridden in .sidebar as 0.5rem. What gap does a paragraph inside .sidebar use?
Quiz
Can you animate a transition on a CSS custom property without @property?
Key Rules
  1. 1CSS custom properties inherit through the DOM tree — scope them intentionally
  2. 2Use var(--prop, fallback) to handle undefined properties gracefully
  3. 3Custom properties are runtime values — use them for themes, responsive behavior, and JS integration
  4. 4Use @property to enable animation/transition of custom properties
  5. 5Build token systems in layers: primitive → semantic → component