CSS Custom Properties (Variables) — Practical Examples
Quick answer
💡Declare CSS variables with a double-dash prefix inside any selector: --primary-color: #3b82f6. Use them with var(): color: var(--primary-color). Declare on :root for global access. Add a fallback as the second argument: var(--primary-color, #000). Read and write them from JavaScript with getComputedStyle(el).getPropertyValue('--name') and el.style.setProperty('--name', value).
Error symptoms
- ✕
A CSS variable is declared but the color or value does not appear in the browser - ✕
var() falls back to the fallback value even though the variable was declared - ✕
Changing a variable in JavaScript does not update the UI - ✕
A variable works in regular selectors but not inside a media query breakpoint value - ✕
An animation does not interpolate a CSS variable smoothly — it jumps - ✕
A variable declared on a child element is not available in a parent selector
Common causes
- •Variable declared with a typo — --primaryColor vs --primary-color
- •Variable declared inside a media query block instead of a base selector
- •Property value is not valid for the property — invalid value at computed time
- •Variable is declared on an element that is not an ancestor of the using element
- •Animation of a CSS variable without @property declaration — not animatable by default
- •JavaScript setProperty targeting the wrong element or using incorrect property name format
When it happens
- •When first migrating from Sass variables or hardcoded values to custom properties
- •When building a dark mode system that swaps variable values via class or media query
- •When trying to animate a color or position using CSS variables in a keyframe animation
- •When reading or writing theme values from JavaScript for a dynamic UI
Examples and fixes
A single source of truth for a color used across multiple components.
Basic declaration and usage
❌ Wrong
.btn {
background: #3b82f6;
border-color: #3b82f6;
}
.link {
color: #3b82f6;
}
.badge {
background: #3b82f6;
}✅ Fixed
:root {
--primary: #3b82f6;
}
.btn {
background: var(--primary);
border-color: var(--primary);
}
.link { color: var(--primary); }
.badge { background: var(--primary); }When the primary color needs to change, updating four hardcoded values is error-prone — it is easy to miss one. Declaring --primary on :root and referencing it with var() creates a single source of truth. Updating --primary once propagates the change to every property that references it. This pattern scales to design tokens with dozens of variables covering color, spacing, typography, and border radius.
Without @property, CSS variables cannot be transitioned — they snap instantly.
Animating a CSS variable with @property
❌ Wrong
:root { --hue: 0; }
.circle {
background: hsl(var(--hue), 80%, 60%);
transition: --hue 1s;
}
.circle:hover { --hue: 120; }✅ Fixed
@property --hue {
syntax: '<angle>';
inherits: false;
initial-value: 0deg;
}
:root { --hue: 0deg; }
.circle {
background: hsl(var(--hue), 80%, 60%);
transition: --hue 1s;
}
.circle:hover { --hue: 120deg; }CSS custom properties are untyped by default. The browser does not know whether --hue is a number, a color, or an angle, so it cannot interpolate between two values during a transition. @property registers the variable with an explicit type (syntax: '<angle>'), which tells the browser how to interpolate it. With a type declared, CSS transitions and animations can smoothly interpolate the variable's value between states.
How CSS custom properties work
CSS custom properties — officially called CSS custom properties for cascading variables — are properties that you define yourself, always beginning with two dashes. They follow the full CSS cascade: they can be declared on any selector, they inherit down the DOM tree, and they can be overridden in more specific selectors or child elements. The var() function reads the nearest ancestor's value for a given property name.
The scope of a custom property is defined by the selector it is declared in. Declaring on :root makes a variable available everywhere because :root matches the html element, the root of all other elements. Declaring on .card means the variable is only available to .card and its descendants. Declaring on a child element does not make the variable available to parent or sibling elements — inheritance is downward only.
Custom properties are resolved at computed value time, not at parse time. This means the browser substitutes the variable's value when it computes styles for a specific element. If --color is declared as invalid at computed time — for example, if its value is not a valid color for a color property — the property falls back to its inherited value or initial value, not to the var() fallback. The var() fallback only applies when the variable is not defined, not when it is invalid.
Custom properties are case-sensitive. --Primary-Color and --primary-color are two different properties. This is a common source of bugs when developers declare a variable in one place and reference a differently cased name elsewhere. Always use a consistent naming convention — lowercase with hyphens is the standard.
Debugging variable issues in DevTools
Open Chrome DevTools and select the element where the variable is not working. In the Computed panel, find the property that should be using the variable and check its resolved value. If it shows the initial value or a different color, the variable is either not defined in an ancestor, is defined with a typo, or the value is invalid for the property.
In the Styles panel, click on any var() reference — Chrome DevTools will show the resolved value in a tooltip or inline. This lets you confirm what value the variable holds at that specific element's scope without hunting through the stylesheet.
To check the inheritance chain, inspect the element's ancestors one by one and look for the --variable-name declaration in their Styles panels. Each element's Styles panel shows properties declared on that element and the inherited custom properties in the Inherited section. Walk up the tree to find where the variable is — or confirm it is missing.
For JavaScript-driven variable changes, use the Console panel. Run getComputedStyle(document.documentElement).getPropertyValue('--primary-color') to read the current value from the root. If it returns an empty string, the variable is not set. Remember that getPropertyValue returns a string with possible leading whitespace — always call .trim() on the result before comparing or parsing it.
Firefox DevTools surfaces custom properties differently from Chrome. In Firefox, open the Inspector panel, select the element, and look in the Rules panel for the var() call highlighted in a different color — hovering it shows the resolved value. Firefox also shows custom property values in the Fonts panel when variables are used for font-size or font-family, which can be handy for typography debugging. When a variable appears valid in Chrome but resolves incorrectly in Firefox, check whether the value uses @property registration that may have limited support in the browser version under test.
Common patterns and correct implementations
For a missing variable, confirm the variable name matches exactly — dash case, exact spelling — and that it is declared on an ancestor of the using element. If the variable is meant to be global, always declare it on :root rather than on a component-specific selector.
For dark mode theming, the two most reliable patterns are: a [data-theme='dark'] attribute on the html element toggled by JavaScript, and a @media (prefers-color-scheme: dark) block that overrides variable values. Both patterns work by re-declaring the same variable names with different values. The media query approach requires no JavaScript and respects the system setting. The data attribute approach allows explicit user control.
For JavaScript integration, always use el.style.setProperty('--variable-name', value) to write custom properties — not el.style['--variable-name'] = value, which does not work. The double-dash prefix must be included in the property name string. To read from the computed value, use getComputedStyle(el).getPropertyValue('--variable-name').trim(). The extra .trim() is important because browsers may include a leading space in the returned value.
For calc() with variables, ensure that units are compatible. calc(var(--spacing) * 2) works when --spacing is declared with units (--spacing: 1rem). If --spacing is declared as a unitless number (--spacing: 16), then calc(var(--spacing) * 1px) is needed to give it a unit before math operations. Using unitless variables requires more careful usage at every call site.
Chaining multiple fallbacks is supported by nesting var() calls inside the fallback position. The syntax var(--custom, var(--brand, #3b82f6)) tries --custom first, falls back to --brand if --custom is undefined, and uses the hardcoded value if both are undefined. This pattern is useful in design system components that expose optional override variables but always need a guaranteed resolved value regardless of how the component is configured by consumers.
Media queries, animations, and inheritance
CSS variables cannot be used as media query values. @media (max-width: var(--breakpoint)) does not work. The media query condition is evaluated before custom properties are resolved, so variables are always substituted as invalid in that position. Use PostCSS or Sass variables for breakpoint values that feed media queries — these are compile-time constants, not runtime values.
Within a media query, you can re-declare custom properties on :root to change their values at different viewport sizes. This is a powerful pattern for responsive spacing and typography. Declare --font-size:16px in the base style and --font-size:18px inside a @media (min-width: 768px) block. All elements using font-size:var(--font-size) automatically update when the breakpoint triggers.
Animating CSS variables requires @property with a registered type. Without registration, the browser cannot interpolate between two custom property values during a transition or animation — it jumps instantly from start to end. @property is supported in Chrome and Edge from 2021 and in Firefox from 2024. For Safari, check current support before using @property in production without a fallback.
Custom properties follow the cascade for inheritance, which means a variable declared on a child does not affect parents or siblings — only the child's own descendants. If a component needs to expose configuration points to its children, it should declare variables on the component's root element: .card { --card-bg: white; } gives all .card children access to --card-bg. External consumers can then override it: .dark-section .card { --card-bg: #1e293b; }.
When using @property to register a typed variable for animation, the syntax field supports a range of type keywords including angle, color, integer, length, number, percentage, and length-percentage. Choosing the right type is important: a variable declared with syntax:'<color>' enables smooth color interpolation in transitions, whereas an unregistered variable snaps. This mechanism is what powers smooth hue rotation animations, gradient position animations, and other effects that previously required JavaScript-driven requestAnimationFrame loops.
Common mistakes with CSS variables
Declaring variables inside a media query block at the :root level is a common and valid pattern — but developers sometimes expect variables declared inside a block to be accessible outside it. They are not. A variable declared inside @media (min-width: 768px) on :root is only active when the media query matches. When it does not match, the variable falls back to its value from outside the media query, or it is undefined if no base value was declared.
Expecting var() to cascade upward is another misunderstanding. If a child declares --color: red, its parent cannot read --color. Custom property inheritance goes from parent to child, like all CSS inheritance. If you need a child's property to communicate a value upward, JavaScript is required.
Using var() for non-custom properties is a JavaScript mistake, not a CSS one. In JavaScript, el.style.getPropertyValue is used for both regular and custom properties. However, regular properties are read and written differently: el.style.color = 'red' (without dashes) for regular, and el.style.setProperty('--my-color', 'red') (with double dashes, using the method) for custom. Mixing these patterns silently fails.
Forgetting the initial-value in @property declarations. When you use @property to register a custom property, initial-value is required unless inherits is set to true. If you omit initial-value, the browser may throw an error or treat the property as unregistered. Always include initial-value with a valid value for the declared syntax.
Another common mistake is using a CSS variable inside a shorthand property where the shorthand has known parsing limitations. For example, font: var(--font-size)/var(--line-height) var(--font-family) can fail to parse correctly in some browsers because the shorthand parser applies strict token validation before custom properties are resolved. Splitting the shorthand into individual longhand properties — font-size, line-height, font-family — and using variables in each one separately is more reliable and easier to debug.
Best practices for CSS custom properties
Organize your custom properties into logical groups: color tokens, spacing tokens, typography tokens, and component-specific overrides. Declare all global tokens on :root and document them with comments. Component-specific variables should be declared on the component's root class, with default values that make the component usable without any configuration.
Use a consistent naming convention. A common pattern is --[category]-[variant]-[property]: --color-primary-base, --color-primary-hover, --spacing-sm, --spacing-md. This makes the system discoverable and prevents naming conflicts as the codebase grows.
When building a design system, declare variables in layers. Use a base :root declaration for default values, then override in [data-theme] selectors for theme variants, and in component classes for component-level customization. This layered approach makes the cascade explicit and predictable.
Consider using @property for any variable you plan to animate. Even if you do not animate it today, registering a type prevents a class of bugs if animation is added later. It also allows the browser to perform type validation, catching invalid values at parse time rather than silently falling back at computed value time. The overhead of @property registration is negligible.
For design tokens shared between CSS, JavaScript, and native mobile platforms, generate CSS custom property declarations from a single source-of-truth token file — typically a JSON or YAML file processed by a build tool like Style Dictionary. This ensures that --color-primary in your CSS matches the exact same hex value used in your JavaScript theme object and your iOS color asset. Keeping tokens in one canonical format eliminates drift between platforms over time.
When scoping component variables, prefer declaring them on the component's own root element rather than :root, even when the values are the same as global defaults. This makes each component self-contained: a consumer can change a component's variable by targeting the component class without needing to understand or modify the global token system. It also makes it straightforward to support multiple instances of the same component with different visual configurations on the same page.
Quick fix checklist
- ✓Confirm variable name is an exact match including case and dashes
- ✓Ensure the variable is declared on an ancestor of the element using it
- ✓Use :root for global variables, component root class for component-scoped variables
- ✓Add a fallback: var(--name, fallback-value) for resilience
- ✓Use getComputedStyle().getPropertyValue('--name').trim() to read from JavaScript
- ✓Use el.style.setProperty('--name', value) to write from JavaScript
- ✓Register with @property before animating a custom property
- ✓Check DevTools Computed tab for the resolved var() value
Related guides
Frequently asked questions
What is the difference between CSS variables and Sass variables?
CSS custom properties exist at runtime in the browser and respond to the cascade, inheritance, and JavaScript changes. Sass variables are compile-time constants that are substituted during the build process — they do not exist in the browser at all. CSS variables support dynamic theming and JavaScript interaction; Sass variables do not. For browser-based theming, CSS custom properties are required.
Can I use CSS variables inside media queries?
You can re-declare variable values inside media queries — this is a common and powerful technique for responsive design. What you cannot do is use a variable as the media query condition itself: @media (max-width: var(--bp)) does not work because media query values are evaluated before custom properties are resolved.
Why does my CSS variable fallback value not work?
The var() fallback only activates when the variable is not defined — it does not activate when the variable is defined but has an invalid value for the property. For example, if --size is 'blue', var(--size, 16px) will not fall back to 16px because --size is defined. The fallback is purely for undefined variables.
Can I animate CSS custom properties?
Not without @property. By default, CSS variables are untyped and cannot be interpolated during transitions or animations — they snap from one value to another. Register the variable with @property and specify a syntax type (such as '<color>', '<length>', or '<angle>') to enable smooth animation.
How do I read a CSS variable from JavaScript?
Use getComputedStyle(element).getPropertyValue('--variable-name').trim(). The .trim() is important because some browsers return the value with a leading space. To read from the root, pass document.documentElement as the element. Do not use element.style.getPropertyValue — that only reads inline styles, not computed values from the stylesheet.
Do CSS variables work in all browsers?
CSS custom properties are supported in all modern browsers — Chrome, Firefox, Safari, and Edge — as well as their mobile counterparts. IE11 does not support them. @property (for animated variables) has broader support now but check current compatibility for your target browsers. For IE11 support, use PostCSS custom-properties plugin to compile variables to static values at build time.
Can a CSS variable reference another CSS variable?
Yes. var() can be nested: --button-bg: var(--primary-color); means --button-bg evaluates to whatever --primary-color is. You can also use variables inside var() fallbacks: var(--custom-color, var(--primary-color)) falls back to --primary-color if --custom-color is undefined. Circular references (a variable referencing itself) resolve to the invalid value.
How do CSS variables interact with dark mode?
Declare light-mode values on :root, then override them inside @media (prefers-color-scheme: dark) or on [data-theme='dark']. All elements using var() automatically reflect the updated values when the theme changes. No class duplication is needed. Combining both the media query and a data attribute lets you respect system preference by default while allowing user override via JavaScript.
All tools run in your browser. Your data never leaves your device. Last updated: 2026-05-06.