Modern CSS Replaced Our Layout JavaScript: Container Queries, :has(), and Subgrid in Production
Table of Contents
About the Author

Ahmed Mahmoud
Author & Developer
Software engineer passionate about web development and user experience design.
Founder of Devya · eng-ahmed.com ↗Key takeaways
Container queries let a component size itself against its container instead of the viewport. Declare container-type: inline-size on a wrapper, then write @container (min-width: 30rem) rules for the component inside it.
The :has() selector is the CSS parent selector. The rule .card:has(> img) matches a card that contains an image, and it re-evaluates live as DOM and form state change, which covers cases like body:has(dialog[open]).
Subgrid, written as grid-template-rows: subgrid, makes a nested grid adopt its parent's track lines, so titles and footers align across sibling cards without fixed heights.
Cascade layers, declared as @layer reset, base, components, utilities, decide override order by layer instead of selector specificity, so a one-class utility in a later layer beats a three-class component selector.
Container queries, :has(), subgrid and cascade layers ship in Chrome, Safari and Firefox. CSS anchor positioning and calc-size() are still Chromium-only, so our team ships them only as progressive enhancement.
When should you use container queries instead of media queries?
Use a container query whenever the component can appear at more than one column width, and keep media queries for page-level concerns. A container query is a CSS rule written with @container that resolves against the size of the nearest ancestor declaring container-type, not against the viewport.
In our design systems this deleted a wrapper component we had shipped in several codebases: one that used ResizeObserver to measure its own width and toggle an is-narrow class. That wrapper is always one frame late because it reads layout after the browser has painted, so the component renders wide and then snaps narrow. A container query is resolved during layout, so there is no intermediate frame.
The value container-type: inline-size makes only the inline axis queryable and applies inline-axis size containment. The practical consequence is that such a container can no longer be sized by the width of its own contents, which is what breaks shrink-to-fit elements for teams adopting it for the first time. Container query units follow the same axis: 1cqi equals one percent of the container inline size, so clamp(1rem, 4cqi, 1.5rem) scales type per card rather than per viewport.
The gotcha that costs the most debugging time is that an element cannot query itself. If container-type is declared on .card, a @container rule targeting .card will never match. The container must be an ancestor of everything the query styles.
What does the :has() selector actually replace?
The :has() selector replaces the JavaScript that adds a class to a parent because of something inside it. It is a relational pseudo-class: A:has(B) selects element A when a descendant, child, or sibling matching B exists.
Three rules cover most of our usage. The rule .field:has(input:user-invalid) styles a form wrapper once the user has actually entered invalid input. The rule .card:has(> img) changes layout only for cards that really have a media child. The rule body:has(dialog[open]) applies overflow: hidden with no event listener, which removed a scroll-lock utility our team had maintained for years.
Two details are worth internalizing. The :has() selector takes the specificity of its most specific argument, so .card:has(#hero) carries an ID's weight unless the argument is wrapped in :where(). And :has() cannot be nested inside another :has() or contain pseudo-elements.
On performance, the blanket claim that :has() is slow is outdated. Browser engines optimize it. Our practice is to keep the subject narrow, preferring .card:has(> img) over *:has(img), rather than avoiding the selector.
When do you need subgrid instead of a nested grid?
Use subgrid when children of separate grid items must line up with each other. A nested display: grid creates its own independent tracks sized by its own content, so three cards with different title lengths produce three different internal layouts. Declaring grid-template-rows: subgrid makes the child adopt the parent's row lines instead.
Two properties work together. The declaration grid-row: span 3 makes the card occupy three rows of the parent grid, and grid-template-rows: subgrid tells the card to use those three row lines for its own children. Every card title sits on the same line and every footer sits on the same line, with no fixed heights anywhere.
The alternatives our team used before subgrid were all worse: hard-coded heights, line clamping to force every title to one length, or a JavaScript pass that measured the tallest card and wrote a pixel height onto the rest. Subgrid is also per-axis, so you can subgrid rows while defining your own columns.
How do cascade layers and @scope stop specificity wars?
Cascade layers make override order an explicit declaration instead of an emergent property of your selectors. A rule in a later layer beats a rule in an earlier layer regardless of specificity, so a single-class utility such as .text-muted in the utilities layer overrides a three-class selector such as .nav .nav__link.is-active in the components layer.
The single most important rule about cascade layers is that unlayered CSS beats every layer. One third-party stylesheet loaded outside a layer outranks an entire carefully ordered cascade, which is why we import vendor CSS with @import url(vendor.css) layer(vendor). The second trap is that !important reverses layer order, so an !important declaration in an early layer beats a normal declaration in a later one.
The @scope rule is the companion feature. Writing @scope (.card) to (.card__content) applies rules only between a root and a lower boundary, which gives real component isolation without long BEM class names. It landed later than the other four features and Firefox was the last engine to ship it, so check current Baseline status before making it load-bearing.
Which CSS feature replaces which JavaScript?
ResizeObserver plus a class toggle for component breakpoints is replaced by container-type and @container, available in every engine since Firefox 110 in February 2023.
Parent class toggles driven by child or form state are replaced by :has(), available in every engine since Firefox 121 in December 2023.
Measuring the tallest card and writing pixel heights is replaced by grid-template-rows: subgrid, available in every engine since Chrome 117 in September 2023.
Specificity hacks, !important chains, and stylesheet injection-order tricks are replaced by @layer, available in every engine since Chrome 99 and Safari 15.4 in 2022.
A scroll-lock utility that saved and restored scroll position is replaced by body:has(dialog[open]) with overflow: hidden.
What is still not safe to ship in 2026?
CSS anchor positioning and keyword size interpolation are still Chromium-only, so our team ships them only where losing them degrades cleanly. Anchor positioning, meaning anchor-name, position-anchor and position-area, tethers a popover to its trigger without a JavaScript positioning library and has been in Chrome since version 125 without matching Safari and Firefox releases.
The pair interpolate-size: allow-keywords and calc-size() finally animates height: auto, and sits in the same Chromium-only bucket. For expanding panels we use the grid-fraction transition instead — animating grid-template-rows from 0fr to 1fr with overflow: hidden on the inner element — because it works in every engine today.
The rule our team follows: if a feature changes information architecture, meaning whether the user can read the content or complete the task, it must be supported in all three engines. If it only changes polish, gate it behind @supports and let older engines get the plain version. That line is what keeps a popover from landing in the wrong corner on Safari.
FAQ
Q: Do container queries replace media queries entirely?
A: No. Container queries size a component against its container, while media queries still own page-level and environment concerns the container cannot know about, including overall page layout, prefers-reduced-motion, prefers-color-scheme, and print styles.
Q: Is the :has() selector slow?
A: Treating :has() as automatically expensive is outdated advice, because modern engines optimize it. Keep the subject narrow, preferring .card:has(> img) over *:has(img), and profile style recalculation in DevTools if a specific page feels slow.
Q: Why does a container query match nothing?
A: Almost always because the element is trying to query itself. The @container rule resolves against an ancestor that declares container-type, never against the element the rule styles, so move container-type onto a wrapper.
Q: What is the difference between subgrid and a nested grid?
A: A nested display: grid creates independent tracks sized by its own content, while grid-template-rows: subgrid adopts the parent's track lines so siblings align with each other.
Q: Do cascade layers work with Tailwind CSS or CSS-in-JS?
A: Yes. Tailwind CSS v4 organizes its own output into @layer theme, base, components, utilities. The rule to remember is that unlayered CSS beats every layer, so pull third-party stylesheets into a named layer if you need to override them.
Further Reading
Frontend Engineering
AbortController Beyond fetch: Cancellation Patterns We Use in Every React App
Uncancelled fetches do not fail loudly — they resolve late and overwrite fresh state. The AbortController patterns we use in every React app we ship: useEffect cleanup, AbortSignal.timeout(), AbortSignal.any(), and one-signal listener cleanup.
Frontend Engineering
WebSockets on Vercel Functions: Real-Time Without a Separate Server
We stopped running a separate WebSocket server for realtime features once Vercel Functions started supporting them natively on Fluid Compute. Here's how the upgrade works, when it beats SSE, and what reconnection logic you still own yourself.