Modern CSS Might Make Your Front-End More Complex

Nov 03, 2026 - 08:46
0
Modern CSS Might Make Your Front-End More Complex
Exhibit at the World's #1 Virtual Deal-making Exhibition for trade and commerce.
Exhibit at the World's #1 Virtual Deal-making Exhibition for trade and commerce.

Have you ever replaced fifty lines of JavaScript with a few lines of CSS, merged the PR, and felt like you just did the codebase a favor? You replace ResizeObserver with a container query, the diff is mostly red, and everyone approves it before lunch.

This was what it looked like before you made the change:

  1. The selector matches more than we meant.
    The :has(img) rule matches any descendant image, so the comment’s avatar counted as a hero image. And the p element selector matches any descendant paragraph. As a result, the comment’s text was caught in the selection.
  2. The selector outweighed the comment’s own rule.
    MDN’s specificity guide explains that :has() adds no weight by itself, but its argument does. That gives .card:has(img) p a specificity score of 0-1-2, while .comment p comes in at just 0-1-1 and loses the battle.

It could get even more complex and difficult to catch because the state changes at runtime. Let’s say a user posts a comment that includes an avatar. The body text of the card above it loses its spacing. Good luck reproducing that from a bug report that says “spacing looks off sometimes.”

The fix is to constrain the selector with child combinators:

.card:has(> img) > p {
  margin: 0;
}

This helps the selector get more specific about what it is yelling about.

Nesting Also Borrows Weight

Native nesting has the same habit. MDN’s guide to nesting and specificity says the & selector takes “the largest specificity in the associated selector list,” exactly like :is(). Look at this adaptation of MDN’s example:

/* Specificity 1-0-0 */
.card, #featured {
  /* Specificity: 1-0-1 */
  & a {
    color: blue;
  }
}

/* Specificity: 0-1-0 */
.button {
  color: red;
}

A .button link inside a .card is blue, even though the #featured ID is nowhere near it. The usual reaction is a more specific .button rule, then an exception to that rule. Before you know it, you are in a war of specificity you never knew you had declared.

The way out is to keep the selector list and drop its weight by wrapping it in :where():

/* :where() always weighs 0-0-0 */
:where(.card, #featured) {
  /* Specificity: 0-0-1 */
  & a {
    color: blue;
  }
}

/* Specificity: 0-1-0 */
.button {
  color: red;
}

Plain links inside a card are still blue, and the .button link is red again. Again, another way to quiet the loud housemate.

Layers Don’t Rescue You By Default

Don’t be fooled into thinking specificity is the whole story. MDN’s introduction to the cascade puts origin, importance, and cascade layers ahead of specificity, and notes that “normal layered styles have less precedence than normal unlayered styles.” In my test, a layered rule with a specificity of 0-4-0 lost to an unlayered .title (0-1-0). So if your new component styles go into @layer components while the legacy stylesheet stays unlayered, the legacy rule wins every conflict, however carefully you wrote the new one. The exception is when the !important keyword is used, because it actually reverses the cascade order.

Miriam Suzanne’s original proposal for cascade layers describes exactly the pain layers were meant to remove: having to “be very careful with matching specificity to layer.” Layers do remove it, but only when the rules on both sides of the conflict are layered. A half-layered codebase just gives you one more ordering to keep in your head.

An Old Fight With A New Tool

None of these battles started with :has() or nesting. In an r/webdev thread titled “CSS-in-JS: What’s the biggest performance drop you actually felt?”, one of the most upvoted answers isn’t about performance at all. It is about the cascade:

“Here’s a fun one: refactoring your JS can cause your CSS to break, because your CSS builds are determined by JS import orders.”

— mq2thez

This is about the “order of appearance,” which is the last step in the cascade that ends up deciding a conflict nobody knew existed. Another commenter in the subreddit adds that the injected styles can land in a different order in development and production builds: “Who doesn’t love debugging something that works locally but not in prod.”

Not everyone in the thread agrees on who is to blame. TheThirdRace replies, “It’s a coding issue, not a technology issue.”

I think both are right, and that is exactly the point. CSS-in-JS is a different technology from the native features we are looking at, so take this as a neighboring example, not as proof. But the shape of the problem is the same: the tool did what it promised, and the trouble arose from how it complied with the existing rules.

Before you add the rule:

  1. Constrain it. Use > so the selector stops at the component’s own children.
  2. Drop the borrowed weight. Wrap heavy selectors in :where(), which always has a specificity of 0-0-0.
  3. Check the layers. Which layer does your rule land in, and where do its competitors live?
Scenario 4: The Housemate That’s Slow To Pay Rent

“We moved it to CSS, so it’s faster now.” You have probably heard this in a code review, and maybe said it yourself. In my last article, I warned that it isn’t always the case. Let’s test it.

Moving a behavior from JavaScript to CSS changes who does the work. It doesn’t tell you how much work there is. The last housemate, the browser’s rendering engine, is the one who gets the bill. Everyone in the apartment shares it, and nobody looks at it after the move.

Take :has() as an example. Browser vendors were worried about its cost from the start. In a CSS Working Group issue about :has() restrictions, Emilio Cobos Álvarez noted that implementations wanted to ship it with limitations “in order to prevent performance footguns and complexity.” Today, MDN’s :has() page has a whole section on performance with two pieces of advice:

  • Don’t anchor :has() to an element with a large subtree, “like body, :root, or *.”
  • Constrain the inner selector with > or +, because otherwise “the browser might need to traverse the entire subtree of the anchor element on every DOM mutation.”

I wanted numbers, so I built a list with 5,000 rows. The test toggles an .is-selected class on the same row 300 times, so the row’s selected state changes on every toggle, and forces a style and layout update each time. I ran it in Chrome 153 on a Mac in October 2026, and recorded a trace in DevTools to count how many elements each toggle restyles.

See the Pen Scenario 4: The Rewrite Nobody Measured [forked] by Shoyombo Gabriel Ayomide.

Rule What is in the stylesheet Median time per toggle Elements restyled
1 JavaScript toggles a class on the toolbar Under 0.1ms 1
2 body:has(.row.is-selected) .toolbar About 1.4ms 1
3 .list:has(.cell > b) .row, with the toolbar handled by JavaScript Under 0.1ms 1
4 Rules 2 and 3 together About 7ms 5,001
5 For scale: JavaScript restyles all 5,000 rows on every toggle About 6ms 5,001

There are two lessons in that table.

First, the “bad” selector was fine. Rule 2 is exactly the shape MDN warns against: a :has() anchored to body. It cost about a millisecond and a half more than the script, and it restyled a single element, the toolbar. A frame at 60Hz lasts 16.7ms, so I would take that trade for deleting an event handler any day.

Second, two harmless rules were not harmless together. Rule 3 styles the rows, and its :has() condition remains unchanged throughout the test. On its own, it costs nothing measurable. Put it in the same stylesheet as the second rule instead, and every toggle restyles all 5,000 rows plus the toolbar. That is about what it costs a script to restyle every row on purpose.

Let’s keep this in proportion. Since 7ms still fits inside a frame, nobody would notice it on this page. However, the browser is now restyling 5,001 elements to change just one, and neither rule says so. Also, the cost grows with the list. When I set the demo to 20,000 rows, the two rules together restyled 20,001 elements and took about 31ms per toggle, which is nearly two frames.

I can tell you what Chrome does here, but not why. Changing .is-selected on one row restyles every element that the other :has() rule targets, even though that rule never mentions .is-selected and its result never changes. I have filed this with the Chromium team as an issue, with a reduced test case, and I only tested Chrome. Whatever the cause, neither rule looks suspicious in a code review, and the cost applies only when both are met.

That is system complexity in a single table. And please take the pattern from it, not the numbers: this is one page, one browser, and one machine.

The same goes for animation. The animation guide on web.dev recommends restricting animations to opacity and transform where possible, and warns that “an animation that moves an element using something other than transform is likely to be slow.” MDN’s CSS performance guide lists the properties that trigger layout when animated, such as width, height, margin, top, and left. A CSS transition on left runs layout on every frame, while a JavaScript animation of transform skips it. The property determines the cost, regardless of which language you write it in.

To be fair, CSS often wins. A script that reads layout, writes styles, and reads layout again forces the browser to do layout several times in one frame, and a container query hands that whole job to the browser. But that win is something you can measure, so measure it:

  1. Record the interaction in the “Performance” panel of Chrome DevTools, before and after the change.
  2. Compare the time spent in “Recalculate Style,” “Layout,” and “Paint.”
  3. Enable CSS selector stats in the panel’s settings to see which selectors cost the most.
  4. Use production-sized data. A 20-row fixture hides what a 5,000-row list reveals.
When All Four Show Up In One Pull Request

Each of these scenarios is small on its own. The trouble is that a real migration could hit all four scenarios at once. You have already seen three of them. They are the three bugs in the “after” demo at the top of this article. Add a long list, and you get the fourth. One move-in, four housemates disturbed.

A team replaces a card’s JavaScript-driven responsive layout with a container query and :has(). The pull request adds twelve lines of CSS, deletes one script, and misses another. Within a month, three bug reports land, and there’s a chance a performance trace turns up as the fourth problem:

  1. The card title is too big on tablets. The observer still sets .card--wide, and one rule still reads it. The developer goes digging through the JavaScript.
  2. The cards on the dashboard have disappeared. On the dashboard, the card is a flex item, and inline-size containment collapsed it to 0px. The developer reads the parent’s layout.
  3. The text is squashed in some cards. Those cards have comments with avatars, so .card:has(img) p matches. The developer traces the winning declaration in DevTools.
  4. Selecting a row might become laggy for our biggest customer. At 5,000 rows, the two :has() rules cost about 7ms per selection. That fits within a frame, even though each selection restyles 5,001 elements, whereas it used to restyle one. As the number of rows increases, the elements restyled increase, and the cost of the two :has() rules per selection will also increase, but who renders a table with a large number of rows without pagination?

None of these reports means that container queries or :has() were the wrong choice. Each bug lives in the meeting point between the new CSS and the system it moved into. Still, one small pull request has sent a developer through the JavaScript, the page layout, the cascade, and the profiler.

And the way out is not a return to JavaScript. Here is the same page once more, fixed.

See the Pen demo (fixed PR) [forked] by Shoyombo Gabriel Ayomide.

It still uses a container query and :has(), with three changes:

  1. The observer and the leftover .card--wide rule are removed, so the layout has a single owner.
  2. The query container is a wrapper that inherits its width from the surrounding layout, so the card can no longer collapse.
  3. The selector is constrained to the card’s own image and its own paragraphs, so the comment keeps its spacing.

This gap between a clean start and a real codebase shows up in the same r/webdev thread. The person who started it, JRM_Insights, opens with an observation that could describe any of our four scenarios:

“I often find that the initial tooling simplicity gives way to harder-to-debug runtime styling issues.”

— JRM_Insights

Further down, mq2thez describes moving an old CSS-in-JS codebase to a newer pattern: “In isolation or greenfield maybe it wouldn’t be a huge issue,” but in practice, it meant rewriting “whole component trees.” TheThirdRace, who had been defending the technology, answers that they had only ever evaluated it “from a greenfield project perspective,” and that it was “very enlightening to see this through the lens of a spaghetti CSS migration.”

That is the honest version of this whole article.

Most of what we read about a new feature is written from the greenfield perspective. Most of what we ship is a migration.

Your Migration Checklist

Before you replace JavaScript with a modern CSS feature, or adopt one in an existing codebase, answer these six questions. Think of them as the conversation to have with the housemates before the new tenant gets a key. I suggest putting the answers right in the pull request description.

  1. Ownership: Is exactly one of CSS or JavaScript the owner of this behavior?
  2. Scope: What changes for the element’s formatting context: its width, its parent, its children, and its neighbors?
  3. Cascade: Which existing rules could override this one, and which rules could it override?
  4. Migration: Have you deleted the old implementation, including every class and selector it relied on, and tested every state?
  5. Performance: Have you measured the real workload before and after, rather than assuming the rewrite is faster?
  6. Maintainability: Can the next developer tell why this rule exists and where its effects stop?
Conclusion

Modern CSS is great and a huge improvement to styling as a whole. In particular, container queries and :has() let us express things that used to need a script, a dependency, or a hack. But a new capability does not hand us a simpler system by default. In every scenario we looked at, the new tenant did something that didn’t sit well with others, and that was because nobody introduced them to the housemates’ ways of doing things. We get a simpler system when the new rule has one clear owner, a scope we have checked, a place in the cascade we chose on purpose, and numbers showing it helped.

So, the next time a pull request deletes more than it adds, enjoy the red lines. Then ask where the complexity went, and run the six questions. If you can’t answer one of them, the migration isn’t finished yet.

References

Reward this post with your reaction or TipDrop:

Like Like 0
Dislike Dislike 0
Love Love 0
Funny Funny 0
TipDrop TipDrop 0
Sad Sad 0
Angry Angry 0
Smashing magazine

Founded in September 2006 in Germany, Smashing Magazine delivers reliable, useful, but most importantly practical articles to web designers and developers. We don’t care about trends; we care about things that work or fail in actual projects. We are — and always have been — independent. 17+ years, and still ongoing.

Silvergoldbull - buy and sell gold and silver securely.
Silvergoldbull - buy and sell gold and silver securely.

Comments (0)

User