Custom Properties and CSS Variables
Introduction
Custom properties are developer-defined CSS properties whose names begin with two hyphens. They are commonly called CSS variables because their values can be reused through the var() function and changed through the cascade, inheritance, media queries, state selectors, and JavaScript. They are useful for colors, spacing, type scales, radii, shadows, component dimensions, motion timings, and theme values.
The word variable is convenient, but a custom property is more than a global text replacement. It is a real CSS property that participates in the cascade and normally inherits. Its value can differ from one element to another, and substitution occurs within the receiving element's computed-value context. These characteristics make custom properties especially valuable for component APIs and design systems.
Good variable architecture reduces repetition without hiding meaning. Shared design decisions belong in broad tokens, component configuration belongs close to the component, and one-off declarations do not need abstraction merely because custom properties exist.
What Is a CSS Custom Property?
A custom property is a property with a dashed identifier such as --color-primary or --space-md. Its unregistered value can contain almost any token sequence. Declaring it alone does not create a visual effect; another property must consume it.
:root {
--color-primary: #2563eb;
}
.button {
background-color: var(--color-primary);
}
The browser substitutes the winning value of --color-primary into the background declaration on each matching button.
Basic Syntax
Definition uses a name beginning with --, followed by a colon and value. Consumption uses the var() function.
.scope {
--variable-name: value;
}
.element {
property: var(--variable-name);
}
Names are case-sensitive. --primary and --Primary are separate properties. Consistent lowercase naming avoids difficult mismatches.
Why Use Custom Properties?
Repeated literal values create maintenance risk. If a brand color appears in headers, buttons, links, focus rings, and charts, changing every occurrence is tedious and easy to do inconsistently. A named token centralizes the decision while preserving contextual overrides.
Custom properties also enable runtime behavior that build-time variables cannot provide. A theme class can override a small token set, a component can expose configuration to its parent, and JavaScript can update a value without rewriting every dependent declaration.
Why :root Is Common
The :root pseudo-class selects the document root, normally the <html> element. Values declared there are available to descendants through inheritance, making it a convenient location for site-wide tokens.
:root {
--color-text: #172033;
--color-surface: #ffffff;
--space-md: 1rem;
}
:root has pseudo-class specificity, which is higher than the html type selector. Global placement is a convention, not a requirement; component-only values should often stay local.
Using var()
The var() function retrieves the computed custom-property value in the current element's context. It can be used wherever substitution produces a valid value for the receiving property.
:root {
--body-font: system-ui, sans-serif;
--text-color: #1f2937;
}
body {
font-family: var(--body-font);
color: var(--text-color);
}
Writing color: --text-color is invalid because a custom-property name is not consumed without var().
Values Custom Properties Can Store
An unregistered custom property can hold colors, lengths, numbers, font lists, gradients, shadows, images, transforms, transitions, and complete shorthand token sequences. Validation usually occurs after substitution into a real property.
:root {
--brand: #2563eb;
--space: 1rem;
--card-shadow: 0 0.5rem 1.5rem rgb(15 23 42 / 15%);
--body-font: Inter, system-ui, sans-serif;
--fast-transition: 150ms ease;
}
The flexibility is useful, but public tokens should document the expected type so consumers do not substitute incompatible values.
Custom Properties Normally Inherit
Unregistered custom properties inherit by default. A child can use a value declared on its parent even when the child's selector never declares that property.
.notice {
--notice-color: #1d4ed8;
}
.notice__title {
color: var(--notice-color);
}
If a nested element overrides the variable, its descendants inherit the nearer value. This is the foundation of local themes and component variants.
Scope Is Determined by the DOM
A custom property is available on the element where it wins the cascade and on descendants that inherit it. It is not available to ancestors or unrelated sibling branches. Scope therefore follows the document tree rather than the textual location of the declaration in the stylesheet.
A value declared on .card can configure elements inside that card but does not leak into a neighboring card unless a shared ancestor provides it.
Global and Local Variables
Global variables are appropriate for decisions shared across the application: semantic colors, type families, spacing scales, standard radii, and elevation. Local variables are appropriate for one component's configurable details.
:root {
--color-accent: #2563eb;
--space-md: 1rem;
}
.card {
--card-padding: 1.5rem;
padding: var(--card-padding);
}
Keeping local details out of :root reduces namespace noise and makes ownership clear.
The Cascade Applies to Custom Properties
Custom-property declarations participate in origin, importance, cascade layers, specificity, scoping, and source order. The browser selects a value on each element before inheritance supplies values to descendants. The var() function then uses that winning value.
:root { --accent: #2563eb; }
.marketing { --accent: #047857; }
.marketing .button { background: var(--accent); }
The button becomes green because it inherits the closer value from the marketing section. The consuming declaration does not need to change.
Overriding Variables for Variants
A component can define defaults once and let modifier classes change only the relevant inputs. This keeps structural declarations together and reduces duplicated state rules.
.button {
--button-bg: #2563eb;
--button-text: #ffffff;
background: var(--button-bg);
color: var(--button-text);
}
.button--danger {
--button-bg: #b91c1c;
}
The danger variant changes configuration rather than restating the complete button design.
State-Based Overrides
Hover, focus, active, disabled, and validation states can override component tokens. The underlying declarations continue to consume the same property.
.button {
--button-bg: #2563eb;
background: var(--button-bg);
}
.button:hover {
--button-bg: #1d4ed8;
}
.button:disabled {
--button-bg: #94a3b8;
}
Do not encode state only through color. Disabled and error states need appropriate semantics, text, icons, or interaction behavior where required.
Fallback Values
The second argument to var() is a fallback used when the referenced custom property has no usable value. It is separated by the first comma after the property name.
.button {
background: var(--button-bg, #2563eb);
}
The fallback is not used merely because the variable's substituted value is unsuitable for the receiving property in every scenario. Understanding invalid-at-computed-value behavior is important when debugging.
Nested Fallbacks
A fallback can call another variable, creating a controlled chain from component token to semantic token to literal safety value.
.badge {
color: var(
--badge-text,
var(--color-on-accent, #ffffff)
);
}
Long fallback chains can hide configuration mistakes. Use them when the hierarchy is intentional and documented.
Commas Inside Fallbacks
Everything after the first fallback-separating comma belongs to the fallback, including additional commas. This allows font lists, shadows, and other comma-separated values.
font-family: var(--body-font, Arial, Helvetica, sans-serif);
The fallback here is the complete font stack, not only Arial.
Undefined Variables Without Fallbacks
If a referenced custom property is missing or has the guaranteed-invalid value and no fallback exists, the var() substitution makes the declaration invalid at computed-value time. The property then follows CSS rules for that invalid state rather than simply searching backward for an earlier declaration as many beginners expect.
Use a fallback when absence is a supported configuration. If absence represents a programming error, allowing it to fail visibly may be more useful than masking it.
Invalid at Computed-Value Time
An unregistered custom property accepts token sequences without knowing where they will be used. --spacing: red is a valid custom-property declaration. When substituted into padding: var(--spacing), red is invalid for padding, and the receiving declaration cannot produce the intended value.
:root {
--spacing: red;
}
.card {
padding: var(--spacing); /* invalid after substitution */
}
This delayed validation is one reason naming conventions, typed registration, and DevTools inspection matter.
Cyclic Dependencies
Custom properties can refer to other custom properties, but a dependency cycle makes the variables in that cycle invalid. Direct and indirect loops both fail.
.example {
--a: var(--b);
--b: var(--a);
color: var(--a, black);
}
The fallback allows color to use black. Keep dependency graphs shallow enough that cycles and missing inputs are easy to identify.
Using Variables With calc()
Custom properties provide named inputs to calculations. Store complete typed values when possible so their meaning remains explicit.
:root {
--header-height: 4rem;
--page-gutter: 1.5rem;
}
main {
min-height: calc(100dvh - var(--header-height));
width: calc(100% - (2 * var(--page-gutter)));
}
A unitless token can be useful for multiplication, but it must be converted into the type the receiving property accepts.
Using Variables With clamp(), min(), and max()
Design tokens can store an entire responsive function or supply individual limits and preferred values.
:root {
--text-title-min: 2rem;
--text-title-fluid: calc(1.25rem + 3vw);
--text-title-max: 4rem;
}
h1 {
font-size: clamp(
var(--text-title-min),
var(--text-title-fluid),
var(--text-title-max)
);
}
Separating inputs helps themes adjust one part of the scale without replacing the complete expression.
Variables in Shorthand Properties
A custom property can hold a complete shorthand value, such as a border or transition. This is concise but couples every shorthand component together.
:root {
--card-border: 1px solid #cbd5e1;
}
.card {
border: var(--card-border);
}
Use separate tokens when consumers need to override width, style, or color independently.
Semantic Design Tokens
Tokens named by purpose survive visual redesign better than tokens named only by current appearance. --color-danger remains meaningful if the design changes from red to another warning treatment, while --red-500 describes a raw palette value.
A mature system often has primitive tokens such as palette steps and semantic tokens such as surface, text, border, success, and danger. Components consume semantic tokens so themes can remap intent without rewriting component CSS.
A Practical Token Foundation
:root {
--color-surface: #ffffff;
--color-text: #172033;
--color-text-muted: #52627a;
--color-primary: #155e9a;
--color-danger: #b42318;
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 1rem;
--space-4: 1.5rem;
--radius-sm: 4px;
--radius-md: 8px;
--shadow-card: 0 0.5rem 1.5rem rgb(15 23 42 / 12%);
}
The values form a shared vocabulary. Governance should define who can add tokens, how names are reviewed, and how obsolete tokens are retired.
Theming With Custom Properties
Themes work well when components depend on semantic tokens and the theme overrides those tokens at a common ancestor.
:root {
--page-bg: #ffffff;
--page-text: #172033;
}
[data-theme="dark"] {
--page-bg: #101827;
--page-text: #f8fafc;
}
body {
background: var(--page-bg);
color: var(--page-text);
}
Switching one attribute updates every descendant that consumes the semantic values.
Respecting the User's Color Preference
A media query can establish a dark default when the user prefers a dark color scheme. An explicit user-selected theme should be designed to override that default predictably.
@media (prefers-color-scheme: dark) {
:root {
--page-bg: #101827;
--page-text: #f8fafc;
}
}
Theme tokens still need contrast review. Variables make color changes easy but do not guarantee accessible combinations.
Responsive Token Overrides
Media and container queries can override a custom property while consumers remain unchanged.
:root {
--page-padding: 1rem;
}
@media (min-width: 48rem) {
:root {
--page-padding: 2rem;
}
}
.page {
padding-inline: var(--page-padding);
}
This centralizes responsive decisions that several selectors share.
Custom Properties in Media Query Conditions
Ordinary element custom properties cannot generally be substituted directly into traditional media-query conditions because those conditions are evaluated outside the normal element cascade.
/* Do not rely on this ordinary custom-property pattern. */
@media (min-width: var(--breakpoint-lg)) {
/* ... */
}
Use explicit query values, build tooling, or newer purpose-built features when supported. Custom properties remain useful inside the rules selected by the query.
Component-Level APIs
A reusable component can expose a small set of custom properties as its styling API. Consumers configure intent without depending on internal selectors.
.card {
--card-padding: 1.5rem;
--card-radius: 0.5rem;
padding: var(--card-padding);
border-radius: var(--card-radius);
}
.dashboard .card {
--card-padding: 1rem;
}
Document supported tokens, accepted types, defaults, and whether changing them is a stable contract.
Button System Example
.button {
--button-bg: var(--color-primary);
--button-text: #ffffff;
--button-padding: 0.75rem 1.25rem;
background: var(--button-bg);
color: var(--button-text);
padding: var(--button-padding);
}
.button--danger {
--button-bg: var(--color-danger);
}
The base class owns structure and focus behavior. Variants modify only the supported variables, reducing duplication and selector conflicts.
Card System Example
.card {
--card-padding: 1.5rem;
--card-border-color: #cbd5e1;
padding: var(--card-padding);
border: 1px solid var(--card-border-color);
border-radius: var(--radius-md);
}
.card--compact {
--card-padding: 0.75rem;
}
Local variables make density variants explicit while global radius tokens preserve shared design language.
Custom Properties and currentColor
currentColor represents the element's computed color and works well as a variable default. Icons and borders can follow text color without a second hard-coded color.
.icon-link {
--icon-color: currentColor;
color: #155e9a;
}
.icon-link svg {
fill: var(--icon-color);
}
Runtime Updates With JavaScript
JavaScript can set and remove custom properties through the style API. Every dependent CSS declaration updates through normal style recalculation.
const root = document.documentElement;
root.style.setProperty('--color-primary', '#047857');
root.style.removeProperty('--color-primary');
Prefer classes or data attributes for discrete states and themes because they keep state rules in CSS. Direct property updates fit genuinely continuous or user-generated values.
Reading Values With JavaScript
getComputedStyle() reads the computed custom-property value for an element. Whitespace may be present, so trim when interpreting simple tokens.
const styles = getComputedStyle(document.documentElement);
const primary = styles
.getPropertyValue('--color-primary')
.trim();
Do not duplicate CSS tokens into JavaScript unless code genuinely needs the value. Often JavaScript should change semantic state and let CSS choose presentation.
Custom Properties Versus Sass Variables
| CSS custom property | Sass variable |
|---|---|
| Exists at browser runtime | Resolved during compilation |
| Participates in cascade and inheritance | Uses Sass lexical scope |
| Can change through selectors and JavaScript | Produces static output values |
| Can depend on element context | Cannot read browser layout context |
Projects can use both. Sass can generate repetitive structures while CSS properties carry runtime theme and component state.
Registering a Custom Property With @property
The @property rule can define syntax, inheritance behavior, and an initial value. Registration gives the browser a type contract instead of accepting an arbitrary token sequence.
@property --progress {
syntax: "<percentage>";
inherits: false;
initial-value: 0%;
}
.meter__fill {
width: var(--progress);
}
An invalid assignment does not become the registered value. Registration also enables type-aware interpolation for supported animations.
Controlling Inheritance With @property
Unregistered custom properties inherit by default. A registered property can set inherits: false, causing each element without a matching declaration to use the registered initial value rather than its parent's value.
This is useful for local state such as progress or rotation that should not leak into descendants. Theme colors generally benefit from inheritance and should retain it.
Animating Registered Custom Properties
Unregistered custom properties usually animate discretely because the browser does not know their type. A registered color, length, angle, or number can interpolate when used in an animatable context.
@property --angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
.spinner {
rotate: var(--angle);
transition: --angle 300ms ease;
}
Respect prefers-reduced-motion and avoid unnecessary animation even when registration makes it possible.
Shadow DOM and Component Boundaries
Inherited custom properties can cross into a shadow tree from its host, which makes them useful for theming web components. A component can consume documented public tokens while keeping internal selectors encapsulated.
Do not expose every internal measurement as a public variable. A small stable API protects component freedom and prevents application code from depending on implementation details.
Cascade Layers and Tokens
Cascade layers can organize token defaults, themes, components, and utilities. Custom properties still follow the winning declaration established by layer order and the rest of the cascade.
@layer tokens, themes, components;
@layer tokens {
:root { --color-primary: #2563eb; }
}
@layer themes {
[data-theme="forest"] { --color-primary: #047857; }
}
A deliberate layer strategy reduces specificity escalation when themes and components interact.
Naming Conventions
Names should communicate category, purpose, and optional variant. Examples include --color-text-muted, --space-md, --radius-control, and --duration-fast. Component tokens can use a prefix such as --button-bg.
Avoid names such as --x1 and appearance-only names for semantic decisions. Document abbreviations and use one casing convention because names are case-sensitive.
Token Architecture
A scalable system often separates primitive, semantic, and component tokens. Primitive tokens describe raw choices. Semantic tokens map those choices to roles. Component tokens map roles to a specific component API.
:root {
--blue-700: #155e9a;
--color-action: var(--blue-700);
}
.button {
--button-bg: var(--color-action);
background: var(--button-bg);
}
This adds indirection, so use it when multiple themes or products justify the architecture. Small sites may need fewer layers.
Accessibility Considerations
Variables can make accessibility improvements consistent. Shared focus colors, target sizes, typography scales, and motion durations can be reviewed centrally. They can also spread inaccessible decisions quickly if contrast or sizing is poor.
Test every theme for contrast, forced colors, zoom, larger text, and state visibility. Keep semantic meaning independent of color, and let users reduce motion or choose supported display preferences.
Performance Considerations
Normal use of custom properties is efficient and should be chosen for maintainability. Changing a variable on the root can affect many descendants and require broad style recalculation, while changing a locally scoped variable limits the affected subtree.
Do not optimize token usage without measurement. Rapid JavaScript updates across large trees or variables that influence layout can be more expensive than occasional theme changes. Profile real interactions when performance matters.
Debugging Custom Properties
DevTools computed-style panels reveal which custom-property declaration won, where it was inherited from, and what value reached the consumer. Check spelling and case, scope, cascade layers, specificity, fallback syntax, cycles, and receiving-property validity.
Temporarily replace a nested expression with a literal known-good value. Then restore variables one level at a time. This isolates whether the problem lies in selection, inheritance, substitution, type, or property grammar.
Testing Variables and Themes
Test default values, each supported theme, component overrides, nested themes, missing optional tokens, invalid public inputs, and JavaScript updates. Verify hover, focus, disabled, error, and high-contrast states in every theme.
Visual regression tests can detect broad token changes, while unit or browser tests can confirm that state selectors update expected custom properties. Include long content and narrow containers because spacing and typography tokens affect layout.
Preventing Theme Flash During Page Load
A theme stored in local browser data may be applied only after the main JavaScript bundle runs. The page can briefly render the default theme and then switch, producing a visible flash and avoidable layout or paint changes. Custom properties make the final theme easy to apply, but the initial theme decision still needs to happen early.
Where possible, let the server render the user's saved theme attribute on the root element. For a client-only site, a small early script can read the stored preference and set the attribute before the page becomes visible. Keep that script narrowly scoped, resilient when storage is unavailable, and consistent with the CSS default based on prefers-color-scheme.
<script>
const savedTheme = localStorage.getItem('theme');
if (savedTheme === 'light' || savedTheme === 'dark') {
document.documentElement.dataset.theme = savedTheme;
}
</script>
The user's explicit selection should normally take precedence over the operating-system preference. Theme controls also need an accessible name, an exposed state, keyboard operation, and a clear way to return to automatic system behavior.
Public and Private Component Variables
A component may use many internal custom properties but expose only a small supported set. Public tokens are part of the component contract and should have stable names, documented types, defaults, examples, and compatibility expectations. Private variables are implementation details that maintainers can rename or remove without affecting consumers.
.progress {
/* Public configuration */
--progress-value: 0%;
--progress-color: var(--color-primary);
/* Internal implementation */
--_progress-track: #e2e8f0;
}
.progress__fill {
width: var(--progress-value);
background: var(--progress-color);
}
A naming convention such as a documented component prefix and an internal underscore can signal intent, though CSS does not enforce privacy. Consumers must avoid overriding private values. Limit the public API to decisions that are useful and safe to customize; exposing every measurement permanently couples callers to the current internal design.
Token Versioning and Migration
Custom-property names become dependencies once many pages, packages, or partner applications consume them. Renaming --brand-blue to --color-action without a migration can silently remove styles. Treat widely published tokens like other interfaces: document changes, provide a transition period, and search the complete codebase before deletion.
A temporary compatibility alias can map an old token to a new one, but direction matters. The consuming component should use the new semantic token while allowing an old caller value as a fallback during migration.
.button {
--button-bg-resolved: var(
--button-bg,
var(--legacy-button-color, var(--color-action))
);
background: var(--button-bg-resolved);
}
Compatibility chains should have an expiration plan because permanent aliases increase complexity. Track token ownership, usage, replacement, and removal in the design-system release process. Visual regression tests are particularly important when changing a semantic token because one value can affect many components and states.
Security and Untrusted Values
JavaScript should not accept arbitrary user strings and place them directly into style properties merely because custom properties are flexible. Validate values against the expected domain, such as a bounded number, a supported theme identifier, or an approved color format. CSS cannot replace application input validation or content-security policy.
Prefer setting a known class or data attribute when users choose from a fixed option set. Use direct custom-property updates for controlled continuous values such as a slider or chart measurement, and clamp numeric input before exposing it to layout. This protects the interface from broken geometry and reduces the chance that presentation becomes an unexpected injection surface.
Common Mistake: Forgetting the Two Hyphens
primary-color: blue is not a custom property. The correct declaration is --primary-color: blue. Consumption still requires var(--primary-color).
Common Mistake: Case Mismatch
Defining --Primary and reading --primary references different names. Lowercase naming and linting reduce this risk.
Common Mistake: Everything Is Global
Putting every component detail in :root creates a large unowned namespace. Globalize broadly shared decisions and keep private component configuration local.
Common Mistake: Variables Without Consumers
Changing a token has no effect if components still hard-code the original literal. Search for duplicate values during migration, update consumers, and remove obsolete declarations after verification.
Common Mistake: Excessive Abstraction
A variable for every one-off value makes CSS harder to trace. Create a token when it communicates meaning, supports reuse, enables override, or represents a deliberate design decision. Leave incidental values direct when abstraction adds no benefit.
A Practical Adoption Workflow
Inventory repeated and theme-sensitive values, group them by purpose, define a naming convention, and introduce a small root token set. Refactor components to semantic tokens, then expose only the local variables consumers genuinely need.
Inventory repeated decisions
-> classify global and component ownership
-> name tokens by purpose
-> define defaults and supported types
-> migrate consumers incrementally
-> test themes, states, and accessibility
-> document and remove obsolete tokens
Review token growth regularly. A design system is easier to maintain when additions are intentional and deprecated names have a migration path.
During code review, trace each new token from declaration to consumer and ask whether its scope matches its ownership. Confirm that a local default remains usable without a global theme, that public overrides cannot create unreadable states, and that the name describes purpose rather than one current visual value. This review keeps variables from becoming an undocumented second programming language inside the stylesheet.
Interview Questions and Answers
What are CSS custom properties? They are developer-defined properties with names beginning with two hyphens, commonly used as runtime CSS variables.
How is a custom property used? Read it through var(--name) in a receiving property.
Do custom properties inherit? Unregistered custom properties normally inherit. Registered properties can control inheritance.
What does :root do? It selects the document root and is a common place for global tokens.
How is a fallback provided? Use the second argument, such as var(--color, black).
Can variables work with calc? Yes, provided substitution creates compatible typed values.
How are CSS and Sass variables different? CSS properties exist at runtime and participate in cascade and inheritance; Sass variables are resolved during compilation.
What does @property add? It can declare syntax, inheritance, and an initial value for a custom property.
Practical Review Checklist
- Prefix every custom-property name with two hyphens.
- Use var() when consuming a custom property.
- Keep names lowercase, semantic, and consistently structured.
- Place only broadly shared tokens at the root.
- Scope component configuration to the component subtree.
- Document expected token types and fallback behavior.
- Account for cascade, inheritance, layers, and source order.
- Use @property when type, initial value, or inheritance control helps.
- Test every theme for contrast and visible interaction states.
- Inspect invalid substitutions and cyclic dependencies.
- Prefer classes for discrete states and JavaScript for true runtime inputs.
- Review, deprecate, and remove unused tokens regularly.
Key Takeaway
CSS custom properties turn repeated values into contextual, cascade-aware design inputs. Define them with a two-hyphen name, consume them with var(), and use scope and inheritance to configure themes and components without duplicating complete rule sets.
The strongest systems combine a small semantic token foundation with focused component APIs. Use fallbacks intentionally, register types when useful, validate accessible outcomes, and avoid abstraction that does not communicate purpose. Custom properties are valuable not because every value should become a variable, but because important design decisions can remain reusable, overridable, and live in the browser.