Scoping Custom Properties to Components

Inheritance is what makes custom properties powerful: set --accent on :root and every component can read it. It is also what makes them leak. Set --card-bg on one card to give it a special colour, nest another card inside it, and the inner card turns the same colour — it inherited the value, because it reads the same name. For motion the same leak produces stranger bugs: a slow --duration meant for a drawer slows every button inside the drawer. This page covers the patterns that keep component properties where they belong: a public/private naming convention, resetting at the component root, and registered properties that do not inherit at all. It belongs to CSS Custom Properties Architecture in the CSS-Only Micro-Interactions & Animations guide.

How the leak happens

A custom property set on an element is inherited by all of its descendants unless one of them sets the property itself. Components usually read their customisation properties directly, so a nested instance of the same component — or a different component that happens to use the same name — picks up the ancestor's value.

Inheritance carries the value inwards A large outer card labelled --card-bg amber. Inside it, a smaller inner card with no custom property set of its own. An arrow shows the amber value flowing from the outer card to the inner card, so both are amber even though only the outer one was customised. The inner card never asked for amber .card --card-bg: amber .card (nested) sets nothing, inherits amber Any descendant reading --card-bg sees the nearest ancestor's value.

The complete implementation

The demo shows two nested pairs of cards. In the first pair the component reads its public property directly and the colour leaks. In the second, the component uses the public/private pattern and the nested card keeps its default.

Live demoA leaky component next to a scoped one
Both outer cards are customised with an amber background. The leaky inner card inherits it; the scoped inner card resets its input and keeps its default.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Scoped custom properties</title>
<style>
  body { font: 15px/1.5 system-ui, sans-serif; margin: 2rem; }

  /* Leaky: the component reads its public property directly. */
  .card-leaky {
    padding: 1rem;
    border-radius: 10px;
    background: var(--card-bg, #f1f5f9);
  }

  /* Scoped: public API in, private value out. */
  .card {
    /* Copy the public input into a private value, with a default. */
    --_card-bg: var(--card-bg, #f1f5f9);
    --_card-duration: var(--card-duration, var(--motion-base, 200ms));
    padding: 1rem;
    border-radius: 10px;
    background: var(--_card-bg);
    transition: background-color var(--_card-duration) ease-out;
  }

  /* Consumers customise through a modifier that sets the local input. */
  .card--highlight { --card-bg: #fde68a; }

  /* Nested cards start clean. :where() gives zero specificity,
     so a modifier on the nested card still wins. */
  :where(.card .card) { --card-bg: initial; }
</style>
</head>
<body>
  <div class="card-leaky" style="--card-bg: #fde68a">
    Outer (leaky)
    <div class="card-leaky">Inner inherits amber</div>
  </div>

  <div class="card card--highlight">
    Outer (scoped)
    <div class="card">Inner keeps its default</div>
  </div>
</body>
</html>

The scoped version has three layers. Consumers set the public input property (--card-bg) through a modifier class. The component copies it into a private property (--_card-bg), with a default, at its own root. And nested cards explicitly reset the input to initial — the guaranteed-invalid value — so the fallback in the private property applies again. The leading underscore is a convention, not syntax: it signals "internal, do not set from outside".

The key technique: reset inputs at the component boundary

The reset line — :where(.card .card) { --card-bg: initial; } — is the piece most implementations miss. Without it, the nested card's private property would compute from the inherited input and still turn amber. Resetting inputs at each nested component root turns inheritance from "everything flows down" into "each component starts clean". Wrapping the selector in :where() matters: any declaration beats inheritance, so the reset needs no specificity at all, and keeping it at zero means a modifier such as .card--highlight on the nested card still overrides it.

Input, private value, styles Three stacked boxes. Top: input property, set by the consumer on one component. Middle: private property, computed at the component root as var of the input with a default. Bottom: styles, which only ever read the private property. A side note shows nested component roots resetting the input to initial so the default applies again. Three layers per component input: --card-bg (set by consumer) private: --_card-bg: var(input, default) styles read only --_card-bg nested roots reset to initial per root

For large design systems the reset can be generalised: a component's root rule sets all of its inputs to initial on nested instances, and a modifier or inline style on a specific instance sets them again. Where there are many inputs, a cascade layer that holds only the resets keeps them from interfering with modifiers.

Registered properties that do not inherit

@property offers a stronger form of isolation. A property registered with inherits: false is simply not passed to descendants:

@property --card-bg {
  syntax: "<color>";
  inherits: false;
  initial-value: #f1f5f9;
}

.card { background: var(--card-bg); }
.card--highlight { --card-bg: #fde68a; }

The nested card now receives the initial value automatically; no reset rule is needed. The trade-off is that the value must be set on exactly the element that reads it. A consumer who sets --card-bg on a wrapper around the card — a common and reasonable thing to do — gets no effect at all. Non-inheriting registration suits properties that genuinely belong to one element, like an animated angle for a gradient border, and suits animation more generally: Animating Custom Properties With @property shows why registration is also what makes such values animatable.

Motion tokens: inherit globally, override locally

Motion tokens need the opposite of isolation at the global level. A base duration and easing defined on :root should reach every component, so that a single reduced-motion override adjusts the whole site:

:root {
  --motion-base: 200ms;
  --motion-ease: cubic-bezier(0.2, 0, 0, 1);
}

@media (prefers-reduced-motion: reduce) {
  :root { --motion-base: 1ms; }
}

.drawer {
  /* Component-specific: slower, but still derived from the global token. */
  --_drawer-duration: calc(var(--motion-base) * 1.75);
  transition: translate var(--_drawer-duration) var(--motion-ease);
}

The drawer's private duration is derived from the global token rather than replacing it. That keeps reduced motion working — 1ms * 1.75 is still effectively instant — and it keeps the drawer's slower speed from leaking into the buttons inside it, because the buttons read --motion-base, which the drawer never changes.

Naming conventions that help

A consistent naming scheme makes leaks easier to spot in code review:

  • Global tokens: --color-accent, --motion-base. Set on :root, read anywhere.
  • Component inputs: --card-bg, --card-duration. Set by consumers, read only by the component root.
  • Private values: --_card-bg, --_card-duration. Set and read only inside the component.

When a component rule reads an input directly in a style declaration rather than through its private value, that is a candidate leak. When anything outside a component sets an underscored property, that is a boundary violation.

Different components, same name

Leaks are not limited to nested instances of one component. Two unrelated components that both read a generic name — --bg, --gap, --duration — interfere whenever one is placed inside the other. A tooltip reading --duration inside a drawer that sets --duration: 400ms suddenly fades in slowly, and nothing in the tooltip's own code explains why. Prefixing every input with the component name (--tooltip-duration, --drawer-duration) removes the collision entirely, and it makes DevTools output readable: the Computed pane lists custom properties by name, and a list of prefixed names shows at a glance which component each value belongs to.

Generic names are fine for true global tokens that every component is meant to share. The distinction is intent: --motion-base is shared on purpose, and a component that reads it expects any ancestor to be able to change it. --duration is ambiguous, and ambiguity is where leaks come from.

Testing for leaks

A simple test catches most leaks before they ship: render every component nested inside every other component that sets custom properties, with the outer one customised, and compare against the component on its own. Visual regression tools make this cheap. When a nested render differs, the difference names the leaking property. For motion, the equivalent test is to hover or open each nested component and check that its timing matches the standalone version.

Browser support

Custom properties and inheritance behave the same in every current browser. @property is supported in Chrome and Edge 85+, Firefox 128+ and Safari 16.4+; in browsers without it, a registered property falls back to ordinary inheriting behaviour, so pair inherits: false with the reset pattern if older browsers matter. prefers-reduced-motion is supported in Chrome 74+, Edge 79+, Firefox 63+ and Safari 10.1+, and @layer in Chrome and Edge 99+, Firefox 97+ and Safari 15.4+.

FAQ

Why does a custom property set on a parent change a nested component? Custom properties inherit by default. A value set on an outer card is visible to every descendant, including a nested card that reads the same property name, unless the nested component sets or resets it itself.

What is the public and private custom property pattern? Components read a private property such as --_card-bg, defined on the component root as var(--card-bg, default). Consumers set the public --card-bg. Nested component roots reset the public input, so an outer value never reaches a nested component unless it is set on that component itself.

Does inherits: false stop custom properties leaking? Yes. A property registered with @property and inherits: false is not passed to descendants; each element gets the initial value unless it is set on that element. It is the strongest isolation, at the cost of needing to set the value on exactly the element that uses it.

Should animation durations be scoped per component? Global motion tokens should inherit so a single reduced-motion override reaches everything. Component-specific tweaks, such as a slower drawer, belong in a private property that falls back to the global token.

Related articles

More pages in the same section.