CSS Custom Properties
Introduction
As stylesheets grow, repeated raw values become hard to maintain: colors drift, spacing becomes inconsistent, and theme changes become expensive. CSS custom properties (often called CSS variables) solve this by turning repeated values into reusable tokens. This chapter explains practical variable patterns for scalable UI systems.
Prerequisites
- Basic CSS syntax and cascade understanding
- Familiarity with color, spacing, and typography styling
- Basic component styling experience
What CSS Custom Properties Are
Custom properties are author-defined CSS values:
:root {
--primary: #2563eb;
}Use with var():
.button {
background: var(--primary);
}Code explanation:
--primarydefines a reusable tokenvar(--primary)reads current resolved value- changing token in one place updates all usages
Why Use Custom Properties
Main benefits:
- consistency across components
- easier theme updates
- less duplication
- clearer design intent (semantic naming)
- better runtime customization options
They are one of the highest-leverage tools in modern CSS architecture.
Global Tokens in :root
Common pattern:
Code explanation:
:rootprovides app-wide defaults- semantic names (text/primary/surface) are clearer than raw color labels
- spacing/radius scales reduce random values in components
Using Tokens in Components
.card {
background: var(--color-surface);
color: var(--color-text);
padding: var(--space-2);
border-radius: var(--radius-md);
}This keeps components aligned with shared design language.
Scope and Inheritance
Custom properties follow normal CSS cascade and inheritance rules.
.section {
--color-text: #1e293b;
}
.section p {
color: var(--color-text);
}Code explanation:
- variable defined on
.sectionis available to descendants - local scope overrides
:rootvalue for that subtree
This enables component-level customization without rewriting every declaration.
Local Component Variables
Use local aliases for clarity:
.alert {
--alert-bg: #fef3c7;
--alert-text: #92400e;
background: var(--alert-bg);
color: var(--alert-text);
}This pattern keeps component intent explicit and easier to refactor.
Fallback Values in var()
You can provide fallback when variable is missing:
.tag {
background: var(--tag-bg, #e2e8f0);
}Code explanation:
- if
--tag-bgexists, use it - otherwise use fallback
#e2e8f0
Useful for optional theming and safer component defaults.
Variable Chaining
Variables can reference variables:
:root {
--brand-primary: #2563eb;
--button-bg: var(--brand-primary);
}This supports token layering (brand -> component -> state).
Keep chains understandable to avoid debugging confusion.
State and Variant Theming
You can override tokens per state/variant:
This avoids duplicating full style blocks for each variant.
Dark Mode with Variables
Custom properties make dark mode much cleaner:
Code explanation:
- only token values change in dark mode
- component styles remain mostly unchanged
This is a major maintainability win for theme support.
Runtime Updates with JavaScript (Concept)
Variables can be changed dynamically:
document.documentElement.style.setProperty("--color-primary", "#7c3aed");Useful for:
- theme switchers
- user personalization
- design playgrounds
Even without JS, variables are still valuable for static maintainability.
Naming Strategy
Prefer semantic names:
- good:
--color-text,--space-2,--radius-md - weaker:
--blue-500,--margin-big
Semantic names survive rebranding and redesign better.
Common Token Groups
Typical groups:
- color tokens
- spacing scale
- typography scale
- radius values
- elevation/shadow levels
- z-index layers
- motion durations/easings
Start small and expand as your UI system grows.
Tip
Keep Token Set Intentional
Too many near-duplicate tokens create confusion. Define only values that represent real design decisions.
Practical Pattern: Tokenized Card and Button
This demonstrates cross-component consistency with minimal hardcoded values.
Common Mistakes
| Mistake | Why it hurts | Better approach |
|---|---|---|
| Creating tokens for every single value | Token bloat and confusion | Tokenize only reusable design decisions |
| Using non-semantic token names everywhere | Hard redesign/refactor | Use semantic purpose-based naming |
| Deep variable chains with unclear ownership | Debug complexity | Keep token hierarchy simple and documented |
| No fallback in optional contexts | Broken values in reused components | Use var(--token, fallback) where helpful |
| Mixing raw values and tokens randomly | Inconsistent UI language | Prefer tokens for core design system values |
Debugging Tips
When variable-driven styles fail:
- inspect computed styles in DevTools
- verify token exists in current scope
- check override order/cascade
- test fallback behavior with
var(--token, fallback) - confirm no typo in token names (
--token-namemust match exactly)
DevTools variable tracing is very useful for scope debugging.
Mini Exercise
- Define root tokens for text, primary color, spacing, and radius
- Refactor one card and one button to use only tokens
- Add one local component token override (
--card-bg) - Add dark mode token overrides with media query
- Add one fallback usage with
var(--missing-token, value)
FAQ
Are CSS custom properties the same as Sass variables?
No. Sass variables are compile-time. CSS custom properties are runtime values that participate in cascade and can be changed dynamically.
Should I put all tokens in :root?
Global system tokens usually go in :root, but component-local tokens are often better scoped near the component.
Can custom properties be used in media queries directly?
Not reliably in all plain-CSS media query contexts. Many teams document breakpoint constants separately or use tooling/preprocessors.
Why does my variable work in one component but not another?
Likely scope/cascade issue. Check where the variable is defined and whether that scope includes the target element.
When should I avoid creating a token?
If a value is truly one-off and not meaningful as a design decision, a local literal may be clearer than adding token complexity.