Modern CSS Complexity: Migrate Without Hidden Bugs15 min read3,005 words

Modern CSS Complexity: Migrate Without Hidden Bugs

Modern CSS can replace JavaScript, but it can also create hidden layout, cascade and performance costs. Use this checklist to migrate without surprises.

modern CSS complexityCSS container queriesCSS has selectorCSS cascade specificityCSS performance
Modern CSS Complexity: Migrate Without Hidden Bugs

Modern CSS Can Reduce Code and Increase Complexity

Swapping a JavaScript handler for a container query can make a component feel cleaner and more responsive. The pull request often looks like a straightforward win: fewer lines, fewer moving parts, and one less script to maintain. But then a card collapses weirdly in a dashboard, a nested comment loses its spacing, or a breakpoint change leaves the title and layout out of sync.

That's not because modern CSS is "bad." Container queries and :has() let you express relationships that used to require JavaScript. The catch is this: fewer lines in one file isn't the same thing as a simpler application. In a real codebase, CSS and JavaScript share the load - layout, the cascade, component structure, and what the browser ends up recalculating. Change a little, and you can shake all of it.

This guide walks you through a practical way to evaluate those effects before and after a migration. We'll cover behavior ownership, layout containment, selector scope and specificity, and how to measure performance. The goal isn't to avoid modern CSS. It's to use it when it removes complexity from the system - not just from the diff.

Why can modern CSS make a codebase more complex?

Most changes come with at least two kinds of cost. There's local complexity - how much code one file (or one change) adds. And then there's system complexity - how many interactions someone has to understand to change the app safely.

A new CSS feature can reduce the first while increasing the second. A container query can replace a ResizeObserver, for example. But if the old observer still runs somewhere else, you've now got CSS and JavaScript both influencing the same layout. The new code is short; the component's behavior is harder to trace.

Give each behavior one owner

Imagine a card that switches to a two-column layout when it gets wide enough. With JavaScript, you might observe the card and add a card--wide class. With a container query, the layout can be described directly:

.card {

container-type: inline-size;

}

@container (width > 480px) {

.card__body {

display: grid;

grid-template-columns: 160px 1fr;

}

}

That's a good replacement only if the old implementation actually goes away. In real migrations, behavior often ends up split across places for a while: maybe the observer stays in a shared script, while another selector still checks .card--wide to adjust the title size. For a while everything "agrees," and basic testing won't necessarily catch the duplication.

Later, someone tweaks the container-query breakpoint and forgets about the leftover class rule. Between the old and new breakpoints, the card can end up narrow while the title looks wide - or vice versa. Nobody intended that state; it exists because two systems still own parts of the same behavior.

A useful way to split responsibilities is:

  • CSS owns presentation derived from the element's size or structure, like responsive layout or styling a form based on structure.
  • JavaScript owns application state and asynchronous work, like request state or what the server returned.
  • If other code needs to read or change a state, don't hide the source of truth inside a CSS-only condition.

This isn't a strict rule - it's a guardrail. Some interfaces genuinely benefit from combining CSS and JavaScript. The problem is when they independently control the same result. After the migration, remove the old observer, class toggles, and any selectors that depended on them. And don't only scan the files in the pull request - search the codebase.

Measure interactions, not deleted lines

Before you merge, ask what someone will need to inspect to explain the component's behavior. If their answer includes a script, a query rule, and a leftover modifier class, the migration hasn't really simplified anything. A smaller diff can be evidence of less code, but it's not evidence of a single owner or a predictable behavior boundary.

What can container queries change besides layout rules?

Container queries are handy because a component can respond to its own available space instead of the viewport. That makes reusable cards and panels easier to drop into different layouts.

But making an element a query container also changes how the browser treats it. container-type isn't just "turning on queries." It can also trigger other containment behavior.

Check containment and formatting context

For example, container-type: inline-size enables inline-size containment. Query containers can also create a block formatting context. Those changes can affect how neighboring content lays out and how the component sizes itself - even when the query condition never matches.

A formatting-context change can influence float behavior and margin collapsing. A card that used to let a floated image sit alongside it might instead position the float relative to the card as a whole. Margins that used to collapse across boundaries may start stacking differently. And a gap that looked correct before can grow even if nobody touched a margin value.

Inline-size containment can also cause elements to shrink in surprising ways. If an element's width previously came from its children, containment can block that sizing path. In flex and grid, that can leave the item narrower than expected - sometimes even collapsing to something close to zero, depending on how the surrounding layout is set up.

A basic block that fills its parent might look unaffected. That's still not proof it's safe. The same card might be a flex item in another view, sit next to a float, or be sized to fit its content elsewhere.

Test the component in its real contexts

Before adding container-type, trace where the element's width comes from. If it depends on its contents, you may need a definite sizing context - or you may need to place the container query on a wrapper whose width is already determined. Often the clean approach is: let the wrapper take the space from the parent, then let the inner component respond to that wrapper.

Then test in the layouts that actually use the component. A good checklist includes:

  • A normal block that fills its parent.
  • A flex item and a grid item.
  • A content-sized or fit-content scenario, if the component supports it.
  • A placement near floats, if your pages still use floats.
  • First/last children with margins that might previously have collapsed.

The point isn't to test every theoretical CSS combination. It's to cover every placement your component API promises. If the component only makes sense as a full-width block, document that constraint and enforce it in layout. If it's truly general-purpose, test the contexts it claims to support.

Put the query container where its effects are predictable

Sometimes the right move is to move container-type onto a wrapper. That keeps the queried width available while limiting what changes about the component's own intrinsic sizing or formatting behavior. Other times the component itself should be the container, but then you need to make its sizing contract explicit.

Avoid using width: 100% or flex: 1 as a reflex. Those can "fix" one layout by forcing a width, and then break the intended content-sized behavior somewhere else. Identify where the width should come from first; then encode that relationship.

How can modern selectors create cascade problems?

Selectors like :has() and native nesting make some relationships easier to write. They can also match a larger portion of the document - or carry more specificity than the compact syntax suggests. The result is a rule that looks local but changes styles inside nested components.

Constrain :has() to the intended structure

Say you want a card to remove paragraph margins when it contains a hero image:

.card:has(img) p {

margin: 0;

}

Looks reasonable, right? The problem is that :has(img) matches whenever any descendant image exists. If there's a comment avatar image somewhere inside the card, the condition is true. Then the selector matches every p below the card, including the comment's own text. Now your comment component's margin rule loses because the card selector is stronger.

This isn't only about selector strength - it's about selector reach. If the hero image and target paragraphs are direct children, make that explicit:

.card:has(> img) > p {

margin: 0;

}

The child combinators prevent the rule from treating a deeply nested image as the "hero," and they stop the rule from reaching into that nested component's paragraphs. If your markup doesn't use direct children, pick a structural boundary that matches the actual component contract.

Watch the specificity that nesting can bring along

Nesting can also "borrow" specificity from selectors around it. When you nest with &, the specificity of the nested rule can reflect the most specific selector in the list it came from. So a nested rule under something like .card, #featured can effectively carry ID-level weight, even when it's really just matching an ordinary card.

If your selector list includes a high-specificity option and you don't mean to carry that weight into nested rules, consider :where(), which groups selectors while contributing zero specificity:

:where(.card, #featured) {

& a {

color: blue;

}

}

This keeps the matching behavior while avoiding the borrowed ID specificity. Use it to shape the cascade intentionally - not as a universal wrapper around everything.

Check layers as well as specificity

Specificity isn't the only thing that decides cascade outcomes. Origin, importance, and cascade layers can decide the winner before specificity ever matters.

In particular, normal unlayered author styles can outrank normal styles inside a layer. That means a component rule added to @layer components won't reliably override an unlayered legacy rule just because the new selector looks stronger.

Layers help when a codebase uses them consistently and keeps their ordering deliberate. A half-migrated stylesheet can create another thing to remember: some styles compete inside layers, while others sit outside the layer system. If a style "mysteriously" loses, use browser devtools to find the winning declaration and check its layer, specificity, and source order before adding another override.

Is CSS always faster than JavaScript?

No. Moving behavior into CSS changes which part of the browser does the work - it doesn't automatically make the work cheaper. The cost depends on what has to be recalculated, which properties change, how much of the document is affected, and how the browser implements the feature.

Treat performance claims as hypotheses

A small test with a long list of rows shows why "broad selector" rules deserve measurement. In one Chrome test (on a specific machine), a :has() rule anchored to body added measurable time when a row's selected class changed. A separate :has() rule on its own wasn't notable. Together, though, the rules caused thousands of elements to be restyled for one selection change. With a larger list, the cost rose again.

Those numbers aren't a general benchmark for :has(). They depend on the test page, browser version, hardware, and the selector shape. The real lesson is the interaction: two rules can look harmless alone and still amplify each other's invalidation work. A code review can't infer that cost from line count.

When you use :has(), avoid anchoring it to very broad elements like body, :root, or * unless you have a clear reason. Constrain the inner selector with relationships like > or + so the browser doesn't have to consider an unnecessarily large subtree when the DOM changes.

The animated property matters more than the language

The same idea applies to animation. A CSS transition that changes left, width, or margin can trigger layout work on every frame. A JavaScript animation that uses transform can avoid that layout work. And if a script repeatedly reads layout and writes styles, it can force extra synchronous calculations.

The implementation language matters, but properties and update patterns matter just as much. For many animations, transform and opacity are good starting points because they can often be handled without recalculating layout. They're not universal answers: transforms can affect stacking and overflow behavior, so confirm the visual result and the performance.

Profile the workload users actually have

Measure before and after using representative data and interactions. In browser devtools, record the relevant interaction in the Performance panel and compare time spent in style recalculation, layout, and paint. If the tools support it, use selector stats to spot expensive rules.

Don't rely on a toy fixture with twenty rows. A small test can hide work that shows up once you have thousands on a real dashboard.

And don't optimize for a benchmark your users will never hit. If a more expressive CSS rule removes code and adds a fraction of a millisecond on a rare interaction, that might be a fair trade. If it restyles thousands of elements on a frequent action for a big customer, that trade may be unacceptable. In the pull request, state the workload, the observed cost, and the decision so the next person doesn't have to guess.

How do you migrate to modern CSS safely?

A safe migration isn't just "convert the syntax." It changes ownership, layout behavior, and performance characteristics. Treat it like a small system change, even if the diff looks short.

Use a staged migration

Start by identifying the behavior you're replacing. Write down the current source of truth, the states it supports, and which elements it affects. For a responsive card, that might include the JavaScript observer, modifier class, breakpoints, title styling, and body layout. Then find every use of the class and every rule that depends on it.

Next, add the CSS implementation with a limited scope. Test it in each supported layout context and across the relevant width range, including values around the breakpoint. If both implementations have to coexist temporarily, decide which one is authoritative and how long overlap lasts. Avoid "leave both in place and hope" migrations where the two versions happen to match today.

After the CSS behavior is verified, remove the JavaScript observer and the old class and selectors. Check intermediate states, not just the wide and narrow endpoints. A migration can look correct at both ends and still diverge in the middle.

Finally, profile the real interaction if the change could affect a large subtree, repeat updates, or animation. Keep a short record of what you measured and under what conditions. Evidence beats a general claim that CSS is inherently faster.

Put the checks in the pull request

Before merging, answer these six questions:

  1. Ownership: Is exactly one of CSS or JavaScript responsible for this behavior?
  2. Scope: What changes for the element's width, formatting context, parent, children, and neighbors?
  3. Cascade: Which existing rules can override this one, and which rules could it override? Are layers involved?
  4. Migration: Did you remove the old implementation, modifier class, and dependent selectors? Did you test intermediate states?
  5. Performance: Did you measure a representative workload before and after, rather than assuming the rewrite is faster?
  6. Maintainability: Can the next developer explain why the rule exists and where its effects stop?

If any answer is unclear, the change might still be worth it - but it's not finished. Add a test, narrow the selector, move the query container, or document the intended boundary before the behavior turns into an undocumented dependency.

Key takeaways

  • Fewer lines do not automatically mean a simpler application; count the interactions a future change will need to understand.
  • Let CSS own presentation derived from layout or structure, and let JavaScript own application state and asynchronous work.
  • Treat container-type as a layout change, not just a query-enabling declaration.
  • Constrain :has() and nested selectors to the intended component boundary, then inspect specificity and cascade layers.
  • Measure browser work with realistic data. Performance depends on the rules and workload - not whether the code is written in CSS or JavaScript.

Conclusion

Modern CSS gives you tools that can remove scripts, dependencies, and brittle workarounds. But shorter code is only a real simplification when it has one clear owner, behaves predictably in its layout context, fits the cascade, and performs well for the workload that matters.

Before merging, trace where the old behavior lives, test how the new rule interacts with its surroundings, and remove what it replaces. Then measure where a performance change is plausible. If you can't explain who owns the behavior and where its effects stop, the code may be shorter - but the system isn't yet simpler.

Frequently Asked Questions

For responsive presentation based on an element’s available width, container queries are often a simpler fit. They are not a replacement for every observer; check whether JavaScript still needs the size for application logic and remove duplicate behavior only when CSS covers the intended states.
Yes. It enables inline-size containment and can establish a new formatting context, affecting intrinsic sizing, floats, and margin collapsing. Test the element in its actual parent layouts, especially when its width depends on content or it is a flex or grid item.
Not inherently. Its cost depends on the anchor element, selector scope, DOM changes, browser implementation, and other stylesheet rules. Constrain broad selectors where appropriate and profile the interactions and document sizes your users actually encounter.
Both can participate, but they should not independently own the same behavior. JavaScript might manage request state while CSS styles its presentation. Problems arise when both decide the same state, such as whether a component is wide or active.

Related articles