The perils of the cascade

Complex style resolution, global scope, and broken component boundaries: Why I don't trust the cascade and you shouldn't either
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.
-
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.
-
Local scope. Inline declarations apply only to the element they're defined on. Unrelated rulesets generally can't override them.
-
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.
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!