Fix: CSS Custom Properties (Variables) Not Working or Not Updating
Part of: React & Frontend Errors
Quick Answer
How to fix CSS custom properties not applying — wrong scope, missing fallback values, JavaScript not setting variables on the right element, and how CSS variables interact with media queries and Shadow DOM.
The Error
You define a CSS custom property (variable) but it has no effect:
:root {
--primary-color: #3b82f6;
}
.button {
background-color: var(--primary-color); /* Shows as transparent/initial */
}Or a variable defined inside a component doesn’t apply to child elements. Or JavaScript updates a CSS variable but the UI doesn’t change:
document.documentElement.style.setProperty('--theme-color', '#ff0000');
/* Nothing changes visually */Or the variable works in one browser but not another.
Why This Happens
CSS custom properties follow the cascade and inheritance rules — they are not globally available by default. Common failure causes:
- Wrong scope — the variable is defined on a selector that doesn’t apply to the element using it.
--coloron.cardis only available to.cardand its descendants, not to siblings or parents. - Typo in variable name —
var(--primaryColor)vsvar(--primary-color). CSS variable names are case-sensitive. - Missing
:rootdeclaration — variables defined anywhere other than:root(or a parent element) are not globally available. - JavaScript setting property on wrong element —
element.style.setProperty('--var', value)sets it on that element only. Child elements inherit it; parent and sibling elements do not. - Overridden by specificity — a more specific rule sets the same variable to a different value, or
!importanton a fallback. - Browser doesn’t support CSS variables — IE11 has no support. All modern browsers (Chrome 49+, Firefox 31+, Safari 9.1+) do.
- Shadow DOM isolation — CSS variables cross Shadow DOM boundaries (they are inherited), but only if the host element has them in scope.
A more subtle failure mode is the “invalid at computed value time” rule. If a variable’s value is syntactically valid CSS but semantically invalid for the property — for example, --width: red; width: var(--width); — the entire declaration is dropped and the element reverts to the initial value. This rule is what makes typos look like silent failures. DevTools will show var(--width) resolving to red, then show width: initial on the same element, and you have to read the Computed tab carefully to see that the declaration was rejected.
The other surprise is animation. Custom properties are by default the <custom-ident> type, not numbers or colors. That means transition: --primary-color 300ms does nothing — the browser has no idea how to interpolate two <custom-ident> values. To animate a custom property you must register it with @property (or use a workaround). This is the single biggest reason “I set a CSS variable on hover and it snaps instead of fading” — the variable changes instantly, the dependent property re-renders instantly, and there is no animation between the two states.
Version History That Changes the Failure Mode
CSS Custom Properties have a long browser history, and what works depends heavily on which spec features your target browsers support.
CSS Custom Properties Level 1 spec (December 2015). Defined the --* syntax, var() function, and the inheritance model. This is the base layer — every “modern browser” can do this much.
Chrome 49 / Safari 9.1 / Firefox 31 (early 2016). Initial browser support landed. Edge added it in version 15 (April 2017). Internet Explorer never supported CSS Custom Properties at any version. If you still ship to IE11 (rare in 2026 but it happens in regulated industries), variables silently degrade to the fallback value or to initial.
Chrome 78 / Firefox 78 / Safari 13.1 (2019–2020). Stable across all evergreen browsers. From this point forward, “browser support” stopped being the practical concern and “spec features” took over.
@property CSS at-rule. Defined in CSS Properties and Values API Level 1. Lets you declare the syntax, initial value, and inheritance of a custom property — and crucially, makes it animatable.
- Chrome 85 (August 2020) — first browser to ship.
- Firefox 128 (July 2024) — Firefox finally caught up.
- Safari 16.4 (March 2023) — Safari support.
If you target browsers before these versions, @property is a no-op and your “animatable variable” will snap instead of transition. The Fix 8 example below relies on @property and requires Safari 16.4+ / Firefox 128+ / Chrome 85+.
:has() selector interaction (2023+). Safari 15.4 (March 2022) shipped first, Chrome 105 (August 2022), Firefox 121 (December 2023). :has() can scope variables to a parent that contains a specific child: :has(> .danger) { --bg: red; }. Before broad :has() support, this pattern required JavaScript. Where you write CSS depending on which browsers your team supports.
CSS Nesting (2023–2024). Native nesting shipped in Chrome 112 (April 2023), Safari 16.5 (May 2023), Firefox 117 (August 2023). This affects variables because nested rules let you redefine variables in a scoped, readable way without preprocessor tooling. If you adopted nesting in 2024, you can write .card { --bg: white; &.dark { --bg: black; } } natively — but it will be silently flat in browsers from 2022 or earlier.
Tailwind CSS v4 (2024–2025). Tailwind’s v4 design tokens are CSS Custom Properties at runtime. The @theme directive emits :root { --color-primary: ... }. If you migrate from v3 to v4 and a class stops working, the most common cause is that the variable name changed — --colors-primary-500 in v3 became --color-primary-500 (singular color) in v4. Compile your tokens and inspect the emitted CSS.
Cascade Layers (@layer) interaction (2022+). Chrome 99, Safari 15.4, Firefox 97. Variables defined inside a @layer are still subject to the cascade — a variable in a low-priority layer can be overridden by an unlayered rule even if specificity is lower. Confusion here usually shows up as “my variable is correct in DevTools’ resolved view but the property using it shows the wrong color.” That’s the layer ordering, not the variable.
Fix 1: Define Variables in the Correct Scope
/* Global — available everywhere */
:root {
--primary: #3b82f6;
--spacing-md: 1rem;
--font-size-base: 16px;
}
/* Scoped — only available within .card and its descendants */
.card {
--card-padding: 1.5rem;
--card-border: 1px solid #e5e7eb;
}
.card-header {
padding: var(--card-padding); /* Works — .card-header is a descendant of .card */
}
.other-element {
padding: var(--card-padding); /* Undefined — not a descendant of .card */
}Use :root for truly global variables:
:root {
/* Design tokens — use everywhere */
--color-primary: #3b82f6;
--color-secondary: #10b981;
--color-danger: #ef4444;
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 2rem;
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
--radius-lg: 1rem;
--font-sans: 'Inter', system-ui, sans-serif;
--font-mono: 'Fira Code', monospace;
}Fix 2: Always Provide a Fallback Value
If a variable is undefined, var() resolves to its fallback — or to the initial value if no fallback is given (often transparent for colors, 0 for lengths):
.button {
/* Without fallback — transparent if --primary is not defined */
background-color: var(--primary);
/* With fallback — uses #3b82f6 if --primary is not defined */
background-color: var(--primary, #3b82f6);
/* Chained fallback */
background-color: var(--button-bg, var(--primary, #3b82f6));
}Use fallbacks to make components self-contained:
/* Component defines its own defaults via fallback */
.card {
background: var(--card-bg, var(--surface, white));
border-radius: var(--card-radius, var(--radius-md, 0.5rem));
padding: var(--card-padding, 1.5rem);
color: var(--card-text, var(--text-primary, #111827));
}
/* Consumer can override any of these */
.dark .card {
--card-bg: #1f2937;
--card-text: #f9fafb;
}Fix 3: Fix JavaScript Not Updating CSS Variables
element.style.setProperty() sets an inline style on that specific element. The variable is then available to that element and all its descendants:
// Sets --theme on the root — available globally
document.documentElement.style.setProperty('--theme-color', '#ff0000');
// Sets --card-bg on a specific element — only affects that element and children
const card = document.querySelector('.card');
card.style.setProperty('--card-bg', '#f0f9ff');
// Read a variable's computed value
const value = getComputedStyle(document.documentElement)
.getPropertyValue('--theme-color')
.trim();
console.log(value); // '#ff0000'Common mistake — setting on the wrong element:
// Wrong — sets on the button, not globally
document.querySelector('button').style.setProperty('--primary', '#red');
// Other buttons still use the old value
// Correct — set globally on :root
document.documentElement.style.setProperty('--primary', '#red');
// All elements using var(--primary) update immediatelyTheme switching with CSS variables:
function setTheme(theme) {
const root = document.documentElement;
if (theme === 'dark') {
root.style.setProperty('--bg', '#111827');
root.style.setProperty('--text', '#f9fafb');
root.style.setProperty('--border', '#374151');
} else {
root.style.removeProperty('--bg'); // Removes inline override — reverts to :root CSS
root.style.removeProperty('--text');
root.style.removeProperty('--border');
}
}
// Or use a data attribute (cleaner approach)
document.documentElement.setAttribute('data-theme', 'dark');:root {
--bg: white;
--text: #111827;
}
:root[data-theme="dark"] {
--bg: #111827;
--text: #f9fafb;
}Fix 4: Fix Variables in Media Queries
CSS custom properties cannot be used inside @media query conditions — only in property values:
/* Wrong — variables cannot be used in media query conditions */
:root { --breakpoint-md: 768px; }
@media (min-width: var(--breakpoint-md)) { /* Does not work */
.container { max-width: 1200px; }
}
/* Correct — use variables in property values inside media queries */
@media (min-width: 768px) {
:root {
--font-size-base: 18px; /* Override variable value inside media query */
--spacing-md: 1.25rem;
}
.container {
padding: var(--spacing-md); /* Uses the updated variable */
}
}Responsive design with CSS variables:
:root {
--columns: 1;
--gap: 1rem;
--font-size-h1: 1.75rem;
}
@media (min-width: 640px) {
:root {
--columns: 2;
--gap: 1.5rem;
--font-size-h1: 2.25rem;
}
}
@media (min-width: 1024px) {
:root {
--columns: 3;
--gap: 2rem;
--font-size-h1: 3rem;
}
}
.grid {
display: grid;
grid-template-columns: repeat(var(--columns), 1fr);
gap: var(--gap);
}
h1 { font-size: var(--font-size-h1); }Fix 5: Fix Variables with Calc()
When using var() inside calc(), the units must be part of the variable or explicitly added:
:root {
--base-size: 16; /* Unitless number */
--base-size-px: 16px; /* With unit */
--multiplier: 1.5;
}
.element {
/* Wrong — unitless variable in calc without unit */
font-size: calc(var(--base-size) * 1); /* 16 is invalid — no unit */
/* Correct — multiply unitless number by a unit */
font-size: calc(var(--base-size) * 1px); /* = 16px */
/* Or use a variable that already includes the unit */
font-size: calc(var(--base-size-px) * var(--multiplier)); /* = 24px */
/* Correct — add/subtract values with units */
width: calc(var(--sidebar-width, 250px) + 2rem);
}Fix 6: Fix Variables in Tailwind CSS / CSS-in-JS
Tailwind v4 (CSS-based config):
/* In your @theme or CSS file */
@theme {
--color-primary: #3b82f6;
--color-primary-dark: #2563eb;
}
/* Use in arbitrary values */
/* class="bg-[var(--color-primary)]" */Tailwind v3 (JavaScript config):
// tailwind.config.js
module.exports = {
theme: {
extend: {
colors: {
primary: 'var(--color-primary)', // Reference CSS variable
},
},
},
};/* Define the variable */
:root { --color-primary: #3b82f6; }<!-- Use the Tailwind class — resolves to var(--color-primary) -->
<div class="bg-primary text-white">...</div>CSS Modules:
/* styles.module.css */
.button {
background-color: var(--button-bg, var(--primary, #3b82f6));
color: var(--button-text, white);
padding: var(--button-padding, 0.5rem 1rem);
}// React component — override variables via inline style
<button
className={styles.button}
style={{ '--button-bg': '#10b981', '--button-text': 'white' }}
>
Custom Button
</button>Pro Tip: Passing CSS variables via
styleprop in React works perfectly for theming individual components. TypeScript will complain because custom properties aren’t inCSSProperties— cast toReact.CSSPropertiesor extend the type:const style = { '--button-bg': '#10b981' } as React.CSSProperties;
Fix 7: Debug CSS Variable Resolution
Inspect variables in browser DevTools:
- Open DevTools → Elements panel.
- Select an element.
- Go to the Computed tab.
- Search for the variable name or look for properties using
var(). - In the Styles panel, hover over a
var()value — DevTools shows the resolved value.
Log all CSS variables on an element:
function getCSSVariables(element = document.documentElement) {
const styles = getComputedStyle(element);
const variables = {};
for (const prop of styles) {
if (prop.startsWith('--')) {
variables[prop] = styles.getPropertyValue(prop).trim();
}
}
return variables;
}
console.log(getCSSVariables()); // All :root variables
console.log(getCSSVariables(document.querySelector('.card'))); // Card-scopedCheck if a variable is defined:
const value = getComputedStyle(document.documentElement)
.getPropertyValue('--primary-color')
.trim();
if (!value) {
console.warn('--primary-color is not defined');
}Still Not Working?
Check for a typo — names are case-sensitive:
:root { --primaryColor: blue; }
.el { color: var(--primary-color); } /* Different name — undefined */
.el { color: var(--primaryColor); } /* Correct */Check if a CSS preprocessor is eating your variables. Some Sass/Less configurations try to resolve var() at compile time, which fails. Ensure your preprocessor is configured to pass var() through unchanged:
// Sass — use #{} to interpolate carefully or just write plain CSS var()
.element {
color: var(--primary); // Sass passes this through unchanged
}Check for Shadow DOM. CSS variables do cross the Shadow DOM boundary (they are inherited), but only if the host element is inside the scope where the variable is defined. Variables defined inside a shadow root do not leak out to the light DOM.
Variable will not animate? Register it with @property. Custom properties default to type <custom-ident>, which is not animatable. To animate a property — including transitions and CSS animations — declare it with @property so the browser knows how to interpolate. This needs Safari 16.4+, Firefox 128+, Chrome 85+:
@property --angle {
syntax: '<angle>';
initial-value: 0deg;
inherits: false;
}
.box {
--angle: 0deg;
background: linear-gradient(var(--angle), red, blue);
transition: --angle 600ms ease;
}
.box:hover {
--angle: 180deg;
}In browsers without @property support, the --angle change still happens but it snaps instantly — there is no animation.
Check Cascade Layers (@layer) ordering. If your variable is defined inside an @layer reset or @layer components block but a later unlayered rule defines the same variable, the unlayered rule wins regardless of specificity. Use the Styles tab in DevTools and look for the small layer pill next to each rule. Move the canonical definition into the highest-priority layer or remove the unlayered override.
Tailwind v4 token name changed. If a Tailwind utility stopped working after a v3-to-v4 migration, the emitted CSS variable name almost certainly changed (--colors-primary-500 to --color-primary-500, plural to singular color). Inspect the compiled output and update any hard-coded var(--colors-*) references. See Fix: Tailwind v4 Not Working for the full migration checklist.
For related CSS issues, see Fix: Tailwind Classes Not Applying, Fix: CSS Grid Not Working, and Fix: CSS Animation Not Working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: View Transitions API Not Working — No Animation Between Pages, Cross-Document Transitions Failing, or Fallback Missing
How to fix View Transitions API issues — same-document transitions, cross-document MPA transitions, view-transition-name CSS, Next.js and Astro integration, custom animations, and browser support.
Fix: Panda CSS Not Working — Styles Not Applying, Tokens Not Resolving, or Build Errors
How to fix Panda CSS issues — PostCSS setup, panda.config.ts token system, recipe and pattern definitions, conditional styles, responsive design, and integration with Next.js and Vite.
Fix: UnoCSS Not Working — Classes Not Generating, Presets Missing, or Attributify Mode Broken
How to fix UnoCSS issues — Vite plugin setup, preset configuration, attributify mode, icons preset, shortcuts, custom rules, and integration with Next.js, Nuxt, and Astro.
Fix: CSS Container Query Not Working — @container and container-type Issues
How to fix CSS container queries not working — setting container-type correctly, understanding containment scope, fixing @container syntax, and handling browser support and specificity issues.