A dense bundle of tangled, multicoloured electrical wires

Modern CSS Migrations: Why Fewer Lines Can Mean More Complexity

Swapping a chunk of JavaScript for a handful of CSS declarations feels like an obvious win. The pull request is mostly deletions, the reviewers are happy, and the component looks cleaner than it has in years. Container queries, :has(), native nesting and cascade layers have made that kind of clean-up easier than ever.

The catch is that a smaller file is not the same thing as a simpler website. A new CSS feature still has to live alongside the scripts, layouts, legacy stylesheets and rendering work that were already there, and that is where the surprises tend to appear.

Small Diff, Big System

It helps to separate two ideas that are easy to blur together. The first is how much code sits in one file. The second is how many moving parts you need to understand before you can change anything safely. A code review only really shows you the first.

Modern CSS genuinely does reduce code. It only reduces complexity when it fully replaces the old approach and doesn’t quietly change the behaviour of everything around it. Four common traps show how that can go wrong.

Go Ad Free
Pale blue jigsaw puzzle pieces fitted together on a stone surface

Trap One: Two Owners for One Job

Picture a card component that switches to a two-column layout once it is wider than 480px. The original version uses a ResizeObserver to add a .card--wide class, and the stylesheet keys off that class. A container query can do the same job with no script at all, so it gets migrated.

Real migrations are rarely tidy. The new @container rule goes in, but the observer lives in another file and survives. One rule that still reads .card--wide is never found. Everything looks fine because both systems agree on 480px. Then the breakpoint moves to 600px, someone updates the container query, and between 480px and 600px the card ends up with a narrow body and a wide-layout heading: a state nobody designed.

The same pattern turns up when a script toggles an “active” class while :has() works out the same state from the markup, or when old media queries sit next to new container queries. A sensible split is:

  • CSS handles presentation that depends on size or document structure.
  • JavaScript handles application state, network requests and anything other code needs to read.

Whichever wins, finish the move: remove the observer, the class and every selector that still refers to it.

Trap Two: Containers Change the Layout

Adding container-type: inline-size sounds like a polite request to let child elements query the width. In practice it also applies inline-size and style containment and creates a new block formatting context. That has knock-on effects that have nothing to do with queries:

  • Margins stop collapsing. A heading’s top margin that used to merge with the paragraph above now stays inside the card, so the gap grows and the card gets taller.
  • Floats behave differently. Instead of its text wrapping round a floated image, the whole card shifts across and becomes narrower.
  • Content-sized boxes can collapse to zero. Size containment stops an element taking its width from its contents. Small tag-style cards in a flex row, an inline-block, a floated box or a fit-content element can all shrink to 0px wide and seemingly vanish.

Before adding a container, check where the element gets its width from. If the answer is “its contents”, give it a width from its context or put the container on a wrapper instead. Then test it in every real layout where it appears, not just on its own in a pattern library.

Go Ad Free

Trap Three: Short Selectors With Long Reach

New selector features make it easy to write compact rules that match more, and carry more weight, than they appear to. Take a rule meant to remove paragraph spacing from cards with a hero image, written as .card:has(img) p. If a comment inside the card contains an avatar, that avatar counts as “an image”, and every paragraph in the card, including the comment’s own text, loses its margin. Because :has() takes the specificity of its argument, the rule also beats the comment component’s own styles. Using the child combinator (>) keeps it focused on the card’s direct children.

Nesting has a similar quirk. The & selector takes on the highest specificity in its parent selector list, so nesting under a list that contains an ID gives every nested rule ID-level weight. Wrapping that list in :where(), which always has zero specificity, keeps the convenience without the extra weight.

Cascade layers are not an automatic rescue either. Unlayered styles beat layered ones (unless !important is involved, which reverses the order), so putting new component styles in a layer while the old stylesheet stays unlayered means the old rules win every clash.

A silver stopwatch resting in an open hand

Trap Four: Faster Isn’t Guaranteed

“It’s in CSS now, so it must be quicker” is a common assumption. Moving work from a script to a stylesheet changes who does the work, not how much of it there is. The browser’s style and layout engine picks up the bill.

In a recent test using a 5,000-row list in Chrome, a single :has() rule anchored to body, exactly the pattern browser documentation warns about, added only around 1.4ms per update and restyled just one element. A second, unrelated :has() rule on the rows cost nothing measurable on its own. Put the two together, though, and every update restyled all 5,000 rows plus the toolbar, at roughly 7ms each. At 20,000 rows that climbed to around 31ms, almost two frames. Neither rule looked suspicious in isolation, and the result came from one browser on one machine, so the lesson is the pattern rather than the exact numbers.

Animation follows the same logic. Transitioning left or width in CSS triggers layout on every frame, while animating transform from JavaScript does not. The property matters more than the language. CSS often does win, but it’s worth proving it: record the interaction in your browser’s performance tools before and after the change, compare style, layout and paint time, and test with realistic amounts of data.

A hand writing a checklist in a squared notebook

A Pre-Merge Checklist

Before swapping a script for a modern CSS feature, it’s worth answering six questions, ideally in the pull request description itself:

  • Ownership: is either CSS or JavaScript clearly in charge of this behaviour, but not both?
  • Scope: how does the change affect the element’s width, its parent, its children and its neighbours?
  • Cascade: which existing rules could override this one, and which could it override?
  • Clean-up: has the old implementation, including every related class and selector, been removed and every state tested?
  • Performance: has the real workload been measured rather than assumed?
  • Maintainability: will the next developer understand why the rule exists and where its effects end?

Key Takeaways

  • Fewer lines of code doesn’t automatically mean a simpler website.
  • Give each behaviour one owner and delete the old version completely.
  • container-type changes layout as well as enabling queries.
  • Keep :has() and nested selectors tightly scoped, and use :where() to drop unwanted specificity.
  • Measure performance with realistic data instead of assuming CSS is faster.

None of this is an argument against modern CSS. Container queries and :has() are excellent and replace plenty of fragile scripts. For business owners, the practical point is that a “quick tidy-up” of a website’s code deserves the same testing as any other change, especially on older sites that have been built up over many years. A careful migration delivers a leaner, faster site; a rushed one can leave behind odd layout glitches that are hard to trace and harder to explain.

Go Ad Free

Create a free account, or log in.

Gain access to read this article, join developers who build better, faster, smarter solutions

Yes! I would like to receive new content and updates.

Scroll to Top