FullStack Course LogoFullStack Course
Module: CSS
CSS·034·7 MIN READ

034: CSS Custom Properties, Functions, and Design Tokens

TOPICS COVERED: CSS Custom Properties, Functions, and Design Tokens

Learning outcomes

By the end of this lesson, you should be able to define and scope custom properties, provide var() fallbacks, and explain how custom properties inherit. You will also use calc(), min(), max(), and clamp(), work safely with common CSS functions, create semantic design tokens, and decide when @property adds useful value.

Prerequisites and retrieval

Bring back the cascade from 020, color roles from 021, sizing from 024, and responsive typography from 022. Custom properties participate in the cascade, which is why a variable that seems broken is often the result of a cascade or inheritance problem.

Mental model: store decisions, not arbitrary duplication

If a value represents a decision you may want to change, a custom property gives that decision a name. Its name starts with --:

css
:root {
  --brand: #2563eb;
}

.button {
  background: var(--brand);
}

Unlike preprocessor variables, CSS custom properties live in the browser's CSS value system. They can therefore vary by selector, media query, state, and inheritance. That runtime behavior is the key difference: the browser resolves them as part of styling rather than replacing them all during a separate build step.

Scope and inheritance

The place where you define a custom property determines which elements can inherit it. For example:

css
:root {
  --surface: white;
  --text: #0f172a;
}

.card {
  --surface: #f8fafc;
  color: var(--text);
  background: var(--surface);
}

Descendants of .card inherit its local --surface value unless one of them overrides that value. The global value still supplies --text, because .card did not replace it.

That behavior is useful for component theming:

css
.pricing-card {
  --accent: #2563eb;
}

.pricing-card[data-plan="pro"] {
  --accent: #7c3aed;
}

.pricing-card__button {
  background: var(--accent);
}

The button only consumes the --accent role. It does not need to know which pricing plan contains it; the ancestor establishes the appropriate value.

var() fallback

A fallback is useful when a component can operate without a value supplied by its surrounding context:

css
.notice {
  border-color: var(--notice-color, #64748b);
}

The second argument is used when the referenced custom property is missing or invalid at computed-value time. It is not a general-purpose repair mechanism for every invalid declaration.

Fallbacks can be nested when ownership is intentionally layered:

css
color: var(--component-text, var(--text, black));

Use this sparingly. A ten-level fallback chain may technically work, but it makes it difficult to determine which layer owns the final value.

Semantic tokens versus literal tokens

Literal tokens describe the raw value itself:

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

Semantic tokens describe the role that value plays:

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

Components should normally consume semantic roles rather than choosing palette values directly:

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

Now a theme or a broader design change can replace the meaning behind --color-action without requiring every button rule to change.

Worked example: light/dark token system

Put the shared roles in one place, then replace their values for dark mode:

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 component selectors do not need to be repeated inside the dark-mode query. They already consume the same roles, so changing the values at :root flows through the existing component rules.

Math functions

calc()

Use calc() when a value needs arithmetic involving CSS dimensions or other compatible values:

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

Here the hero reserves space for the header while still responding to the dynamic viewport height.

min()

min() expresses an upper constraint directly:

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

The shell can use the available width minus its surrounding space, but it will not grow beyond 72rem.

max()

max() is useful for a lower bound, including a safe-area inset:

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

The padding is at least 1rem, while a larger environment-provided inset can protect content on a device with an unsafe edge.

clamp()

clamp() combines a minimum, a preferred value, and a maximum:

css
h1 {
  font-size: clamp(2rem, 1rem + 4vw, 5rem);
}

Read it as: never go below the first value, prefer the middle expression as the context changes, and never exceed the final value. This often gives you fluid behavior without a series of breakpoint overrides.

Other high-value CSS functions

url()

url() supplies a resource such as an image to a property that accepts one:

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

gradients

Gradients generate an image from color stops, so they can be used anywhere the relevant property accepts an image:

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

rgb() / hsl()

Color functions let you express channels and, in this syntax, alpha directly:

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

minmax() in Grid

minmax() gives a grid track a lower and upper bound. Combined with auto-fit, it can produce responsive columns without manually listing breakpoints:

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

repeat()

repeat() avoids writing the same track definition over and over:

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

transform functions

Transform functions can be composed in one declaration:

css
.icon {
  transform: translateX(0.25rem) rotate(3deg);
}

These functions do not all behave alike or belong to one interchangeable category. Each CSS property defines which function types and value grammar it accepts.

Worked example: spacing and radius tokens

A small scale can give repeated layout decisions consistent names:

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);
}

The purpose of a token scale is to reduce arbitrary decisions and make repetition visible. It should not forbid every one-off value when a genuinely unique value is the clearest choice.

Component-local tokens

Tokens can be private to a component when their meaning does not need to be part of the global design language:

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 appear once. Each variant changes only the tokens, which keeps the relationship between the component and its tone easy to inspect.

Typed custom properties with @property

@property lets you register a custom property with a syntax, inheritance behavior, and initial value:

css
@property --progress {
  syntax: "<number>";
  inherits: false;
  initial-value: 0;
}

Typed custom properties can enable smoother animation behavior for some custom values and provide a defined grammar, in addition to controlling inheritance and the initial value.

For example:

css
.progress-ring {
  --progress: 0.7;
}

Do not add @property just because it is newer CSS. It earns its place when type checking, explicit inheritance, or animation behavior solves a real problem.

Deep dive: computed-value time and invalid custom properties

Custom properties preserve token streams until another property consumes them. As a result, a declaration can be valid as a custom-property declaration but become invalid after substitution into the consuming property.

css
.card {
  --gap: tomato;
  gap: var(--gap);
}

--gap is valid as a custom property because custom properties accept the token stream. gap: tomato is not valid, however, so the failure appears at computed-value time when var(--gap) is substituted.

Fallbacks only help when the custom property is missing or invalid as a custom property. They do not rescue a value that exists but is wrong for the consuming property's grammar:

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

Typed registration with @property can catch some such mistakes earlier:

css
@property --card-radius {
  syntax: "<length>";
  inherits: true;
  initial-value: 0.75rem;
}

With that registration, a value such as --card-radius: tomato does not satisfy the registered grammar.

Deep dive: token layers

Keep raw values, semantic decisions, and component aliases separate. Each layer answers a different question: what value exists, what role it serves, and how a particular component consumes that role.

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 replace semantic tokens without rewriting every component rule:

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

The component remains connected to a role rather than to a particular palette value, so theme changes stay localized to the token layer.

Deep dive: functions and unit algebra

calc() can combine compatible dimensions. This example subtracts a length from a percentage:

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

It cannot make incompatible dimensions meaningful. A duration and a length do not become compatible simply because both are written inside calc():

css
/* invalid idea: time and length do not combine */
.example {
  inline-size: calc(2s + 10px);
}

Use min(), max(), and clamp() when the design is really expressing constraints. They can often replace a pile of breakpoint-specific values:

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 and device values are exposed through env(). Use them when the platform provides a value with the meaning your layout needs:

css
.app-shell {
  padding-bottom: max(
    1rem,
    env(safe-area-inset-bottom)
  );
}

Environment values are not a universal substitute for ordinary spacing. Apply them only where the corresponding platform condition actually matters.

Debugging custom properties

When a custom property produces an unexpected result, use DevTools to follow the value from its definition to its consumer:

  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);.

The useful distinction is that custom properties are part of the cascade, not a separate variable system. Inspect them with the same attention to scope, specificity, and inheritance that you would give any other cascaded declaration.

Common mistakes

  • Naming every literal value as a global token.
  • Assuming custom properties are compile-time constants.
  • Forgetting that custom properties inherit.
  • Using a missing variable without a fallback when its 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 and 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 values that participate in the cascade and can change with scope and context. CSS functions let you calculate or generate values from that context. Used together, they provide a practical foundation for theming, responsive systems, and maintainable component APIs.

Official references

Reader page: /css/lesson/034/css-custom-properties-functions-and-design-tokens