CSS

CSS Custom Properties: Beyond the Basics

css-variables-guide

Stop calling them "CSS variables." I mean, you can — everyone does — but "custom properties" is the official name, and understanding why matters. Unlike preprocessor variables from Sass or Less, custom properties are live. They exist in the DOM, they cascade, they inherit, and you can change them at runtime with JavaScript. That distinction changes everything about how you architect your styles.

I spent years reaching for Sass variables before I really gave custom properties a fair shot. Once I did, I couldn't go back. Here's what I wish someone had told me on day one.

The Basics (Quick Refresher)

If you already know how to declare and use a custom property, skip ahead. But for completeness — you define them with a double-hyphen prefix and consume them with var():

:root {
  --color-primary: #3b82f6;
  --spacing-md: 16px;
  --font-body: 'Inter', sans-serif;
}

.button {
  background: var(--color-primary);
  padding: var(--spacing-md);
  font-family: var(--font-body);
}

Simple enough. But most tutorials stop here, and that's a shame because the interesting stuff starts with scoping.

Scoping: The Real Superpower

Here's what clicked for me after months of dumping everything into :root: custom properties follow the cascade. They inherit down the DOM tree just like color or font-size. This means you can redefine them on any element, and all children pick up the new value.

.card {
  --card-padding: 24px;
  --card-radius: 12px;
  padding: var(--card-padding);
  border-radius: var(--card-radius);
}

.card.compact {
  --card-padding: 12px;
  --card-radius: 8px;
}

.card.featured {
  --card-padding: 32px;
  --card-radius: 16px;
}

Notice something? I didn't rewrite the padding and border-radius declarations. I just changed the variables. The component adapts. When you have a card with 15 properties that all reference these variables, this approach saves massive amounts of repetition.

Component-Scoped Variables

I prefer namespacing my component variables. It prevents collisions and makes the code self-documenting:

.sidebar {
  --sidebar-width: 280px;
  --sidebar-bg: #1e293b;
  --sidebar-text: #e2e8f0;

  width: var(--sidebar-width);
  background: var(--sidebar-bg);
  color: var(--sidebar-text);
}

@media (max-width: 768px) {
  .sidebar {
    --sidebar-width: 100%;
  }
}

The media query only changes the variable, not the property. Clean, readable, and the intent is crystal clear.

Tip: Don't put every variable in :root. Only global design tokens (colors, typography scales, spacing) belong there. Component-specific variables should live on the component selector. This keeps your global namespace clean and makes components more portable.

Building a Theming System

This is where custom properties absolutely crush preprocessor variables. Sass can't switch themes at runtime — it compiles to static CSS. Custom properties can.

Here's a theming pattern I've used in production that's held up really well:

:root {
  --color-bg: #ffffff;
  --color-surface: #f8fafc;
  --color-text: #0f172a;
  --color-text-muted: #64748b;
  --color-border: #e2e8f0;
  --color-accent: #3b82f6;
}

[data-theme="dark"] {
  --color-bg: #0f172a;
  --color-surface: #1e293b;
  --color-text: #f1f5f9;
  --color-text-muted: #94a3b8;
  --color-border: #334155;
  --color-accent: #60a5fa;
}

Now every component in your app just uses var(--color-bg), var(--color-text), etc. Toggle the data-theme attribute on the <html> element, and the entire UI flips instantly. No class swapping on 50 elements. No stylesheet replacement. Just one attribute change.

// Toggle dark mode with one line
document.documentElement.dataset.theme =
  document.documentElement.dataset.theme === 'dark'
    ? 'light'
    : 'dark';

You can even add a transition to make it smooth:

* {
  transition: background-color 0.3s, color 0.3s, border-color 0.3s;
}

Fair warning though — that universal transition can cause performance issues if you have lots of elements. In practice, I scope it to the major layout containers.

Dynamic Updates with JavaScript

This is the part that makes custom properties feel like magic. You can read and write them from JS:

// Set a custom property
document.documentElement.style.setProperty('--header-height', '80px');

// Read a custom property
const accent = getComputedStyle(document.documentElement)
  .getPropertyValue('--color-accent');
console.log(accent); // "#3b82f6"

Why would you do this? Tons of reasons. Here's one I use all the time — passing mouse position to CSS for interactive effects:

document.addEventListener('mousemove', (e) => {
  const x = (e.clientX / window.innerWidth).toFixed(3);
  const y = (e.clientY / window.innerHeight).toFixed(3);
  document.documentElement.style.setProperty('--mouse-x', x);
  document.documentElement.style.setProperty('--mouse-y', y);
});
.spotlight {
  background: radial-gradient(
    circle at calc(var(--mouse-x) * 100%) calc(var(--mouse-y) * 100%),
    rgba(59, 130, 246, 0.15),
    transparent 50%
  );
}

No animation library needed. Pure CSS reacting to JS-provided data. I've built entire interactive hero sections with this technique.

Dynamic Spacing Based on Viewport

Another practical example — adjusting a layout variable on scroll:

window.addEventListener('scroll', () => {
  const shrink = Math.min(window.scrollY / 200, 1);
  document.documentElement.style.setProperty(
    '--navbar-padding',
    `${20 - shrink * 12}px`
  );
});

The navbar smoothly shrinks as you scroll. CSS handles the rendering, JS just tweaks the number. Clean separation of concerns.

Fallback Values (And Nested Fallbacks)

The second argument to var() is a fallback. You probably know that. But did you know you can nest them?

.element {
  /* Simple fallback */
  color: var(--text-color, #333);

  /* Nested fallback - try --brand-color, then --accent, then hardcoded */
  background: var(--brand-color, var(--accent, #3b82f6));
}

This is incredibly useful for component libraries. You can ship a component with sensible defaults while letting consumers override specific tokens:

.btn {
  background: var(--btn-bg, var(--color-accent, #3b82f6));
  color: var(--btn-text, #ffffff);
  padding: var(--btn-padding, 10px 20px);
  border-radius: var(--btn-radius, 8px);
}

Users can set --btn-bg for a specific button, or --color-accent for all accent-colored elements, or neither — and it still works.

Tip: Fallback values only kick in when the variable is not defined (not set on any ancestor). If a variable is set to an invalid value for the property, the fallback won't save you — the property will use its inherited or initial value instead. This catches a lot of people off guard.

Calculations and Derived Values

Custom properties work beautifully with calc(). Define a base value, then derive everything else from it:

:root {
  --space-unit: 8px;
}

.container {
  padding: calc(var(--space-unit) * 3);     /* 24px */
  gap: calc(var(--space-unit) * 2);          /* 16px */
  margin-bottom: calc(var(--space-unit) * 5); /* 40px */
}

Change --space-unit to 6px, and your entire spacing system compresses. I use this to create density modes — comfortable, compact, and spacious — with a single variable change.

You can even build a fluid type scale:

:root {
  --fluid-min: 16;
  --fluid-max: 20;
  --fluid-size: calc(
    var(--fluid-min) * 1px + (var(--fluid-max) - var(--fluid-min)) *
    ((100vw - 320px) / (1200 - 320))
  );
}

body {
  font-size: var(--fluid-size);
}

Patterns I Reach for Constantly

State-Driven Styles

Instead of writing separate rules for every state, use a variable as a "switch":

.input-field {
  --field-border: var(--color-border);
  border: 2px solid var(--field-border);
}

.input-field:focus {
  --field-border: var(--color-accent);
}

.input-field.error {
  --field-border: #ef4444;
}

.input-field.success {
  --field-border: #22c55e;
}

All four states, one border declaration. When you need to change the border width or style, you do it in one place.

Responsive Design Tokens

:root {
  --container-max: 1200px;
  --grid-cols: 3;
  --section-padding: 80px;
}

@media (max-width: 768px) {
  :root {
    --grid-cols: 1;
    --section-padding: 40px;
  }
}

Your media queries become a list of token overrides. The actual layout rules stay untouched. I find this much easier to maintain than scattering property overrides across breakpoints.

Things to Watch Out For

Custom properties aren't perfect. A few gotchas:

Building CSS-Heavy Templates?

Convert your HTML and CSS designs into shareable images instantly — perfect for documentation, social sharing, and presentations.

Try HTML to PNG Converter →

Wrapping Up

CSS custom properties aren't just "variables in CSS." They're a runtime styling API built into the language. Once you start thinking of them as dynamic, cascading, scopeable values — rather than static placeholders — your CSS architecture changes fundamentally.

Start small. Move your color palette into custom properties. Build a dark mode toggle. Then try component-scoped variables. Before long, you'll wonder how you ever built anything without them.

The best CSS I've written in the last two years has one thing in common: heavy use of custom properties at every level. Not because they're trendy, but because they make code genuinely easier to maintain and extend. That's the whole point.

Sachin Bhanushali
Written by

Sachin Bhanushali

Full-stack developer and creator of HTMLtoImages. Building free, privacy-first developer tools that run entirely in your browser.