Custom properties are not just variables. They participate in the cascade, inherit by default, and can be changed at runtime without recompiling CSS. That makes them useful for component contracts.
A good custom property answers: what decision should be configurable from outside this component?
The danger is turning every declaration into a variable. That does not create a design system. It creates an API surface nobody can reason about.
Tokens and component variables are different
Global tokens describe system values.
:root {
--color-ink: #141412;
--color-danger: #d85d3f;
--space-4: 1rem;
}
Component variables describe slots or decisions inside a component.
.alert {
--alert-accent: var(--color-danger);
--alert-padding: var(--space-4);
border-inline-start: 0.35rem solid var(--alert-accent);
padding: var(--alert-padding);
}
This gives you two layers of meaning. The brand palette can change without rewriting the component. The component can expose only the parts callers should adjust.
Keep the contract small
.callout {
--callout-accent: #0f8b8d;
--callout-surface: #e8f3f3;
border-inline-start: 0.35rem solid var(--callout-accent);
background: var(--callout-surface);
padding: 1rem;
}
.callout[data-tone="warning"] {
--callout-accent: #d85d3f;
--callout-surface: #f8e8e2;
}
The component exposes tone decisions, not every declaration. A caller can change the accent and surface without rewriting the component’s spacing, border side, or layout.
If you expose --callout-border-width, --callout-padding-block, --callout-padding-inline, and every other internal detail, the component becomes harder to maintain. Expose the decisions that are meant to vary.
Inheritance is useful until it leaks
Custom properties inherit by default. That makes them powerful for themes.
.theme-dark {
--surface: #141412;
--text: #fbfbfa;
}
.panel {
background: var(--surface);
color: var(--text);
}
The panel does not need to know which theme it is inside. It consumes inherited values.
The risk is accidental inheritance. A generic variable such as --accent can leak into a nested component and change it unexpectedly. Scope component variables with names such as --tabs-accent or --callout-accent when the value belongs to one component.
Fallbacks are for resilience, not silence
.meter {
inline-size: var(--meter-value, 0%);
}
Fallbacks are useful when a missing value has a safe default. They are harmful when they hide a broken integration. If a component cannot render correctly without a property, make that requirement visible in the markup or component API instead of masking it.
For example, a progress meter can safely default to zero. A brand color for a required status state may not have a safe fallback if the missing value makes the UI ambiguous.
Runtime behavior is the point
Because custom properties resolve in the browser, they pair well with media queries, container queries, user preferences, and small interactive tools.
@media (prefers-reduced-motion: reduce) {
:root {
--motion-duration: 1ms;
}
}
.drawer {
transition-duration: var(--motion-duration, 180ms);
}
That is more maintainable than trying to remember every selector that has a transition. The decision lives where the browser can apply it.
Custom properties also pair well with cascade layers. Tokens can live in a low layer, components can consume them, and utilities or variants can adjust scoped values later.
The production approach
Use custom properties when a component has a small set of meaningful styling decisions, theme values should inherit through a subtree, or state needs to coordinate several declarations.
Avoid them when a normal class communicates the variant more clearly, the variable exposes an internal detail that should not be configurable, or the fallback would hide a broken integration.
When a custom property seems wrong, inspect the computed value in DevTools. Check where it was defined, whether it was inherited, whether a later layer overrode it, and whether the final value is valid for the property that consumes it.
Name the decision, not the value
Good custom property names describe the role they play. --card-surface is more useful than --white. --tabs-active-border is more useful than --blue-line. Role-based names survive design changes because the component still needs the same decision even when the value changes.
Avoid exposing variables only because two declarations happen to share a value today. If a card gap and a button padding both use 1rem, that does not mean they should share a property. They may change for different reasons later. Tie variables to decisions that should move together: a theme surface, a component accent, a local rhythm, or a motion duration.
For public component APIs, document the few properties callers are expected to set. For internal implementation details, keep variables local and scoped. A custom property is easy to override from the outside, so every exposed name becomes part of the maintenance surface whether or not you intended it.
Related guides: Cascade layers, Fluid spacing without breakpoint sprawl, and Animation performance.