MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

CSS Custom Properties (Variables): A Complete Guide

Master CSS variables: scoping, theming, fallbacks, and dynamic styling.

CSSCustom PropertiesVariablesFrontend

By MinhVo

Introduction

CSS Custom Properties, commonly known as CSS variables, have fundamentally changed how we write and maintain stylesheets. Before their introduction, developers relied on preprocessor variables from Sass or Less, which compiled to static values and couldn't respond to the DOM. CSS custom properties are live — they cascade, inherit, and can be changed dynamically with JavaScript, making them far more powerful than their preprocessor counterparts.

From design token systems and theme switching to responsive typography and component variants, CSS custom properties are the backbone of modern CSS architecture. They enable patterns that were previously impossible without JavaScript, like changing a component's appearance based on its position in the DOM or responding to user preferences in real time.

CSS custom properties enable dynamic theming

This guide covers everything from basic syntax to advanced patterns including computed values, dynamic theming, and integration with modern CSS features like @property and container queries.

Understanding Custom Properties: Core Concepts

Declaration and Usage

Custom properties are declared with a double-dash prefix and accessed with the var() function:

:root {
  --color-primary: #2563eb;
  --color-text: #1f2937;
  --spacing-md: 16px;
  --font-size-base: 1rem;
}
 
.button {
  background-color: var(--color-primary);
  color: white;
  padding: var(--spacing-md);
  font-size: var(--font-size-base);
}

Cascade and Inheritance

Unlike preprocessor variables, custom properties participate in the CSS cascade. They inherit down the DOM tree and can be overridden at any level:

:root {
  --card-bg: white;
  --card-text: #1f2937;
}
 
.dark-theme {
  --card-bg: #1f2937;
  --card-text: #f9fafb;
}
 
.card {
  background: var(--card-bg);
  color: var(--card-text);
}

When .dark-theme is applied to any ancestor, all descendant cards automatically use the dark theme values. No JavaScript class toggling on individual cards is needed.

Fallback Values

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

.element {
  /* Use --custom-color if defined, otherwise use blue */
  color: var(--custom-color, blue);
  
  /* Nested fallbacks */
  background: var(--theme-bg, var(--default-bg, white));
}

Invalid Variables and the initial-value Problem

When a custom property is set to an invalid value for its context, the fallback is used. However, if no fallback is specified, the property reverts to its initial value (which varies by property). This can cause unexpected behavior:

:root {
  --spacing: 16px;
}
 
.element {
  /* This works: --spacing is a valid length */
  margin: var(--spacing);
  
  /* This breaks if --spacing is set to "red" somewhere */
  margin: var(--spacing, 0);
  /* The fallback protects against invalid values */
}

Understanding custom property scoping

Architecture and Design Patterns

The Design Token Pattern

Design tokens are the atomic values of a design system — colors, spacing, typography, and shadows. Custom properties are the ideal vehicle for design tokens because they cascade, inherit, and can be themed.

:root {
  /* Color tokens */
  --color-blue-50: #eff6ff;
  --color-blue-500: #3b82f6;
  --color-blue-900: #1e3a5f;
  
  /* Semantic tokens */
  --color-primary: var(--color-blue-500);
  --color-primary-hover: var(--color-blue-600);
  --color-surface: white;
  --color-on-surface: #1f2937;
  
  /* Spacing scale */
  --space-1: 4px;
  --space-2: 8px;
  --space-3: 12px;
  --space-4: 16px;
  --space-6: 24px;
  --space-8: 32px;
}

The Theming Pattern

Use custom properties at the component level to enable theme switching without modifying component CSS:

[data-theme="dark"] {
  --color-surface: #111827;
  --color-on-surface: #f9fafb;
  --color-primary: #60a5fa;
  --color-border: #374151;
  --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
}

The Component Variant Pattern

Custom properties enable flexible component APIs without complex selector patterns:

.alert {
  --alert-bg: var(--color-blue-50);
  --alert-border: var(--color-blue-200);
  --alert-text: var(--color-blue-800);
  
  background: var(--alert-bg);
  border: 1px solid var(--alert-border);
  color: var(--alert-text);
  padding: var(--space-3) var(--space-4);
  border-radius: var(--radius-md);
}
 
.alert--success {
  --alert-bg: var(--color-green-50);
  --alert-border: var(--color-green-200);
  --alert-text: var(--color-green-800);
}
 
.alert--danger {
  --alert-bg: var(--color-red-50);
  --alert-border: var(--color-red-200);
  --alert-text: var(--color-red-800);
}

The Responsive Custom Property Pattern

Override custom properties at different breakpoints to create responsive design tokens:

:root {
  --container-padding: var(--space-4);
  --heading-size: 1.5rem;
  --card-columns: 1;
}
 
@media (min-width: 768px) {
  :root {
    --container-padding: var(--space-6);
    --heading-size: 2rem;
    --card-columns: 2;
  }
}
 
@media (min-width: 1024px) {
  :root {
    --container-padding: var(--space-8);
    --heading-size: 2.5rem;
    --card-columns: 3;
  }
}

Step-by-Step Implementation

Building a Theme System

Create a complete light/dark theme system with custom properties:

<html data-theme="light">
  <body>
    <header class="site-header">
      <h1>My Application</h1>
      <button id="theme-toggle" aria-label="Toggle dark mode">
        🌙
      </button>
    </header>
    <main>
      <div class="card">
        <h2>Card Title</h2>
        <p>Card content adapts to the current theme.</p>
      </div>
    </main>
  </body>
</html>
/* Global design tokens */
:root {
  --color-surface: #ffffff;
  --color-surface-raised: #f9fafb;
  --color-on-surface: #111827;
  --color-on-surface-muted: #6b7280;
  --color-primary: #2563eb;
  --color-primary-hover: #1d4ed8;
  --color-border: #e5e7eb;
  --color-focus-ring: rgba(37, 99, 235, 0.4);
  --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
  --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.07);
  --radius-sm: 4px;
  --radius-md: 8px;
  --radius-lg: 12px;
}
 
/* Dark theme overrides */
[data-theme="dark"] {
  --color-surface: #0f172a;
  --color-surface-raised: #1e293b;
  --color-on-surface: #f1f5f9;
  --color-on-surface-muted: #94a3b8;
  --color-primary: #3b82f6;
  --color-primary-hover: #60a5fa;
  --color-border: #334155;
  --color-focus-ring: rgba(59, 130, 246, 0.4);
  --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
  --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4);
}
 
/* Components reference tokens */
body {
  background: var(--color-surface);
  color: var(--color-on-surface);
}
 
.card {
  background: var(--color-surface-raised);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-sm);
  padding: var(--space-6);
}
 
.btn-primary {
  background: var(--color-primary);
  color: white;
  border: none;
  border-radius: var(--radius-md);
  padding: var(--space-2) var(--space-4);
}
 
.btn-primary:hover {
  background: var(--color-primary-hover);
}
 
.btn-primary:focus-visible {
  outline: 2px solid var(--color-focus-ring);
  outline-offset: 2px;
}

Toggle theme with JavaScript:

const toggle = document.getElementById('theme-toggle');
toggle.addEventListener('click', () => {
  const html = document.documentElement;
  const current = html.getAttribute('data-theme');
  const next = current === 'light' ? 'dark' : 'light';
  html.setAttribute('data-theme', next);
  localStorage.setItem('theme', next);
});

Building Responsive Typography with Custom Properties

:root {
  --font-size-xs: clamp(0.75rem, 0.7rem + 0.25vw, 0.875rem);
  --font-size-sm: clamp(0.875rem, 0.8rem + 0.35vw, 1rem);
  --font-size-base: clamp(1rem, 0.9rem + 0.5vw, 1.125rem);
  --font-size-lg: clamp(1.125rem, 1rem + 0.6vw, 1.375rem);
  --font-size-xl: clamp(1.25rem, 1.1rem + 0.75vw, 1.75rem);
  --font-size-2xl: clamp(1.5rem, 1.2rem + 1.5vw, 2.5rem);
  --font-size-3xl: clamp(2rem, 1.5rem + 2.5vw, 3.5rem);
  --line-height-tight: 1.2;
  --line-height-normal: 1.5;
  --line-height-relaxed: 1.75;
}
 
h1 { font-size: var(--font-size-3xl); line-height: var(--line-height-tight); }
h2 { font-size: var(--font-size-2xl); line-height: var(--line-height-tight); }
p  { font-size: var(--font-size-base); line-height: var(--line-height-normal); }

Dynamic theming with CSS variables

Real-World Use Cases

Use Case 1: Multi-Brand Design System

A white-label application serving multiple brands can switch the entire color scheme by changing a set of custom properties on the root element. Each brand defines its own color tokens, and all components automatically adapt without any component-level CSS changes.

Use Case 2: User-Configurable Dashboards

Dashboard applications where users can customize their workspace benefit from custom properties for layout density, color themes, and widget sizing. Users adjust preferences through a settings panel, and JavaScript updates custom properties on the dashboard container.

Use Case 3: Print Stylesheets

Custom properties simplify print stylesheet management. Override spacing, colors, and typography tokens in a @media print block to create a clean print layout without duplicating component CSS.

Use Case 4: Animation Properties

Custom properties can drive animations, enabling dynamic timing and values. An element's animation speed, distance, or color can be controlled by custom properties that change based on context or user interaction.

Best Practices for Production

  1. Organize tokens in three layers — Primitive tokens (raw values like --blue-500), semantic tokens (purpose-based like --color-primary), and component tokens (scoped like --card-bg). Components should reference semantic tokens, not primitives.

  2. Always provide fallback values — Use var(--custom, fallback) to prevent broken layouts when a custom property is undefined. This is especially important for component-level properties that might not be set in all contexts.

  3. Use :root for global tokens only — Place design system tokens on :root and theme overrides on themed ancestor elements. Avoid putting component-specific properties on :root.

  4. Prefer custom properties over Sass variables — CSS custom properties cascade, inherit, and can be changed dynamically. Sass variables are static and compiled away. Use Sass only for build-time utilities like mixins and functions.

  5. Document your token system — Maintain a living styleguide or token reference that shows all available custom properties, their intended use, and their default values.

  6. Use @property for typed custom properties — When you need custom properties that the browser can animate or validate, declare them with @property:

@property --rotation {
  syntax: '<angle>';
  initial-value: 0deg;
  inherits: false;
}
  1. Avoid deep nesting of var() fallbacks — While var(--a, var(--b, var(--c, default))) is valid, it's hard to read and debug. Keep fallback chains to two levels maximum.

  2. Use custom properties for spacing scales — A consistent spacing scale using custom properties (--space-1 through --space-12) ensures visual consistency and makes layout adjustments trivial.

Common Pitfalls and Solutions

PitfallImpactSolution
Using custom properties in @media queriesNot allowed by specUse @media with fixed values; use container queries for dynamic breakpoints
Setting invalid values for custom propertiesFallback to initial value or fallbackUse @property to define syntax and initial values
Over-specificity with custom property selectorsStyles don't cascade properlyUse data attributes or class-based overrides with appropriate specificity
Forgetting :root vs element-level scopeProperties don't inherit as expectedUse :root for global tokens, element selectors for component tokens
Custom properties in calc() with wrong typesInvalid computationUse @property to define custom property types explicitly
Not providing fallbacks in var()Broken layouts when property undefinedAlways include a fallback: var(--prop, default)

Performance Optimization

Custom properties have minimal runtime overhead. The browser resolves them during style computation, which is fast for most use cases. However, there are optimization considerations for large-scale applications.

Reading a custom property with getComputedStyle() forces a style recalculation. Batch reads and avoid reading custom properties in animation loops or scroll handlers. When you need to read and update custom properties frequently, cache the computed values.

// Bad: forces style recalc on every call
function getSpacing() {
  return getComputedStyle(document.documentElement)
    .getPropertyValue('--spacing');
}
 
// Good: cache the value
let cachedSpacing = null;
function getSpacing() {
  if (!cachedSpacing) {
    cachedSpacing = getComputedStyle(document.documentElement)
      .getPropertyValue('--spacing');
  }
  return cachedSpacing;
}

Writing custom properties with JavaScript is efficient because it only updates the property value without triggering a full stylesheet recalculation. The browser batches property writes and applies them in the next style/layout pass.

Comparison with Alternatives

FeatureCSS Custom PropertiesSass VariablesCSS @propertyCSS Constants (proposed)
Runtime availabilityYesNo (compiled)YesYes (if proposed)
Cascade/inheritanceYesNoYesTBD
Dynamic updates (JS)YesNoYesNo
Type checkingNoNoYes (syntax)Yes
AnimatableNo (unless @property)NoYesNo
Fallback supportYes (var() 2nd arg)NoVia initial-valueTBD
Media query usageNoYes (compiled)NoTBD

Advanced Patterns

Computed Custom Properties with calc()

Combine custom properties with calc() for derived values:

:root {
  --base-spacing: 8px;
  --container-max: 1200px;
  --sidebar-width: 280px;
  --content-width: calc(var(--container-max) - var(--sidebar-width));
  --grid-gap: calc(var(--base-spacing) * 2);
}

Custom Properties with Container Queries

Use custom properties to communicate container size information to descendant components:

.layout-container {
  container: layout / inline-size;
}
 
@container layout (min-width: 800px) {
  .layout-container {
    --layout-columns: 2;
    --layout-gap: var(--space-6);
  }
}
 
@container layout (min-width: 1200px) {
  .layout-container {
    --layout-columns: 3;
    --layout-gap: var(--space-8);
  }
}

@property for Animatable Values

Define typed custom properties that the browser can interpolate:

@property --gradient-angle {
  syntax: '<angle>';
  initial-value: 0deg;
  inherits: false;
}
 
.gradient-border {
  --gradient-angle: 0deg;
  background: conic-gradient(from var(--gradient-angle), #2563eb, #7c3aed, #2563eb);
  transition: --gradient-angle 0.5s ease;
}
 
.gradient-border:hover {
  --gradient-angle: 180deg;
}

Testing Strategies

Custom properties should be tested for correct inheritance, fallback behavior, and theme switching:

test('dark theme overrides surface color', async ({ page }) => {
  await page.goto('/demo');
  await page.evaluate(() => {
    document.documentElement.setAttribute('data-theme', 'dark');
  });
  
  const surfaceColor = await page.locator('.card').evaluate(
    el => getComputedStyle(el).backgroundColor
  );
  
  expect(surfaceColor).toBe('rgb(30, 41, 59)');
});
 
test('fallback value is used when property is undefined', async ({ page }) => {
  await page.goto('/demo');
  
  const color = await page.locator('.fallback-test').evaluate(
    el => getComputedStyle(el).color
  );
  
  expect(color).toBe('rgb(0, 0, 255)'); // blue fallback
});

Future Outlook

CSS custom properties continue to evolve. The @property rule enables typed, animatable custom properties. Container queries use custom properties for inter-component communication. The proposed CSS Constants feature would add compile-time constants alongside the runtime custom properties we have today. As these features mature, custom properties will become even more central to CSS architecture.

Cross-Browser Testing Strategy

Modern CSS features often have varying levels of browser support, making a systematic cross-browser testing strategy essential. Before using any CSS feature in production, verify its support status and implement appropriate fallbacks for browsers that haven't yet implemented it.

Progressive Enhancement with @supports

Use the @supports at-rule to provide fallback styles for browsers that don't support specific CSS features:

/* Base styles for all browsers */
.grid-container {
  display: flex;
  flex-wrap: wrap;
  gap: 1rem;
}
 
.grid-container > * {
  flex: 1 1 300px;
}
 
/* Enhanced layout for browsers supporting CSS Grid */
@supports (display: grid) {
  .grid-container {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
    gap: 1.5rem;
  }
 
  .grid-container > * {
    flex: none;
  }
}
 
/* Further enhancement with subgrid support */
@supports (grid-template-columns: subgrid) {
  .grid-container {
    grid-template-columns: subgrid;
  }
}

Visual Regression Testing

Implement visual regression testing to catch unintended layout shifts and styling changes. Tools like Percy, Chromatic, or Playwright's screenshot comparison can detect visual differences across browsers and screen sizes:

const { test, expect } = require('@playwright/test');
 
test('responsive layout matches design', async ({ page }) => {
  await page.goto('/components/dashboard');
 
  // Test at multiple viewport sizes
  for (const viewport of [
    { width: 375, height: 812, name: 'mobile' },
    { width: 768, height: 1024, name: 'tablet' },
    { width: 1440, height: 900, name: 'desktop' },
  ]) {
    await page.setViewportSize({ width: viewport.width, height: viewport.height });
    await expect(page).toHaveScreenshot(
      `dashboard-${viewport.name}.png`,
      { maxDiffPixels: 100 }
    );
  }
});

Browser Compatibility Testing Matrix

Maintain a testing matrix that covers the browsers and versions your users actually use. Use analytics data to determine your browser support baseline, then configure tools like Browserslist to automatically handle polyfilling and prefixing:

{
  "browserslist": [
    "> 0.5%",
    "last 2 versions",
    "not dead",
    "not ie 11"
  ]
}

This data-driven approach ensures you're spending testing effort where it matters most, rather than trying to support every possible browser configuration.

Community Resources and Further Learning

The technology landscape evolves rapidly, making continuous learning essential for maintaining expertise. Building a systematic approach to staying current with developments in your technology stack ensures you can leverage new features and avoid deprecated patterns.

Curated Learning Pathways

Rather than consuming content randomly, create structured learning pathways aligned with your current projects and career goals. Start with official documentation and specification documents, which provide the most accurate and comprehensive information. Follow this with hands-on tutorials and workshops that reinforce concepts through practical application.

Technical blogs from framework maintainers and core team members often provide deeper insights into design decisions and upcoming features. Subscribe to the official blogs of your primary frameworks and libraries to stay ahead of breaking changes and deprecation timelines.

Contributing to Open Source

Contributing to open-source projects in your technology stack provides unparalleled learning opportunities. Start with documentation improvements and bug reports, then progress to fixing small issues tagged as "good first issue" in your favorite projects. This direct engagement with maintainers and the codebase accelerates your understanding far beyond what passive learning can achieve.

# Setting up for contribution
git clone https://github.com/project/repository.git
cd repository
git checkout -b fix/issue-description
 
# Run the project's contribution setup
npm run setup:dev
npm run test  # Ensure tests pass before making changes
 
# Make your changes, then run the full test suite
npm run test:full
npm run lint
npm run build
 
# Submit your contribution
git add -A
git commit -m "fix: description of the fix
 
Closes #1234"
git push origin fix/issue-description

Building a Technical Knowledge Base

Maintain a personal knowledge base that captures insights, solutions, and patterns you discover during your work. Tools like Obsidian, Notion, or even a simple Markdown repository can serve as an external memory that grows more valuable over time.

Organize your notes by topic rather than chronologically, and include code examples, links to relevant documentation, and explanations of why certain approaches work better than others. When you encounter a particularly insightful article or conference talk, write a summary that captures the key takeaways and how they apply to your current projects.

Follow key conferences and their published talks to stay informed about emerging patterns and best practices. Many conferences publish recorded talks on YouTube within weeks of the event, making world-class technical content freely accessible.

Join relevant Discord servers, Slack communities, and forums where practitioners discuss real-world challenges and solutions. These communities provide early warning about emerging issues and access to collective wisdom that isn't available through formal documentation.

Mentorship and Knowledge Sharing

Teaching others is one of the most effective ways to deepen your own understanding. Consider writing technical blog posts, giving talks at local meetups, or mentoring junior developers. The process of explaining concepts to others forces you to organize your knowledge and identify gaps in your understanding.

Pair programming sessions with colleagues of different experience levels create mutual learning opportunities. Senior developers gain fresh perspectives on problems they've solved the same way for years, while junior developers benefit from exposure to production-grade thinking and decision-making processes.

Conclusion

CSS Custom Properties are the foundation of modern CSS architecture. The key takeaways are:

  1. Use a three-tier token system — primitive, semantic, and component tokens organized by purpose.
  2. Leverage cascading and inheritance — custom properties automatically flow through the DOM, enabling theme switching and contextual styling.
  3. Always provide fallback values — var(--prop, default) prevents broken layouts when properties are undefined.
  4. Use @property for typed values — when you need validation, animation, or explicit type information.
  5. Combine with container queries — for responsive, themeable components that adapt to their context.

Start by extracting your existing hardcoded values into custom properties. Once you have a token system in place, theming, responsive design, and component variants become trivial.