Goswami Digital WorldBlog & Insights
All PostsMain SiteContact Us
← All Posts·Web Development

CSS Custom Properties (Variables) — The Complete Deep-Dive Guide

Master CSS Custom Properties from scratch — syntax, scope, dynamic theming, dark mode, component systems, JavaScript integration, and advanced patterns used in professional projects.

A
Ankur Goswami
22 April 2026 · 13 min read
👁views|
❤️likes|
🔗shares
#css#custom-properties#variables#theming#frontend

Introduction

CSS Custom Properties — commonly called CSS Variables — are one of the most transformative features ever added to CSS. They're not just a way to avoid repeating color values. They're a runtime system that enables dynamic theming, component-scoped design tokens, JavaScript-CSS communication, and much more.

Unlike Sass/LESS variables that compile away at build time, CSS Custom Properties live in the browser. They respond to DOM changes, media queries, JavaScript, and user interactions — in real time.

In this guide, we go deep. By the end, you'll be using Custom Properties to build complete design token systems, theme switchers, and dynamic component APIs.


1. The Syntax — Declaring and Using Variables

Declaring a Custom Property

Custom properties always start with two dashes (--). They're declared inside a selector block.

:root {
  --color-primary: #7c3aed;
  --font-size-base: 1rem;
  --spacing-md: 16px;
  --border-radius: 8px;
}

:root is the document root (same as html, but higher specificity). Variables declared here are globally available everywhere.

Using a Custom Property with var()

.btn {
  background-color: var(--color-primary);
  font-size: var(--font-size-base);
  padding: var(--spacing-md);
  border-radius: var(--border-radius);
}

Fallback Values

var() accepts a second argument as a fallback — used when the variable isn't defined.

.btn {
  color: var(--btn-color, white);
  /* If --btn-color isn't set, use white */
}

.card {
  padding: var(--card-padding, var(--spacing-md, 16px));
  /* Nested fallbacks: tries --card-padding, then --spacing-md, then 16px */
}

2. Scope — Where Variables Live

Unlike Sass variables (file-scoped), CSS Custom Properties follow the cascade and inheritance. A variable declared on an element is available to that element and all its descendants.

/* Global scope */
:root {
  --color: blue;
}

/* Component scope */
.card {
  --color: red;
  /* --color is red inside .card and all its children */
}

/* All <p> inside .card will use red */
.card p {
  color: var(--color); /* red */
}

/* All <p> outside .card will use blue */
p {
  color: var(--color); /* blue */
}

This is incredibly powerful for building component-level theming:

/* Button base — uses variables */
.btn {
  background: var(--btn-bg, var(--color-primary));
  color: var(--btn-color, white);
  padding: var(--btn-padding, 10px 24px);
  border-radius: var(--btn-radius, 6px);
}

/* Variant — just override the variables */
.btn-danger {
  --btn-bg: #e53935;
}

.btn-large {
  --btn-padding: 14px 32px;
  --btn-radius: 10px;
}

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

Now you can mix and match:

<button class="btn btn-danger btn-large">Delete Account</button>

3. Invalid Values and the Initial / Unset Trick

If a custom property is declared but has an invalid value for the property using it, the browser uses the inherited value (if the property inherits) or the initial value.

:root {
  --size: "large"; /* Not a valid length */
}

.box {
  width: var(--size); /* Fails — uses initial: auto */
}

The Space Trick — Toggling Variables

You can toggle states using a space as a "null-like" value:

.btn {
  --is-disabled: ; /* Space = "off" state */

  /* These create logical AND — both must be active for background to apply */
  background: var(--is-disabled, var(--color-primary));
  opacity: var(--is-disabled, 1);
  cursor: var(--is-disabled, pointer);
}

.btn[disabled] {
  --is-disabled: initial; /* "on" state — triggers fallback in all vars */
}

4. Dynamic Values — Power Beyond Sass

Unlike Sass/LESS variables, CSS Custom Properties can hold any partial CSS value and are computed at runtime.

Computed Values

:root {
  --hue: 258;
  --saturation: 80%;
  --lightness: 55%;

  --color-primary: hsl(var(--hue), var(--saturation), var(--lightness));
  --color-light: hsl(var(--hue), var(--saturation), 85%);
  --color-dark: hsl(var(--hue), var(--saturation), 30%);
}

Change --hue to 0 and your entire color scheme becomes red. One variable, full rebrand.

Using in calc()

:root {
  --base-size: 4px;
  --space-1: calc(var(--base-size) * 1);   /* 4px */
  --space-2: calc(var(--base-size) * 2);   /* 8px */
  --space-4: calc(var(--base-size) * 4);   /* 16px */
  --space-8: calc(var(--base-size) * 8);   /* 32px */
  --space-16: calc(var(--base-size) * 16); /* 64px */
}

Unitless Numbers in calc()

Store unitless values and apply units at usage:

:root {
  --ratio: 1.5;   /* unitless */
}

.element {
  width: calc(var(--ratio) * 100px);
  /* 150px */

  padding: calc(var(--ratio) * 1rem);
  /* 1.5rem */
}

5. Dark Mode with Custom Properties

Custom Properties make dark mode trivial — just redefine variables under a different selector.

/* Light theme (default) */
:root {
  --bg-primary: #ffffff;
  --bg-secondary: #f5f4f1;
  --bg-card: #ffffff;
  --text-primary: #1a1a1a;
  --text-muted: #6b7280;
  --text-faint: #9ca3af;
  --border: #e5e7eb;
  --shadow: 0 1px 3px rgba(0,0,0,0.1);
}

/* Dark theme */
[data-theme="dark"] {
  --bg-primary: #0f0f0f;
  --bg-secondary: #1a1a1a;
  --bg-card: #1e1e1e;
  --text-primary: #f0f0f0;
  --text-muted: #9ca3af;
  --text-faint: #6b7280;
  --border: #2d2d2d;
  --shadow: 0 1px 3px rgba(0,0,0,0.4);
}

/* System preference fallback */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme]) {
    --bg-primary: #0f0f0f;
    --bg-secondary: #1a1a1a;
    --bg-card: #1e1e1e;
    --text-primary: #f0f0f0;
    --text-muted: #9ca3af;
    --text-faint: #6b7280;
    --border: #2d2d2d;
    --shadow: 0 1px 3px rgba(0,0,0,0.4);
  }
}

Your components use only variables — no conditional classes:

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

.card {
  background: var(--bg-card);
  border: 1px solid var(--border);
  box-shadow: var(--shadow);
}

Toggle with one attribute:

document.documentElement.setAttribute('data-theme', 'dark');
document.documentElement.removeAttribute('data-theme');

6. JavaScript Integration

CSS Custom Properties are readable and writable via JavaScript — this is the most powerful aspect.

Reading Variables

const root = document.documentElement;
const styles = getComputedStyle(root);

// Read a variable
const primary = styles.getPropertyValue('--color-primary').trim();
console.log(primary); // "#7c3aed"

Writing Variables

// Set on :root — global
document.documentElement.style.setProperty('--color-primary', '#e53935');

// Set on a specific element — scoped
const card = document.querySelector('.card');
card.style.setProperty('--card-bg', '#fff9c4');

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

Interactive Theme Picker

const hueSlider = document.querySelector('#hue-slider');

hueSlider.addEventListener('input', (e) => {
  document.documentElement.style.setProperty('--hue', e.target.value);
});
:root {
  --hue: 258;
  --color-primary: hsl(var(--hue) 80% 55%);
  --color-light: hsl(var(--hue) 80% 90%);
  --color-dark: hsl(var(--hue) 80% 30%);
}

The slider instantly rethemes the entire page — zero JavaScript DOM manipulation needed beyond setting one variable.

Passing Data from JS to CSS

// Mouse position → CSS parallax effect
document.addEventListener('mousemove', (e) => {
  const x = (e.clientX / window.innerWidth - 0.5) * 2;   // -1 to 1
  const y = (e.clientY / window.innerHeight - 0.5) * 2;  // -1 to 1
  document.documentElement.style.setProperty('--mouse-x', x);
  document.documentElement.style.setProperty('--mouse-y', y);
});
.parallax-element {
  transform: translate(
    calc(var(--mouse-x) * 20px),
    calc(var(--mouse-y) * 20px)
  );
  transition: transform 0.1s ease-out;
}

7. Building a Complete Design Token System

Design tokens are the single source of truth for your design decisions — spacing, colors, typography, radii, shadows, and more.

/* ============================================
   DESIGN TOKEN SYSTEM
   ============================================ */

:root {
  /* ── Color Palette ── */
  --hue-brand: 258;
  --hue-neutral: 220;

  /* Brand scale */
  --color-brand-50:  hsl(var(--hue-brand) 90% 96%);
  --color-brand-100: hsl(var(--hue-brand) 85% 90%);
  --color-brand-200: hsl(var(--hue-brand) 80% 80%);
  --color-brand-300: hsl(var(--hue-brand) 78% 70%);
  --color-brand-400: hsl(var(--hue-brand) 76% 62%);
  --color-brand-500: hsl(var(--hue-brand) 74% 55%);  /* Primary */
  --color-brand-600: hsl(var(--hue-brand) 72% 45%);
  --color-brand-700: hsl(var(--hue-brand) 70% 36%);
  --color-brand-800: hsl(var(--hue-brand) 68% 28%);
  --color-brand-900: hsl(var(--hue-brand) 66% 20%);

  /* Neutral scale */
  --gray-50:  hsl(var(--hue-neutral) 20% 98%);
  --gray-100: hsl(var(--hue-neutral) 15% 95%);
  --gray-200: hsl(var(--hue-neutral) 12% 90%);
  --gray-300: hsl(var(--hue-neutral) 10% 80%);
  --gray-400: hsl(var(--hue-neutral) 8% 65%);
  --gray-500: hsl(var(--hue-neutral) 8% 50%);
  --gray-600: hsl(var(--hue-neutral) 10% 38%);
  --gray-700: hsl(var(--hue-neutral) 12% 28%);
  --gray-800: hsl(var(--hue-neutral) 14% 18%);
  --gray-900: hsl(var(--hue-neutral) 16% 10%);

  /* Semantic colors */
  --color-success: hsl(142 70% 40%);
  --color-warning: hsl(38 90% 50%);
  --color-error:   hsl(0 72% 50%);
  --color-info:    hsl(210 85% 55%);

  /* ── Semantic Tokens ── */
  --color-primary:    var(--color-brand-500);
  --color-primary-hover: var(--color-brand-600);
  --color-primary-light: var(--color-brand-100);

  --surface-default:  var(--gray-50);
  --surface-card:     #ffffff;
  --surface-elevated: #ffffff;
  --surface-overlay:  rgba(0, 0, 0, 0.5);

  --text-default: var(--gray-900);
  --text-muted:   var(--gray-500);
  --text-faint:   var(--gray-400);
  --text-inverse: #ffffff;
  --text-link:    var(--color-primary);

  --border-default: var(--gray-200);
  --border-focus:   var(--color-primary);

  /* ── Typography ── */
  --font-sans:    'Inter', system-ui, sans-serif;
  --font-display: 'Playfair Display', Georgia, serif;
  --font-mono:    'JetBrains Mono', monospace;

  --text-xs:   0.75rem;
  --text-sm:   0.875rem;
  --text-base: 1rem;
  --text-lg:   1.125rem;
  --text-xl:   1.25rem;
  --text-2xl:  1.5rem;
  --text-3xl:  1.875rem;
  --text-4xl:  2.25rem;
  --text-5xl:  3rem;

  --leading-none:    1;
  --leading-tight:   1.25;
  --leading-normal:  1.5;
  --leading-relaxed: 1.75;

  --font-normal:    400;
  --font-medium:    500;
  --font-semibold:  600;
  --font-bold:      700;

  /* ── Spacing ── */
  --space-px: 1px;
  --space-0:  0;
  --space-1:  0.25rem;   /* 4px  */
  --space-2:  0.5rem;    /* 8px  */
  --space-3:  0.75rem;   /* 12px */
  --space-4:  1rem;      /* 16px */
  --space-5:  1.25rem;   /* 20px */
  --space-6:  1.5rem;    /* 24px */
  --space-8:  2rem;      /* 32px */
  --space-10: 2.5rem;    /* 40px */
  --space-12: 3rem;      /* 48px */
  --space-16: 4rem;      /* 64px */
  --space-20: 5rem;      /* 80px */

  /* ── Border Radius ── */
  --radius-sm:   4px;
  --radius-md:   8px;
  --radius-lg:   12px;
  --radius-xl:   16px;
  --radius-2xl:  24px;
  --radius-full: 9999px;

  /* ── Shadows ── */
  --shadow-sm:
    0 1px 2px rgba(0, 0, 0, 0.04),
    0 1px 1px rgba(0, 0, 0, 0.06);
  --shadow-md:
    0 4px 6px -1px rgba(0, 0, 0, 0.08),
    0 2px 4px -1px rgba(0, 0, 0, 0.06);
  --shadow-lg:
    0 10px 15px -3px rgba(0, 0, 0, 0.08),
    0 4px 6px -2px rgba(0, 0, 0, 0.05);
  --shadow-xl:
    0 20px 25px -5px rgba(0, 0, 0, 0.1),
    0 10px 10px -5px rgba(0, 0, 0, 0.04);

  /* ── Z-index Scale ── */
  --z-below:    -1;
  --z-base:      0;
  --z-dropdown:  100;
  --z-sticky:    200;
  --z-overlay:   300;
  --z-modal:     400;
  --z-toast:     500;

  /* ── Transitions ── */
  --ease-default: cubic-bezier(0.4, 0, 0.2, 1);
  --ease-in:      cubic-bezier(0.4, 0, 1, 1);
  --ease-out:     cubic-bezier(0, 0, 0.2, 1);
  --ease-spring:  cubic-bezier(0.34, 1.56, 0.64, 1);

  --duration-fast:   150ms;
  --duration-normal: 250ms;
  --duration-slow:   400ms;
}

8. Component API Pattern

Custom Properties let you build component-level APIs — other developers override only what they need.

/* ── Button Component ── */
.btn {
  /* Public API — these are meant to be overridden */
  --btn-bg:          var(--color-primary);
  --btn-bg-hover:    var(--color-primary-hover);
  --btn-color:       var(--text-inverse);
  --btn-border:      transparent;
  --btn-radius:      var(--radius-full);
  --btn-padding-x:   var(--space-6);
  --btn-padding-y:   var(--space-3);
  --btn-font-size:   var(--text-sm);
  --btn-font-weight: var(--font-semibold);
  --btn-shadow:      var(--shadow-sm);
  --btn-shadow-hover: var(--shadow-md);

  /* Implementation — uses the API */
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
  background: var(--btn-bg);
  color: var(--btn-color);
  border: 1.5px solid var(--btn-border);
  border-radius: var(--btn-radius);
  padding: var(--btn-padding-y) var(--btn-padding-x);
  font-family: var(--font-sans);
  font-size: var(--btn-font-size);
  font-weight: var(--btn-font-weight);
  box-shadow: var(--btn-shadow);
  cursor: pointer;
  transition:
    background var(--duration-fast) var(--ease-out),
    box-shadow var(--duration-fast) var(--ease-out),
    transform var(--duration-fast) var(--ease-out);
}

.btn:hover {
  background: var(--btn-bg-hover);
  box-shadow: var(--btn-shadow-hover);
  transform: translateY(-1px);
}

/* Variants — only override changed tokens */
.btn-ghost {
  --btn-bg:          transparent;
  --btn-bg-hover:    var(--color-primary-light);
  --btn-color:       var(--color-primary);
  --btn-border:      currentColor;
  --btn-shadow:      none;
  --btn-shadow-hover: none;
}

.btn-danger {
  --btn-bg:          var(--color-error);
  --btn-bg-hover:    hsl(0 72% 42%);
}

.btn-lg {
  --btn-padding-x: var(--space-8);
  --btn-padding-y: var(--space-4);
  --btn-font-size: var(--text-base);
}

.btn-sm {
  --btn-padding-x: var(--space-4);
  --btn-padding-y: var(--space-2);
  --btn-font-size: var(--text-xs);
}

Usage:

<button class="btn">Primary</button>
<button class="btn btn-ghost">Ghost</button>
<button class="btn btn-danger btn-lg">Delete Account</button>

<!-- Inline override — no new class needed -->
<button class="btn" style="--btn-bg: #ff6b6b; --btn-radius: 4px;">
  Custom
</button>

9. Responsive Custom Properties

Variables can be redefined inside media queries — this is something Sass variables simply cannot do.

:root {
  /* Mobile-first defaults */
  --container-width: 100%;
  --container-padding: var(--space-4);
  --grid-cols: 1;
  --text-hero: var(--text-3xl);
  --nav-height: 56px;
}

@media (min-width: 640px) {
  :root {
    --grid-cols: 2;
    --text-hero: var(--text-4xl);
  }
}

@media (min-width: 1024px) {
  :root {
    --container-width: 1200px;
    --container-padding: var(--space-8);
    --grid-cols: 3;
    --text-hero: var(--text-5xl);
    --nav-height: 64px;
  }
}

/* Components just use variables — no media queries needed inside */
.hero-title {
  font-size: var(--text-hero);
}

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

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

10. Animation with Custom Properties

@keyframes pulse-ring {
  0%   {
    transform: scale(0.8);
    opacity: var(--pulse-opacity-start, 0.8);
  }
  100% {
    transform: scale(var(--pulse-scale, 2));
    opacity: 0;
  }
}

.notification-dot {
  --pulse-scale: 2.5;
  --pulse-opacity-start: 1;

  position: relative;
  width: 12px;
  height: 12px;
  border-radius: 50%;
  background: var(--color-error);
}

.notification-dot::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background: var(--color-error);
  animation: pulse-ring 1.5s var(--ease-out) infinite;
}
/* Progress bar driven by a variable */
.progress-bar {
  --progress: 0;  /* Set by JS: el.style.setProperty('--progress', 0.65) */

  width: 100%;
  height: 8px;
  background: var(--gray-200);
  border-radius: var(--radius-full);
  overflow: hidden;
}

.progress-bar::after {
  content: '';
  display: block;
  height: 100%;
  width: calc(var(--progress) * 100%);
  background: var(--color-primary);
  border-radius: inherit;
  transition: width var(--duration-slow) var(--ease-out);
}
// Update from JavaScript
const bar = document.querySelector('.progress-bar');
bar.style.setProperty('--progress', 0.75); // 75%

11. CSS Custom Properties vs Sass Variables

Feature CSS Custom Properties Sass Variables
Runtime updates ✅ Yes ❌ No (compile-time only)
JavaScript access ✅ Read/write ❌ Not accessible
Scope Cascade-based File/block-based
Media query redefinition ✅ Yes ❌ No
Browser fallback var(--x, fallback) N/A
Dark mode ✅ Trivial Complex
Performance Excellent Excellent
Browser support 97%+ Build-step required

They're not mutually exclusive. Many teams use Sass variables at build-time for things like generating utility classes, and CSS Custom Properties for runtime theming.


12. Performance and Best Practices

Do

/* ✅ Group all tokens in :root */
:root {
  --color-primary: #7c3aed;
  --space-4: 1rem;
}

/* ✅ Use semantic names */
--text-link: var(--color-primary);

/* ✅ Provide fallbacks for optional component variables */
.card {
  padding: var(--card-padding, var(--space-6));
}

Avoid

/* ❌ Don't use CSS variables for values that never change */
:root {
  --zero: 0;  /* Pointless */
}

/* ❌ Don't deeply nest var() in var() unnecessarily */
color: var(--a, var(--b, var(--c, var(--d, red))));

/* ❌ Don't use for layout-triggering animations */
/* Use transform/opacity instead */

Browser Support

CSS Custom Properties have 97%+ global browser support (all modern browsers). For IE11 (if you still care), use the @supports block as a fallback:

/* IE11 fallback */
.btn {
  background: #7c3aed;
}

/* Modern browsers */
@supports (--css: variables) {
  .btn {
    background: var(--color-primary);
  }
}

13. A Practical Theme Switcher — Complete Example

Here's a full working theme switcher system:

CSS:

/* themes.css */
:root[data-theme="light"] {
  --bg: #ffffff;
  --surface: #f5f4f1;
  --text: #1a1a1a;
  --text-muted: #6b7280;
  --border: #e5e7eb;
  --primary: #7c3aed;
}

:root[data-theme="dark"] {
  --bg: #0f0f0f;
  --surface: #1a1a1a;
  --text: #f0f0f0;
  --text-muted: #9ca3af;
  --border: #2d2d2d;
  --primary: #a78bfa;
}

:root[data-theme="ocean"] {
  --bg: #0c1929;
  --surface: #132338;
  --text: #e2f0ff;
  --text-muted: #7fa8c9;
  --border: #1e3a5f;
  --primary: #38bdf8;
}

JavaScript:

// theme-switcher.js
const STORAGE_KEY = 'gdw-theme';

function applyTheme(theme) {
  document.documentElement.setAttribute('data-theme', theme);
  localStorage.setItem(STORAGE_KEY, theme);
}

// On load: restore saved theme or detect system preference
(function init() {
  const saved = localStorage.getItem(STORAGE_KEY);
  if (saved) {
    applyTheme(saved);
    return;
  }

  const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
  applyTheme(prefersDark ? 'dark' : 'light');
})();

// Watch for system theme changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
  if (!localStorage.getItem(STORAGE_KEY)) {
    applyTheme(e.matches ? 'dark' : 'light');
  }
});

// Expose for buttons
window.setTheme = applyTheme;

HTML:

<button onclick="setTheme('light')">☀️ Light</button>
<button onclick="setTheme('dark')">🌙 Dark</button>
<button onclick="setTheme('ocean')">🌊 Ocean</button>

Conclusion

CSS Custom Properties are the backbone of modern scalable CSS. They replace the "variables only" use case of Sass while adding runtime superpowers no preprocessor can offer. Once you build your design token system with Custom Properties, you'll find that:

  • Dark mode takes minutes, not hours
  • Theming a component means overriding 2-3 variables
  • JavaScript-driven animations become trivially simple
  • Your CSS becomes self-documenting and consistent

Invest the time to define your token system properly once — everything built on top of it becomes faster, cleaner, and endlessly flexible.

Next up: CSS Pseudo-classes & Pseudo-elements — Complete Guide

Enjoyed this post?
...
Share this post
💬𝕏inF
← Back to BlogWork With Us →