CSS Variables and Custom Properties Explained — How to Write Smarter, Reusable Styles
Every professional website you've ever used — Spotify, GitHub, your bank's app — has a consistent look. The buttons are always the same color, the fonts match everywhere, and spacing feels uniform. That consistency doesn't happen by accident, and it definitely doesn't happen by copy-pasting the same hex code 200 times across a stylesheet. There's a smarter system underneath, and CSS Variables are a big part of it.
Before CSS Variables existed, developers faced a painful problem: if a designer changed the brand color from #3a86ff to #6c63ff, the developer had to hunt through thousands of lines of CSS and update every single occurrence manually. Miss one and the site looks broken. It was tedious, error-prone, and made maintaining a design system a nightmare. CSS Custom Properties — the official name for CSS Variables — were introduced specifically to kill that problem dead.
By the end of this article you'll know how to declare your own CSS variables, use them across your entire stylesheet, change them on the fly with JavaScript, and understand exactly how they cascade and inherit. You'll also know the traps that catch beginners off guard and be ready to answer the questions interviewers love to ask about this topic.
Declaring and Using Your First CSS Variable
A CSS variable has two parts: a declaration and a usage. Think of the declaration as writing a word in a dictionary ('Brand Blue means #3a86ff') and the usage as pointing to that dictionary entry instead of writing the hex code directly.
Declarations always start with two dashes: --my-variable-name. That double-dash is not optional — it's what tells the browser 'this is a custom property, not a built-in CSS property'. You almost always declare your variables inside a :root selector. The :root selector targets the very top of your HTML document (the html element), which means variables declared there are available everywhere on the page — they're global, like a dictionary everyone can read.
To use a variable you wrap its name inside var(): color: var(--brand-color). That's it. The browser looks up --brand-color, finds the value, and uses it. Now if you ever change the declaration, every single rule using that variable updates automatically. No find-and-replace, no risk of missing a spot.
/* ── Step 1: Declare your variables inside :root ────────────────────── :root is like the global dictionary for your whole page. Every selector below can read these values. */ :root { --brand-color: #3a86ff; /* primary blue used for buttons, links, highlights */ --brand-color-dark: #1a56cc; /* darker shade for hover states */ --text-color: #1a1a2e; /* near-black for body text — easier on eyes than pure black */ --background-color: #f8f9ff; /* very light blue-white page background */ --spacing-unit: 16px; /* base spacing — multiply this for consistent rhythm */ --border-radius: 8px; /* rounded corners used on cards, buttons, inputs */ --font-family-base: 'Segoe UI', Arial, sans-serif; } /* ── Step 2: Use variables with var() wherever you need them ─────────── Instead of typing #3a86ff over and over, we just reference the variable. */ body { background-color: var(--background-color); /* grabs #f8f9ff from :root */ color: var(--text-color); /* grabs #1a1a2e from :root */ font-family: var(--font-family-base); margin: 0; padding: var(--spacing-unit); /* 16px padding around the page */ } .btn-primary { background-color: var(--brand-color); /* #3a86ff — matches the :root declaration */ color: #ffffff; padding: calc(var(--spacing-unit) / 2) var(--spacing-unit); /* 8px top/bottom, 16px left/right */ border: none; border-radius: var(--border-radius); /* 8px rounded corners */ cursor: pointer; font-size: 1rem; transition: background-color 0.2s ease; } /* ── Hover state — only ONE value changes, not a whole new color string ── */ .btn-primary:hover { background-color: var(--brand-color-dark); /* swaps to #1a56cc on hover */ } .card { background-color: #ffffff; border: 1px solid var(--brand-color); /* consistent branded border */ border-radius: var(--border-radius); padding: var(--spacing-unit); /* 16px padding inside card */ margin-bottom: var(--spacing-unit); } /* ── Result: Change --brand-color in :root and EVERY rule above updates ── */
How CSS Variables Inherit and Scope — This Changes Everything
Here's the part that trips up most beginners: CSS variables follow the same inheritance rules as regular CSS properties like color and font-size. That means a variable declared on a parent element is automatically available to all of its children. And you can override a variable for just one section of the page by declaring it on a specific element — without touching any other section.
This is called scoping, and it's one of the most powerful features of CSS variables. Think of it like a company with a global dress code (blue shirts for everyone), but one department is allowed to wear red shirts instead. The global rule applies everywhere, except where a local rule overrides it.
A classic real-world use case: dark mode. Instead of writing an entirely separate dark-mode stylesheet, you just flip the values of your color variables on the body element (or a data-theme attribute), and every child element that uses those variables automatically gets the new look — no extra CSS rules needed for individual components.
/* ── Global variables on :root — available to the ENTIRE page ─────────── */ :root { --card-bg: #ffffff; /* white card background by default */ --card-text: #1a1a2e; /* dark text by default */ --card-border: #e0e0ef; /* light grey border by default */ --highlight-color: #3a86ff; /* blue accent color */ } /* ── The .card component uses the variables — no hard-coded colors ─────── */ .card { background-color: var(--card-bg); color: var(--card-text); border: 1px solid var(--card-border); padding: 20px; border-radius: 8px; margin: 12px 0; } .card h2 { color: var(--highlight-color); /* uses the global blue accent */ margin: 0 0 8px 0; } /* ── Dark theme section — scoped override on a specific container ────── Any .card INSIDE .dark-section automatically gets dark styles. Cards OUTSIDE .dark-section are completely unaffected. */ .dark-section { /* We re-declare the SAME variable names with new values. Children inherit these new values instead of the :root ones. */ --card-bg: #1a1a2e; /* dark background for cards in this section */ --card-text: #e0e0ff; /* light text so it's readable on dark bg */ --card-border: #3a3a5c; /* subtle dark border */ --highlight-color: #ff6b6b; /* swap to red accent for dark section */ background-color: #0d0d1a; /* the section itself is very dark */ padding: 24px; border-radius: 12px; } /* ── A fallback value — the second argument to var() is a safety net ─── If --card-shadow is never declared, the browser uses 'none' instead of breaking. */ .card { box-shadow: var(--card-shadow, none); /* fallback = 'none' if --card-shadow doesn't exist */ }
Cards INSIDE .dark-section: dark navy background, light lavender text, red heading, dark border.
The .card CSS rules are identical — only the variable values changed via scoping. Zero duplication.
Updating CSS Variables with JavaScript for Live, Dynamic Styles
This is where CSS Variables go from 'nice convenience' to 'genuinely powerful tool'. Because CSS variables are part of the DOM's computed style, JavaScript can read and write them in real time. When you update a variable's value via JavaScript, every single element using that variable re-renders immediately — no class swapping, no inline style spaghetti, no page reload.
You update a CSS variable in JavaScript using element.style.setProperty('--variable-name', 'new-value'). To update a global variable (one declared on :root), you target document.documentElement — that's the html element, which is what :root refers to in CSS.
You can also read the current value of a CSS variable using getComputedStyle(element).getPropertyValue('--variable-name'). This is useful for building things like color pickers, theme switchers, or sliders that let users customize the look of your app in real time — all without writing a single line of extra CSS.
// ── HTML structure this script expects ───────────────────────────────── // <div id="theme-controls"> // <button id="toggle-dark-mode">Toggle Dark Mode</button> // <input type="range" id="font-size-slider" min="14" max="24" value="16" /> // <input type="color" id="accent-color-picker" value="#3a86ff" /> // </div> // <p id="current-font-size">Current font size: 16px</p> // ── document.documentElement is the <html> element — same as :root in CSS ── const rootElement = document.documentElement; // ── Helper: update a CSS variable on :root from JavaScript ───────────── function setCSSVariable(variableName, value) { // setProperty takes the variable name (with --) and the new value as strings rootElement.style.setProperty(variableName, value); console.log(`CSS variable ${variableName} updated to: ${value}`); } // ── Helper: read the current value of a CSS variable ─────────────────── function getCSSVariable(variableName) { // getComputedStyle reads the FINAL computed value, including :root declarations const value = getComputedStyle(rootElement).getPropertyValue(variableName); return value.trim(); // trim() removes any accidental whitespace } // ── Feature 1: Dark mode toggle ───────────────────────────────────────── const darkModeButton = document.getElementById('toggle-dark-mode'); let isDarkMode = false; darkModeButton.addEventListener('click', function () { isDarkMode = !isDarkMode; // flip the boolean flag if (isDarkMode) { // Override the global color variables for dark mode setCSSVariable('--background-color', '#1a1a2e'); // dark navy page background setCSSVariable('--text-color', '#e0e0ff'); // light lavender text setCSSVariable('--brand-color', '#ff6b6b'); // switch accent to warm red darkModeButton.textContent = 'Switch to Light Mode'; } else { // Restore original light mode values setCSSVariable('--background-color', '#f8f9ff'); // light page background setCSSVariable('--text-color', '#1a1a2e'); // dark text setCSSVariable('--brand-color', '#3a86ff'); // back to blue accent darkModeButton.textContent = 'Switch to Dark Mode'; } }); // ── Feature 2: Live font-size slider ──────────────────────────────────── const fontSizeSlider = document.getElementById('font-size-slider'); const fontSizeDisplay = document.getElementById('current-font-size'); fontSizeSlider.addEventListener('input', function () { const newSize = this.value + 'px'; // e.g. '18px' // Update the CSS variable — every element using --base-font-size re-renders setCSSVariable('--base-font-size', newSize); // Also update the display so the user can see the current value fontSizeDisplay.textContent = `Current font size: ${newSize}`; }); // ── Feature 3: Accent color picker ────────────────────────────────────── const accentColorPicker = document.getElementById('accent-color-picker'); accentColorPicker.addEventListener('input', function () { // this.value is the hex color chosen by the user, e.g. '#ff6b6b' setCSSVariable('--brand-color', this.value); // Read it back to confirm — shows how getCSSVariable works const confirmedValue = getCSSVariable('--brand-color'); console.log(`Brand color is now: ${confirmedValue}`); }); // ── On page load: log the current value of our base variables ─────────── console.log('Initial brand color:', getCSSVariable('--brand-color')); // #3a86ff console.log('Initial background:', getCSSVariable('--background-color')); // #f8f9ff
Initial brand color: #3a86ff
Initial background: #f8f9ff
When dark mode button is clicked:
CSS variable --background-color updated to: #1a1a2e
CSS variable --text-color updated to: #e0e0ff
CSS variable --brand-color updated to: #ff6b6b
→ Every element using those variables instantly re-renders in dark colors.
When slider moves to 20:
CSS variable --base-font-size updated to: 20px
→ All text using var(--base-font-size) grows live without a page reload.
When color picker selects #e63946:
CSS variable --brand-color updated to: #e63946
Brand color is now: #e63946
| Feature / Aspect | CSS Variables (Custom Properties) | Hardcoded Values in CSS |
|---|---|---|
| Change one value, update everywhere | Yes — update the variable once, every reference updates | No — must find and replace every occurrence manually |
| Theme switching (dark/light mode) | Trivial — flip variable values on a parent element | Requires duplicating entire rule sets for each theme |
| JavaScript can read/write the value | Yes — via setProperty and getPropertyValue | No — JS can only set inline styles, not find all usages |
| Inheritance and scoping | Inherits down the DOM tree, scoped to any element | No concept of scope — each property stands alone |
| Browser support | All modern browsers, no IE11 support | Universal — works in every browser ever made |
| Fallback values | Built-in: var(--name, fallback) | N/A — the value is the value |
| Works inside calc() | Yes — var() works inside calc() expressions | Yes — raw values also work inside calc() |
| Readable from DevTools | Yes — visible in Computed Styles panel | Yes — but harder to trace where the value came from |
🎯 Key Takeaways
- Always declare global CSS variables inside :root — that's the equivalent of a global dictionary your entire stylesheet can read from.
- CSS variables inherit DOWN the DOM tree. Declaring a variable on a child element creates a scoped override — the parent and siblings are unaffected, but all descendants of that element see the new value.
- Use var(--variable-name, fallback-value) whenever a variable might not exist in a given context. The fallback prevents invisible layout breakage from a missing or misspelled variable name.
- From JavaScript, always use element.style.setProperty('--variable-name', value) to write and getComputedStyle(element).getPropertyValue('--variable-name') to read. Direct bracket assignment does not work reliably for custom properties.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Forgetting the double dash on the variable name — Writing -brand-color or brand-color instead of --brand-color. The browser silently ignores the declaration (no error, no warning) and falls back to the default or nothing at all. Fix it: always start custom property names with exactly two dashes (--). If your style looks mysteriously broken, open DevTools, click the element, check the Styles panel — a misspelled variable will show as invalid or unset.
- ✕Mistake 2: Trying to build a variable name dynamically inside var() — Writing something like var(--color-#{theme}) and expecting it to work. CSS is not a programming language — it can't evaluate expressions inside var(). Fix it: do the dynamic part in JavaScript instead. Use JS to determine the full variable name as a string and then call setProperty with that complete name.
- ✕Mistake 3: Declaring variables on a specific element and wondering why siblings or parents can't see them — CSS variables only flow DOWN the DOM tree through inheritance. If you declare --accent-color on a .sidebar div, a .header div at the same level can't access it. Fix it: declare shared variables on :root (or the nearest common ancestor of all elements that need them). Only declare variables on specific elements when you intentionally want that scope to be limited.
Interview Questions on This Topic
- QWhat is the difference between a CSS custom property (CSS variable) and a Sass/LESS variable — and when would you choose one over the other?
- QHow would you implement a live dark-mode toggle using CSS variables and JavaScript, without writing duplicate CSS rule sets for each theme?
- QIf a CSS variable is declared on a div with the value red, and a child span inside that div also has the same variable declared with the value blue, what color will a grandchild element inside the span see — and why?
Frequently Asked Questions
What is the difference between a CSS variable and a Sass variable?
A Sass variable (like $brand-color) is processed at build time — it's replaced with its value before the CSS file is ever sent to the browser, so it can't change at runtime. A CSS custom property (like --brand-color) lives in the browser and can be read, updated, and scoped dynamically using JavaScript or CSS inheritance. Sass variables are compile-time constants; CSS variables are runtime values. For theming and interactivity, CSS variables win. For things like mixins and loops, Sass is still useful.
Do CSS variables work in all browsers?
CSS custom properties work in all modern browsers — Chrome, Firefox, Safari, Edge, and mobile browsers. The one notable exception is Internet Explorer 11, which has zero support. If you need IE11 support (rare in 2024), you must provide hardcoded fallback values alongside your var() usage or use a PostCSS plugin to polyfill them at build time.
Can I use CSS variables inside media queries or animations?
You can use CSS variables INSIDE the rules of a media query (e.g., redefining :root variables for mobile breakpoints), but you cannot use var() as the condition value of the media query itself — so @media (max-width: var(--breakpoint-md)) won't work. For animations (keyframes), CSS variables do work and can be animated if the browser can interpolate the value type, though animating color variables requires careful browser support testing.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.