Home

The perils of the cascade

A person being washed down a cascade waterfall

Complex style resolution, global scope, and broken component boundaries: Why I don't trust the cascade and you shouldn't either

4 minutes

The cascade determines which CSS declarations take precedence when multiple rules apply to the same element. It's the feature that gives CSS its name—and also the feature I've learned to distrust the most. As a design system engineer, I've spent years debugging cascade-induced defects: rulesets that silently do nothing, components broken by unrelated styles defined elsewhere, and overrides that win for reasons nobody can recall.

Problems with the cascade

Complex style resolution algorithm

Determining which ruleset takes precedence requires reasoning about source order, specificity, and cascade layer precedence. That's already difficult with hand-written CSS. When a CSS-in-JS system generates stylesheets at runtime, even source order, the simplest of these, often depends on runtime behavior rather than code structure.

I recently built a proof-of-concept running Chakra UI v2 and v3 side by side. My team needed a gradual migration path for a large codebase, so the two versions had to coexist. I started with a custom button recipe, but its styles didn't seem to work. v3 uses cascade layers, while v2's global CSS reset is unlayered. The unlayered declarations in v2's reset took precedence over v3's layered styles, including those generated by my button recipe.

Another case involved a component library I had built, which used a static stylesheet. My colleague attempted to customize some of the styling using Emotion. He wrote <Text variant="heading1" css={{ color: "#666" }}>...</Text>, expecting dark gray text. Instead, he was baffled when he got magenta text, the default for our "heading1" variant. The css prop looks like an inline style, but Emotion turns it into a generated class selector like .css-9fh586, which is easy to override unintentionally due to its low specificity. And that's exactly what happened: Emotion's stylesheet preceded the component library's style sheet in DOM order, and with equal specificity the default overrode the customization.

Global scope

CSS is global by default. A selector can match elements anywhere in the document, including inside components that have nothing to do with the stylesheet that defines it.

Component libraries often rely on a global reset stylesheet so they don't have to define every default within each component. But those defaults also affect unrelated components, and other stylesheets can override them. In my Chakra proof-of-concept, v2's global reset also applied to v3's components, unintentionally overriding their layered styles.

In another project, I needed to integrate a React header built with a modern component library into a legacy product. The header's links were styled with a .nav-link selector, but an existing stylesheet applied conflicting styles through an a:link selector. The legacy selector had higher specificity, so its styles broke the component.

Even within a single application, styles intended for one component can affect others. A colleague recently added a :hover selector to suppress a row hover effect in one data table, but ended up disabling it across every data table in the application. New to the codebase, he'd assumed the CSS import was scoped to the component. Given how much build-time CSS magic exists these days, I can hardly blame him.

Mutability

A component's API defines how client code interacts with it. Ideally, its implementation details are free to change as long as that contract is honored. But when client code depends on those implementation details instead of the explicit API, a small patch can silently become a breaking change.

The cascade allows client code to bypass a component's props and target its internal markup with selectors. Those overrides depend on details such as class names and element structure, making them vulnerable to changes that don't otherwise affect the component's API.

As a library maintainer, I'd rather extend a component's API to support a new use case than have client code depend on its internals. Overrides I don't know about make it difficult to assess whether a change is safe.

Escaping the cascade

Inline styles resist interference from stylesheet rules and avoid adding rules that can interfere with others. The result is that you can think more about styling your components and less about your styling system.

  1. Explicit precedence. Inline style objects compose in the order you write them. You don't need to resolve competing selectors, stylesheet order, or cascade layers.

  2. Local scope. Inline declarations apply only to the element they're defined on. Unrelated rulesets generally can't override them.

  3. Stronger encapsulation. Client code generally can't use selectors to override component styles. Customization is restricted to the component API.

The standard objection to inline styles is that they don't support pseudo-classes like :hover or responsive queries. But my article From CSS madness to CSS Hooks offers a sound solution to that problem.

Discuss this post|Suggest an edit

Stay informed

Subscribe to email updates and be the first to know when I post new content.

I hate spam as much as you do.
Unsubscribe at any time — no hard feelings!