Module: CSS
CSS·034·4 MIN READ

034: CSS Custom Properties, Functions, and Design Tokens

TOPICS COVERED: 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 --:

css
: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

css
: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:

css
.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

css
.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:

css
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:

css
:root {
  --blue-600: #2563eb;
  --slate-900: #0f172a;
}

Semantic:

css
:root {
  --color-action: var(--blue-600);
  --color-text: var(--slate-900);
}

Components should usually consume semantic roles:

css
.button {
  background: var(--color-action);
}

This makes themes and design changes easier.

Worked example: light/dark token system

css
: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()

css
.hero {
  min-block-size: calc(100dvh - var(--header-height));
}

min()

css
.shell {
  inline-size: min(100% - 2rem, 72rem);
}

max()

css
.safe-panel {
  padding-inline: max(1rem, env(safe-area-inset-left));
}

clamp()

css
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()

css
.hero {
  background-image: url("/images/hero.jpg");
}

gradients

css
.banner {
  background: linear-gradient(135deg, #2563eb, #7c3aed);
}

rgb() / hsl()

css
.overlay {
  background: rgb(15 23 42 / 0.7);
}

minmax() in Grid

css
.cards {
  grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
}

repeat()

css
.grid {
  grid-template-columns: repeat(12, 1fr);
}

transform functions

css
.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

css
: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

css
.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

css
@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:

css
.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.

css
.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.

css
.card {
  --space: tomato;
  padding: var(--space, 1rem); /* fallback does not rescue "tomato" */
}

Typed registration with @property can catch some of these mistakes earlier:

css
@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.

css
: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.

css
[data-theme="dark"] {
  --color-text: #f8fafc;
  --color-surface: #0f172a;
}

Deep dive: functions and unit algebra

calc() can combine compatible dimensions:

css
.sidebar {
  inline-size: calc(30% - 1rem);
}

It cannot make incompatible dimensions meaningful.

css
/* 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:

css
.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():

css
.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:

  1. inspect where the custom property is defined;
  2. trace inheritance;
  3. check whether a nearer declaration overrides it;
  4. inspect the final consuming property;
  5. temporarily replace var(...) with a literal to isolate substitution;
  6. 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

  1. Convert a card palette to semantic tokens.
  2. Build two pricing-card variants using only local token overrides.
  3. Create fluid section spacing with clamp().
  4. Build a shell with min().
  5. Introduce a deliberate missing variable and inspect computed styles.
  6. 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