CSS Private and public props
CSS Private and public props

Modern Custom Properties with Defaults

Back in 2021, Lea Verou shared a brilliant pattern for handling default values in CSS variables. Now, in 2026, while that pattern remains useful, we also have the powerful @property rule as a standard feature. Let’s look at both the “Classic” and “Modern” ways to handle component defaults.

The “Classic” Way: The --_property Pattern

This technique solves the specificity wars by using a “private” internal variable.

The Problem

If we define a component’s style using a variable with a fallback in multiple places:

.button {
  background: var(--bg, black);
  border-color: var(--bg, black);
}

We are repeating var(--bg, black). If we want to change the default to blue, we have to edit it everywhere.

The Solution: Pseudo-Private Variables

We create a new variable, prefixed with _ (underscore), which acts as our internal source of truth.

.button {
  /* Internal variable = Public variable OR Default */
  --_bg: var(--bg, black);

  /* Use the internal variable everywhere */
  background: var(--_bg);
  border: 1px solid var(--_bg);
}

To customize it, the user just sets the public variable:

.button.red {
  --bg: red;
}

This acts like a Constructor for your CSS component. It’s lightweight, requires no global registration, and works perfectly for local scope.

The “Modern” Way: @property

With widespread support for @property, we can now define variables that really have defaults, types, and are even animatable.

Instead of needing a “private” intermediate variable, we register the public property directly.

@property --button-bg {
  syntax: "<color>";
  initial-value: black;
  inherits: true;
}

.button {
  /* No need for var(--button-bg, black) - the fallback is native! */
  background: var(--button-bg);
  border: 1px solid var(--button-bg);

  /* Plus: We can now transition this variable! */
  transition: --button-bg 0.3s;
}

Why use @property?

  1. Type Safety: The browser knows --button-bg is a <color>. If someone sets --button-bg: 20px, it is invalid and falls back to black (the initial value), rather than breaking the UI.
  2. Animation: You can transition from black to red smoothly because the browser knows how to interpolate colors. Standard variables snap from one value to another.
  3. Cleaner Code: No need for the --_ trick if you are okay with global registration.

Which one to choose?

My Recommendation: The Professional Setup

To take your CSS architecture to the next level, I recommend combining these patterns with a solid foundational setup:

  1. Global :root Variables: Define your design tokens (colors, spacing units, scale) in a base file scoped to :root.

  2. Organize with @layer: I highly recommend using the @layer rule to manage your CSS cascade. Since it is now Baseline Widely available, it provides a robust way to handle context and overrides, especially in platforms or frameworks that inject styles at multiple levels. You can read more in my post: How I Use @layer in CSS.

  3. Consistent Spacing & Sizing: Use a base --spacing unit and compute your paddings and margins from it (e.g., padding: calc(var(--spacing) * 4)). This ensures visual harmony across the entire project.

  4. Fluid Typography with clamp(): Don’t use static font sizes. Use clamp() to create fluid typography that scales beautifully between mobile and desktop without needing a dozen media queries.

/* 1. Global Setup with @layer and :root tokens */
@layer tokens, base, components;

@layer tokens {
  :root {
    --spacing: 0.25rem;
    /* 2. Fluid Typography with clamp() */
    --font-size-base: clamp(1rem, 1.2vw, 1.125rem);
    --color-primary: #3b82f6;
    --color-text: #1f2937;
  }
}

@layer components {
  .card {
    /* 3. Pseudo-private variables for component logic */
    --_bg: var(--card-bg, #ffffff);
    /* 4. Consistent spacing using calc() */
    --_padding: calc(var(--spacing) * 6);

    background: var(--_bg);
    padding: var(--_padding);
    font-size: var(--font-size-base);
    color: var(--color-text);
    border: 1px solid var(--color-primary);
    border-radius: calc(var(--spacing) * 2);

    /* Enable transition for the custom property */
    transition: --card-bg 0.3s ease;
  }
}

/* 5. Registered @property for smooth transitions */
@property --card-bg {
  syntax: "<color>";
  initial-value: #ffffff;
  inherits: false;
}

.card:hover {
  --card-bg: #f3f4f6;
}

By combining Lea’s --_ pattern for components with a global :root system, you get the best of both worlds: global consistency and local flexibility. In fact, I used this exact architecture to build this very website!

Updated for 2026. Based on Custom properties with defaults: 3+1 strategies

Link copied!

Comments for LVCSTP