034: CSS Custom Properties, Functions, and Design Tokens
Learning outcomes
By the end, you can define and scope custom properties; use var() fallbacks; understand inheritance of custom properties; use calc(), min(), max(), and clamp(); use common CSS functions safely; create semantic design tokens; and know when @property is useful.
Prerequisites and retrieval
Retrieve the cascade from 020, color roles from 021, sizing from 024, and responsive typography from 022. Custom properties participate in the cascade, so variable problems are often cascade problems.
Mental model: store decisions, not arbitrary duplication
A custom property begins with --:
:root {
--brand: #2563eb;
}
.button {
background: var(--brand);
}
Unlike preprocessor variables, CSS custom properties exist in the browser's CSS value system and can vary by selector, media query, state, and inheritance.
Scope and inheritance
:root {
--surface: white;
--text: #0f172a;
}
.card {
--surface: #f8fafc;
color: var(--text);
background: var(--surface);
}
Descendants of .card inherit the local --surface unless they override it.
This makes component theming possible:
.pricing-card {
--accent: #2563eb;
}
.pricing-card[data-plan="pro"] {
--accent: #7c3aed;
}
.pricing-card__button {
background: var(--accent);
}
The button does not need to know which plan it is inside.
var() fallback
.notice {
border-color: var(--notice-color, #64748b);
}
The second argument is used when the referenced custom property is missing/invalid at computed-value time.
Nested fallbacks are possible:
color: var(--component-text, var(--text, black));
Do not build ten-level fallback chains. That makes ownership harder to understand.
Semantic tokens versus literal tokens
Literal:
:root {
--blue-600: #2563eb;
--slate-900: #0f172a;
}
Semantic:
:root {
--color-action: var(--blue-600);
--color-text: var(--slate-900);
}
Components should usually consume semantic roles:
.button {
background: var(--color-action);
}
This makes themes and design changes easier.
Worked example: light/dark token system
:root {
color-scheme: light;
--page: #ffffff;
--surface: #f8fafc;
--text: #0f172a;
--muted: #475569;
--border: #cbd5e1;
--action: #2563eb;
}
@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
--page: #020617;
--surface: #0f172a;
--text: #f8fafc;
--muted: #cbd5e1;
--border: #334155;
--action: #60a5fa;
}
}
body {
color: var(--text);
background: var(--page);
}
The selectors for components do not need to be repeated in the dark-mode query.
Math functions
calc()
.hero {
min-block-size: calc(100dvh - var(--header-height));
}
min()
.shell {
inline-size: min(100% - 2rem, 72rem);
}
max()
.safe-panel {
padding-inline: max(1rem, env(safe-area-inset-left));
}
clamp()
h1 {
font-size: clamp(2rem, 1rem + 4vw, 5rem);
}
Read clamp() as: never below the first value, prefer the middle expression, never above the final value.
Other high-value CSS functions
url()
.hero {
background-image: url("/images/hero.jpg");
}
gradients
.banner {
background: linear-gradient(135deg, #2563eb, #7c3aed);
}
rgb() / hsl()
.overlay {
background: rgb(15 23 42 / 0.7);
}
minmax() in Grid
.cards {
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
}
repeat()
.grid {
grid-template-columns: repeat(12, 1fr);
}
transform functions
.icon {
transform: translateX(0.25rem) rotate(3deg);
}
Functions are not one category with identical behavior. Each property defines which function types it accepts.
Worked example: spacing and radius tokens
:root {
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-6: 1.5rem;
--space-8: 2rem;
--radius-sm: 0.375rem;
--radius-md: 0.75rem;
--radius-pill: 999px;
}
.card {
padding: var(--space-6);
border-radius: var(--radius-md);
}
.card__actions {
display: flex;
gap: var(--space-3);
}
A token scale should reduce arbitrary decisions, not forbid all one-off values.
Component-local tokens
.alert {
--alert-bg: #f1f5f9;
--alert-border: #64748b;
--alert-text: #0f172a;
color: var(--alert-text);
background: var(--alert-bg);
border-inline-start: 4px solid var(--alert-border);
}
.alert[data-tone="danger"] {
--alert-bg: #fef2f2;
--alert-border: #dc2626;
--alert-text: #7f1d1d;
}
The structural declarations remain once; only tokens change per variant.
Typed custom properties with @property
@property --progress {
syntax: "<number>";
inherits: false;
initial-value: 0;
}
Typed custom properties can define syntax, inheritance, and initial values and can enable smoother animation behavior for some custom values.
Example:
.progress-ring {
--progress: 0.7;
}
Do not introduce @property merely because it is modern. Use it when type/inheritance/animation behavior is valuable.
Deep dive: computed-value time and invalid custom properties
Custom properties store token streams until another property consumes them. This means a declaration can look syntactically valid but become invalid when substituted.
.card {
--gap: tomato;
gap: var(--gap);
}
--gap itself is valid as a custom property, but gap: tomato is not. The failure appears at computed-value time.
Fallbacks only apply when the custom property is missing or invalid as a custom property, not when the substituted value is wrong for the consuming property.
.card {
--space: tomato;
padding: var(--space, 1rem); /* fallback does not rescue "tomato" */
}
Typed registration with @property can catch some of these mistakes earlier:
@property --card-radius {
syntax: "<length>";
inherits: true;
initial-value: 0.75rem;
}
Now a value such as --card-radius: tomato does not satisfy the registered grammar.
Deep dive: token layers
Separate raw values from semantic decisions and component aliases.
:root {
--blue-600: #2563eb;
--slate-950: #020617;
--slate-50: #f8fafc;
--color-action: var(--blue-600);
--color-text: var(--slate-950);
--color-surface: var(--slate-50);
--space-2: 0.5rem;
--space-4: 1rem;
--space-6: 1.5rem;
}
.button {
--button-bg: var(--color-action);
--button-padding-inline: var(--space-4);
background: var(--button-bg);
padding-inline: var(--button-padding-inline);
}
A theme can change semantic tokens without rewriting every component.
[data-theme="dark"] {
--color-text: #f8fafc;
--color-surface: #0f172a;
}
Deep dive: functions and unit algebra
calc() can combine compatible dimensions:
.sidebar {
inline-size: calc(30% - 1rem);
}
It cannot make incompatible dimensions meaningful.
/* invalid idea: time and length do not combine */
.example {
inline-size: calc(2s + 10px);
}
Use min(), max(), and clamp() to express constraints rather than a pile of breakpoints:
.page {
padding-inline: clamp(1rem, 4vw, 4rem);
}
.hero-title {
font-size: clamp(2rem, 1.4rem + 3vw, 4.5rem);
}
.panel {
inline-size: min(100%, 70rem);
}
Environment values
Some browser/device values are exposed through env():
.app-shell {
padding-bottom: max(
1rem,
env(safe-area-inset-bottom)
);
}
Use environment values only where the platform meaning actually applies.
Debugging custom properties
In DevTools:
- inspect where the custom property is defined;
- trace inheritance;
- check whether a nearer declaration overrides it;
- inspect the final consuming property;
- temporarily replace
var(...)with a literal to isolate substitution; - check for cycles such as
--a: var(--b); --b: var(--a);.
Custom properties are part of the cascade, not a separate variable system.
Common mistakes
- Naming every literal value as a global token.
- Assuming custom properties are compile-time constants.
- Forgetting they inherit.
- Using a missing variable without a fallback where absence is expected.
- Storing whole chunks of unrelated declarations in custom properties.
- Creating a design system before the design has repeated patterns.
- Using viewport math that ignores zoom/content.
Practice set
- Convert a card palette to semantic tokens.
- Build two pricing-card variants using only local token overrides.
- Create fluid section spacing with
clamp(). - Build a shell with
min(). - Introduce a deliberate missing variable and inspect computed styles.
- Add a dark theme by changing tokens instead of repeating component rules.
Recap
Custom properties are cascading values. Functions let CSS calculate and generate values from context. Together they form a practical foundation for theming, responsive systems, and maintainable component APIs.
Official references
-
MDN: Using custom properties — https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_cascading_variables/Using_CSS_custom_properties
-
MDN:
@property— https://developer.mozilla.org/en-US/docs/Web/CSS/@property -
MDN: Using CSS custom properties — https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_cascading_variables/Using_CSS_custom_properties
-
MDN: CSS values and functions — https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Values_and_Units/CSS_Value_Functions
-
MDN:
calc()— https://developer.mozilla.org/en-US/docs/Web/CSS/calc -
MDN:
clamp()— https://developer.mozilla.org/en-US/docs/Web/CSS/clamp -
MDN:
@property— https://developer.mozilla.org/en-US/docs/Web/CSS/@property
