The Cascade's Missing Control Layer

CSS has always had two forces shaping every style: the cascade, which layers rules by specificity and source order, and inheritance, which passes values down the DOM tree. Both are powerful, but they have rarely been something we can shape directly. Inheritance requires an unbroken chain of ancestor elements, while specificity and order only give us coarse-grained control. CSS Custom Properties change that, letting us route inheritance and cascade through our own named "stacks."

Here is how to use that idea to solve problems that selectors alone cannot handle cleanly: scoping component styles to a context, declaring when one layer of intent should beat another, and simulating more deliberate origin and ownership structures.

Custom property basics

A custom property is simply an author-defined property with an empty -- prefix. Unlike variables you might know from preprocessors, these are real CSS properties — they cascade and inherit like any other. To retrieve a value, the var() function accepts the property name and an optional fallback:

button {
  /* use the --brand-color if available, or fall back to deeppink */
  background: var(--brand-color, deeppink);
}

That fallback is not a browser-support shim. If a browser does not support custom properties at all, it will drop the whole var() declaration. When a browser does support them, the fallback kicks in only if the property is undefined — analogous to how a font stack finds the next available family. With no fallback provided, the default is unset.

Because var() accepts only a single fallback, the way to build a longer list is to nest calls:

button {
  /* try Consolas, then Menlo, then Monaco, and finally monospace */
  font-family: Consolas, Menlo, Monaco, monospace;

  /* try --state, then --button-color, then --brand-color, and finally deeppink */
  background: var(--state, var(--button-color, var(--brand-color, deeppink)));
}

The syntax is verbose, but a preprocessor can keep the repetition tidy. The single-fallback limit exists so that a fallback itself can contain commas — for things like font stacks or layered background images.

Two kinds of "scope"

Selectors let you reach into the DOM and style nodes wherever they sit:

/* all links */
a { color: slateblue; }

/* only links inside a section */
section a { color: rebeccapurple; }

/* only links inside an article */
article a { color: deeppink; }

What selectors do poorly is reflect the mental model of component-driven code — overlapping blocks of context, each of which should win when it wraps another. Two distinct notions of precedence — proximity and ownership — get conflated when you work only with selectors.

Proximity scopes

Consider a .light theme and a .dark theme, applied to parent containers, possibly nested. Normally you declare the color pairs on the classes. Nested headings and paragraphs then inherit their colors from whichever theme class is the nearest ancestor. That "nearest defined ancestor wins" behavior is proximity, and inheritance handles it well.

Proximity does nothing for selectors, though. Trying to target a button inside both contexts — light variants purple with white text, dark variants plum with black text — simply fails. A surrounding markup context carrying both classes will take whichever selector appears later in the source, because both selectors share specificity. Overlapping ancestor classes produce a cascade decision, not a proximity decision.

Ownership and donut scopes

Even when there is no overlap, nested contexts are not always meant to style everything inside them. A "tab layout" owns the tab buttons, not the panels they swap; framework-generated scoped styles and naming approaches like BEM attempt to encode that same ownership with class names. Nicole Sullivan called this the "donut scope" problem back in 2011. Specificity cannot say: this container's defaults apply to me and my controls, but not the arbitrary content body that sits inside.

Custom properties as the scope mechanism

What you need instead is a style that inherits the surrounding theme, yet is only applied to a specific child. Custom properties can split that difference.

First define the button to use a variable with a default fallback:

button {
  background: var(--btn-color, rebeccapurple);
  color: var(--btn-contrast, white);
}

Now declare those variables on the theme classes; every button inside inherits the value from the nearest theme ancestor, even when both light and dark classes are present:

.dark {
  --btn-color: plum;
  --btn-contrast: black;
}

.light {
  --btn-color: rebeccapurple;
  --btn-contrast: white;
}

The outcome is fewer lines of CSS, one shared rule for every button, and theme-class proximity that simply works. The variables name the component's configurable points — its API. The default fallback guarantees the button renders even outside any theme.

For ownership, declare global values at the root so they function as the theme system's fallback:

html {
  --background--global: white;
  --color--global: black;
  --btn-color--global: rebeccapurple;
  --btn-contrast--global: white;
}

Then introduce a second set of properties that each component can override. The stack lets a component-specific value win, or fall back to the global one:

[data-theme] {
  /* If there's no component value, use the global value */
  background: var(--background--component, var(--background--global));
  color: var(--color--component, var(--color--global));
}

Any component that wants to invert the default sets its local variables to reversed values:

[data-theme='invert'] {
  --background--component: var(--color--global);
  --color--component: var(--background--global);
}

The crucial step is to blank those same component-level variables everywhere else, before any theme-specific values get declared. Assigning initial to a custom property sends it to the Guaranteed-Invalid state: the property is undefined rather than equal to a literal initial in a shorthands sense. Inheritance stops at that boundary, and consumers fall through the stack to the global settings:

[data-theme] {
  --background--component: initial;
  --color--component: initial;
}

Apply the data-theme attribute per component and any unmarked component falls back to the global theme. The cascade's ring around an element closes, giving you donut-scope semantics from the cascade's own machinery.

Ordering intents like origins

Before specificity and source order, the cascade sorts by origin: browser defaults, then user preferences, then author styles — the order reversing when anything is marked !important. Those layers always exist, so you would never rebuild them, but the idea transfers.

Component vs. theme vs. default is really a set of origins layered from fine to coarse. You can express other layered intents the same way:

  • Override » Component » Theme » Default
  • Theme » Design system or framework
  • State » Type » Default

Buttons again illustrate the payoff. A disabled style should beat any type variation like danger, but no selector relationship proves it. On a disabled primary button, .disabled and .primary have the same specificness, so order wins arbitrarily. Make a stack with state above type:

button {
  background: var(--btn-state, var(--btn-type, var(--btn-default)));
}

Declaring both variables then assures that the disabled property is what actually animates the result. There is no ordering trick left to depend on.

This same layering approach powers a Cascading Colors framework built deliberately so that pre-defined theme attributes in HTML, user color preferences, light and dark modes, and global defaults each have precedence in a defined order.

Picking a layer count

Nothing forces these patterns to stack ten variables deep. Day-to-day implementations typically need only two or three properties in a stack, unified by one or two of the earlier techniques:

  • A variable stack to name the layers of intent
  • Inheritance to assign those layers by proximity and context
  • The initial keyword to seal off nested regions you don't want restyled

The cascade was never something authors could tune while still using its built-in behavior. Selectors and ordering gave us only adjacency; inheritance gave us only lineage. Custom property stacks capture both and make them explicit levers. For anyone building design systems or component libraries, that control surfaces the real shape of a stylesheet's architecture.