CSSbeginner

CSS Variables & Custom Properties

Learn CSS custom properties (variables): defining, scoping, fallback values, JavaScript interaction, theming, dark mode, and practical patterns for maintainable stylesheets.

11 min readยทPublished Apr 5, 2026
cssvariablescustom-propertiestheming

What Are CSS Variables?

CSS custom properties (commonly called CSS variables) let you store values and reuse them throughout your stylesheet. They're native to CSS โ€” no preprocessor needed.

:root {
  --primary-color: #3b82f6;
  --spacing-md: 1rem;
  --border-radius: 8px;
}

.button {
  background: var(--primary-color);
  padding: var(--spacing-md);
  border-radius: var(--border-radius);
}

Change --primary-color in one place and every element using it updates. No find-and-replace across files.

CSS Variables vs Preprocessor Variables

/* Sass variable โ€” compiled away, doesn't exist at runtime */
$primary: #3b82f6;
.button { background: $primary; }
/* Output: .button { background: #3b82f6; } */

/* CSS variable โ€” exists at runtime, can be changed dynamically */
:root { --primary: #3b82f6; }
.button { background: var(--primary); }
/* Output: exactly as written, variable resolved by browser */

Key differences:

FeatureCSS VariablesSass Variables
RuntimeYesNo (compiled away)
ScopeCascading (DOM tree)Block scope (Sass)
JavaScript accessYesNo
Media query responsiveYesNo
Dynamic themesYesNo (without recompilation)
Fallback valuesBuilt-inN/A
ComputationLimited (calc)Full (math, loops, functions)

Defining CSS Variables

Syntax

Variable names start with -- (two dashes):

:root {
  /* Colors */
  --color-primary: #3b82f6;
  --color-secondary: #6b7280;
  --color-success: #22c55e;
  --color-danger: #ef4444;

  /* Typography */
  --font-family: 'Inter', sans-serif;
  --font-size-base: 1rem;
  --font-size-lg: 1.25rem;
  --line-height: 1.5;

  /* Spacing */
  --space-xs: 0.25rem;
  --space-sm: 0.5rem;
  --space-md: 1rem;
  --space-lg: 1.5rem;
  --space-xl: 2rem;

  /* Shadows */
  --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
  --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
  --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);

  /* Transitions */
  --transition-fast: 0.15s ease;
  --transition-base: 0.3s ease;
}

Using Variables

.card {
  font-family: var(--font-family);
  padding: var(--space-lg);
  border-radius: 8px;
  box-shadow: var(--shadow-md);
  background: white;
}

.card-title {
  font-size: var(--font-size-lg);
  color: var(--color-primary);
  margin-bottom: var(--space-sm);
}

Naming Conventions

There's no official standard, but common patterns:

/* Functional naming (by purpose) */
--color-primary: #3b82f6;
--color-text: #1a1a1a;
--color-background: #ffffff;

/* Semantic naming */
--color-brand: #3b82f6;
--color-error: #ef4444;
--color-success: #22c55e;

/* Scale naming */
--gray-100: #f7f7f7;
--gray-200: #e5e5e5;
--gray-300: #d4d4d4;
--gray-900: #1a1a1a;

/* Component-prefixed */
--btn-bg: var(--color-primary);
--btn-text: white;
--btn-radius: 6px;

A useful pattern: define raw values, then create semantic aliases:

:root {
  /* Raw palette */
  --blue-500: #3b82f6;
  --blue-600: #2563eb;
  --gray-900: #1a1a1a;
  --white: #ffffff;

  /* Semantic tokens (reference raw values) */
  --color-primary: var(--blue-500);
  --color-primary-hover: var(--blue-600);
  --color-text: var(--gray-900);
  --color-bg: var(--white);
}

Variable Scope

CSS variables follow the cascade โ€” they're inherited by descendant elements. This is their superpower.

Global Scope

:root makes variables available everywhere:

:root {
  --color-primary: #3b82f6;
}

/* Available in any element */
.header { color: var(--color-primary); }
.footer { color: var(--color-primary); }
.sidebar { color: var(--color-primary); }

Local Scope

Variables defined on a specific element are available only to that element and its descendants:

.card {
  --card-padding: 1.5rem;
  --card-radius: 8px;
  padding: var(--card-padding);
  border-radius: var(--card-radius);
}

.card-compact {
  --card-padding: 0.75rem;  /* override for this variant */
  --card-radius: 4px;
}

/* This won't work โ€” --card-padding isn't available outside .card */
.header {
  padding: var(--card-padding); /* undefined โ€” uses fallback or initial */
}

Overriding Variables

Variables can be overridden at any level of the DOM tree:

:root {
  --text-color: #1a1a1a;
}

.dark-section {
  --text-color: #e5e5e5;
}

p {
  color: var(--text-color);
}
<p>Dark text (inherits from :root)</p>

<section class="dark-section">
  <p>Light text (inherits overridden value)</p>
</section>

This is how CSS variable-based theming works โ€” override variables at the right scope and everything below updates.

Inheritance Chain

:root        { --size: 16px; }
.section     { --size: 18px; }
.section .box { /* inherits --size: 18px from .section */ }
.box          { /* inherits --size: 16px from :root */ }
:root (--size: 16px)
  |
  +-- .section (--size: 18px)
  |     |
  |     +-- .box (inherits: 18px)
  |
  +-- .box (inherits: 16px)

Fallback Values

The var() function accepts a fallback value as a second argument:

.element {
  /* If --color-accent is undefined, use #3b82f6 */
  color: var(--color-accent, #3b82f6);

  /* Fallback can be another variable */
  color: var(--color-brand, var(--color-primary, blue));

  /* Fallback for complex values */
  box-shadow: var(--shadow, 0 2px 4px rgba(0, 0, 0, 0.1));
}

Important: a fallback is only used when the variable is not defined (doesn't exist in the scope). If the variable is defined but set to an invalid value for the property, the fallback is NOT used โ€” the property becomes unset instead.

:root {
  --color: not-a-color;  /* defined but invalid */
}

.element {
  color: var(--color, blue);
  /* Does NOT use blue as fallback */
  /* --color IS defined (just invalid), so fallback is skipped */
  /* color becomes the inherited value (unset) */
}

Fallback Patterns

/* Component with optional customization */
.button {
  background: var(--btn-bg, var(--color-primary, #3b82f6));
  color: var(--btn-text, white);
  padding: var(--btn-padding, 0.5rem 1rem);
}

/* Usage: customize per instance */
.cta-section .button {
  --btn-bg: #22c55e;
  --btn-text: white;
}

Using Variables with calc()

CSS variables work inside calc() for dynamic computations:

:root {
  --base-size: 1rem;
  --scale: 1.25;
  --columns: 3;
  --gap: 1rem;
}

h1 { font-size: calc(var(--base-size) * var(--scale) * var(--scale) * var(--scale)); }
h2 { font-size: calc(var(--base-size) * var(--scale) * var(--scale)); }
h3 { font-size: calc(var(--base-size) * var(--scale)); }

.grid-item {
  width: calc((100% - (var(--columns) - 1) * var(--gap)) / var(--columns));
}

Spacing Scale with calc

:root {
  --space-unit: 0.25rem;
}

.mt-1 { margin-top: calc(var(--space-unit) * 1); }  /* 0.25rem */
.mt-2 { margin-top: calc(var(--space-unit) * 2); }  /* 0.5rem */
.mt-4 { margin-top: calc(var(--space-unit) * 4); }  /* 1rem */
.mt-6 { margin-top: calc(var(--space-unit) * 6); }  /* 1.5rem */
.mt-8 { margin-top: calc(var(--space-unit) * 8); }  /* 2rem */

JavaScript Interaction

CSS variables are live in the DOM. JavaScript can read and write them at runtime.

Reading Variables

// Get a variable from any element
const root = document.documentElement;
const primaryColor = getComputedStyle(root).getPropertyValue('--color-primary');
// Returns: " #3b82f6" (note: may have leading space)

// Trimmed
const color = getComputedStyle(root).getPropertyValue('--color-primary').trim();

Writing Variables

// Set a variable on the root element (global)
document.documentElement.style.setProperty('--color-primary', '#ef4444');

// Set on a specific element (local scope)
const card = document.querySelector('.card');
card.style.setProperty('--card-bg', '#f0f0f0');

// Remove a variable
document.documentElement.style.removeProperty('--color-primary');

Dynamic Theming with JavaScript

function setTheme(theme) {
  const root = document.documentElement;

  if (theme === 'dark') {
    root.style.setProperty('--color-bg', '#1a1a1a');
    root.style.setProperty('--color-text', '#e5e5e5');
    root.style.setProperty('--color-surface', '#2a2a2a');
  } else {
    root.style.setProperty('--color-bg', '#ffffff');
    root.style.setProperty('--color-text', '#1a1a1a');
    root.style.setProperty('--color-surface', '#f5f5f5');
  }
}

Mouse-Following Effects

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

Scroll-Based Animation

window.addEventListener('scroll', () => {
  const scrollPercent = window.scrollY / (document.body.scrollHeight - window.innerHeight);
  document.documentElement.style.setProperty('--scroll', scrollPercent.toFixed(3));
});
.progress-bar {
  transform: scaleX(var(--scroll, 0));
  transform-origin: left;
}

Dark Mode Implementation

CSS variables are the cleanest way to implement dark mode.

Method 1: Media Query (System Preference)

:root {
  /* Light theme (default) */
  --color-bg: #ffffff;
  --color-text: #1a1a1a;
  --color-surface: #f5f5f5;
  --color-border: #e5e5e5;
  --color-primary: #3b82f6;
  --shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
}

@media (prefers-color-scheme: dark) {
  :root {
    --color-bg: #0f172a;
    --color-text: #e2e8f0;
    --color-surface: #1e293b;
    --color-border: #334155;
    --color-primary: #60a5fa;
    --shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
  }
}

body {
  background: var(--color-bg);
  color: var(--color-text);
}

Method 2: Class Toggle (User Controlled)

:root {
  --color-bg: #ffffff;
  --color-text: #1a1a1a;
}

:root.dark {
  --color-bg: #0f172a;
  --color-text: #e2e8f0;
}

/* Or using data attribute */
[data-theme="dark"] {
  --color-bg: #0f172a;
  --color-text: #e2e8f0;
}
// Toggle dark mode
function toggleDarkMode() {
  document.documentElement.classList.toggle('dark');
  const isDark = document.documentElement.classList.contains('dark');
  localStorage.setItem('theme', isDark ? 'dark' : 'light');
}

// Load saved preference
const savedTheme = localStorage.getItem('theme');
if (savedTheme === 'dark') {
  document.documentElement.classList.add('dark');
}

Method 3: Hybrid (System + User Override)

:root {
  --color-bg: #ffffff;
  --color-text: #1a1a1a;
}

/* System dark mode */
@media (prefers-color-scheme: dark) {
  :root:not(.light) {
    --color-bg: #0f172a;
    --color-text: #e2e8f0;
  }
}

/* Explicit dark override */
:root.dark {
  --color-bg: #0f172a;
  --color-text: #e2e8f0;
}

/* Explicit light override */
:root.light {
  --color-bg: #ffffff;
  --color-text: #1a1a1a;
}

Full Theme Example

:root {
  /* Color palette (unchanging) */
  --blue-500: #3b82f6;
  --blue-400: #60a5fa;
  --red-500: #ef4444;
  --green-500: #22c55e;

  /* Semantic tokens (change per theme) */
  --color-bg: #ffffff;
  --color-bg-secondary: #f9fafb;
  --color-text: #111827;
  --color-text-secondary: #6b7280;
  --color-border: #e5e7eb;
  --color-primary: var(--blue-500);
  --color-primary-text: #ffffff;
  --color-error: var(--red-500);
  --color-success: var(--green-500);

  /* Component tokens */
  --card-bg: var(--color-bg);
  --card-border: var(--color-border);
  --card-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
  --input-bg: var(--color-bg);
  --input-border: var(--color-border);
}

:root.dark {
  --color-bg: #0f172a;
  --color-bg-secondary: #1e293b;
  --color-text: #f1f5f9;
  --color-text-secondary: #94a3b8;
  --color-border: #334155;
  --color-primary: var(--blue-400);

  --card-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
}

Practical Patterns

Design Token System

:root {
  /* Primitive tokens */
  --size-1: 0.25rem;
  --size-2: 0.5rem;
  --size-3: 0.75rem;
  --size-4: 1rem;
  --size-6: 1.5rem;
  --size-8: 2rem;
  --size-12: 3rem;

  /* Semantic tokens */
  --space-inline: var(--size-4);
  --space-block: var(--size-6);
  --radius-sm: var(--size-1);
  --radius-md: var(--size-2);
  --radius-lg: var(--size-3);
}

Component Customization API

/* Button component โ€” exposes customization variables */
.button {
  --_bg: var(--btn-bg, var(--color-primary));
  --_text: var(--btn-text, white);
  --_padding-x: var(--btn-px, 1rem);
  --_padding-y: var(--btn-py, 0.5rem);
  --_radius: var(--btn-radius, 6px);

  background: var(--_bg);
  color: var(--_text);
  padding: var(--_padding-y) var(--_padding-x);
  border-radius: var(--_radius);
  border: none;
  cursor: pointer;
}

/* Variant: danger button */
.button-danger {
  --btn-bg: var(--color-error);
}

/* Context: larger buttons in hero section */
.hero .button {
  --btn-px: 2rem;
  --btn-py: 0.75rem;
}

The --_ prefix convention (private variables) comes from the Open UI community. It indicates variables meant for internal use only.

Responsive Variables with Media Queries

:root {
  --container-padding: 1rem;
  --grid-columns: 1;
  --heading-size: 1.5rem;
}

@media (min-width: 768px) {
  :root {
    --container-padding: 2rem;
    --grid-columns: 2;
    --heading-size: 2rem;
  }
}

@media (min-width: 1024px) {
  :root {
    --container-padding: 3rem;
    --grid-columns: 3;
    --heading-size: 2.5rem;
  }
}

.container {
  padding: 0 var(--container-padding);
}

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

h1 {
  font-size: var(--heading-size);
}

This is powerful โ€” Sass variables can't do this because they're compiled away before the browser sees them.

Animation with Variables

@keyframes slideIn {
  from {
    transform: translateX(var(--slide-from, -100%));
    opacity: 0;
  }
  to {
    transform: translateX(0);
    opacity: 1;
  }
}

.slide-left  { --slide-from: -100%; animation: slideIn 0.3s ease; }
.slide-right { --slide-from: 100%;  animation: slideIn 0.3s ease; }
.slide-up    { --slide-from: -100%; animation: slideIn 0.3s ease; }

Limitations

Can't Use Variables in Media Queries

:root { --breakpoint: 768px; }

/* THIS DOESN'T WORK */
@media (min-width: var(--breakpoint)) { }

/* Variables in media queries aren't supported because media queries
   are evaluated before the DOM exists */

Can't Use as Property Names

:root { --my-property: color; }

/* THIS DOESN'T WORK */
.element { var(--my-property): red; }

Can't Use Partial Values

:root { --size: 16; }

/* THIS DOESN'T WORK */
.element { font-size: var(--size)px; }

/* DO THIS */
:root { --size: 16px; }
.element { font-size: var(--size); }

/* OR use calc for unitless values */
:root { --scale: 1.5; }
.element { font-size: calc(var(--scale) * 1rem); }

Browser Support

CSS custom properties are supported in all modern browsers since 2017:

BrowserVersion
Chrome49+
Firefox31+
Safari9.1+
Edge15+

No polyfill needed for modern projects.

Key Takeaways

  • CSS variables are defined with --name and accessed with var(--name).
  • They cascade through the DOM โ€” children inherit parent variables.
  • Use :root for global variables, element selectors for local scope.
  • var(--name, fallback) provides a fallback if the variable is undefined.
  • Variables work inside calc() for dynamic computations.
  • JavaScript can read and write CSS variables at runtime with getPropertyValue and setProperty.
  • Dark mode: define semantic color variables and override them per theme.
  • Variables can change per media query โ€” something preprocessor variables can't do.
  • Limitations: can't use in media query conditions, can't use as property names, can't concatenate with units directly.
  • Use a layered token system: primitive values -> semantic tokens -> component tokens.
  • CSS variables are supported in all modern browsers. No polyfill needed.

Found this helpful?

Support devsofus โ€” help us keep creating free dev guides.

Related Articles