DEV Community

Ahmed Mahmoud
Ahmed Mahmoud

Posted on • Originally published at devya.dev

Modern CSS Deleted My Layout JavaScript: Field Notes on Container Queries, :has(), and Subgrid

Headline: Four CSS features — container queries, :has(), subgrid, and cascade layers — let me delete most of the JavaScript I used to write for layout and conditional styling. Container queries size a component against its container instead of the viewport, :has() styles a parent based on its children, subgrid makes a nested grid adopt its parent's track lines, and @layer makes override order explicit instead of a specificity race.

Key takeaways

  • container-type: inline-size on a wrapper plus @container (min-width: 30rem) styles a component by the width of its container, so one card works in a sidebar and in a full-width grid with no viewport media query.
  • :has() is the CSS parent selector: .card:has(> img) matches a card that contains an image, and it re-evaluates live as the DOM and form state change.
  • grid-template-rows: subgrid makes a nested grid adopt its parent's row lines, which is how sibling cards align their titles and footers without fixed heights.
  • Cascade layers — @layer reset, base, components, utilities — decide override order by layer rather than selector specificity, so a one-class utility in a later layer beats a three-class component selector.
  • Container queries, :has(), subgrid, and @layer all ship in Chrome, Safari, and Firefox. CSS anchor positioning and calc-size() are still Chromium-only, so I use them only as progressive enhancement.

When should I use container queries instead of media queries?

Use a container query whenever the thing you are styling can appear at more than one column width; 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 which declares container-type — not against the viewport.

.card-grid { container-type: inline-size; container-name: cards; }

.card { display: grid; gap: 0.5rem; }

@container cards (min-width: 30rem) {
  .card { grid-template-columns: 8rem 1fr; }
  .card__title { font-size: clamp(1rem, 4cqi, 1.5rem); }
}
Enter fullscreen mode Exit fullscreen mode

This deleted a component I had shipped in three codebases: a wrapper that used ResizeObserver to measure its own width and toggle a .is-narrow class. That wrapper is always one frame late, because it reads layout after the browser has already painted — the component renders wide, then snaps narrow. A container query is resolved during layout, so there is no intermediate frame to see.

container-type: inline-size means only the inline axis is queryable and the element gets inline-axis size containment. The practical consequence: a container declared that way can no longer be sized by the width of its own contents, which is what breaks shrink-to-fit elements when people first adopt it. Container query units follow the same axis — 1cqi is one percent of the container's inline size, which is what makes the clamp() above scale type per card rather than per viewport.

The gotcha that cost me the most time: an element cannot query itself. If container-type lives 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?

:has() replaces the JavaScript that added 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 (or, with a combinator, a child or sibling) matching B exists.

/* the wrapper reacts to the input inside it */
.field:has(input:user-invalid) { --field-border: #c0392b; }

/* layout changes only when the card really has a media child */
.card:has(> img) { grid-template-rows: auto 1fr; }

/* page-level scroll lock with no event listener */
body:has(dialog[open]) { overflow: hidden; }
Enter fullscreen mode Exit fullscreen mode

That last rule removed a scroll-lock utility I had maintained for years — the one that stored window.scrollY, set position: fixed on the body, and restored it on close. :has() is live: it re-evaluates as [open], :checked, and :user-invalid change, so form and dialog state can drive layout with no listener at all.

Two rules worth internalizing. First, :has() takes the specificity of its most specific argument, so .card:has(#hero) carries an ID's weight — wrap the argument in :where() when you want it to stay cheap. Second, :has() cannot be nested inside another :has() and cannot contain pseudo-elements. On performance, the blanket ":has() is slow" advice is dated; the engines optimize it, and I keep the subject narrow — .card:has(> img), never *:has(img) — rather than avoiding it.

When do I 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. grid-template-rows: subgrid makes the child adopt the parent's row lines instead.

.cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; }

.card {
  display: grid;
  grid-row: span 3;              /* claim three parent rows */
  grid-template-rows: subgrid;   /* and adopt their lines */
}
Enter fullscreen mode Exit fullscreen mode

The two lines work together: grid-row: span 3 makes the card occupy three rows of the parent grid, and subgrid tells the card to use those three row lines for its own children. Every card's title sits on the same line, every footer sits on the same line, and no card needs a fixed height. The alternatives I used before were all worse — hard-coded heights, -webkit-line-clamp to force titles to one length, or a JavaScript pass that measured the tallest card and wrote a pixel height onto the rest.

subgrid is per-axis: you can subgrid rows while defining your own columns, or the reverse. Gaps are inherited from the parent grid unless the subgrid explicitly sets its own.

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. Rules in a later layer beat rules in an earlier layer regardless of specificity.

@layer reset, base, components, utilities;
@import url("vendor.css") layer(vendor);

@layer components {
  .nav .nav__link.is-active { color: var(--brand); } /* specificity 0,3,0 */
}

@layer utilities {
  .text-muted { color: var(--muted); }               /* 0,1,0 — still wins */
}
Enter fullscreen mode Exit fullscreen mode

The single most important rule about layers is the one that bites people: unlayered CSS beats every layer. One third-party stylesheet loaded outside a layer outranks your entire carefully ordered cascade, which is why the @import ... layer(vendor) line above matters. 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.

@scope is the companion feature: @scope (.card) to (.card__content) applies rules only between a root and a lower boundary, which is real component isolation without BEM-length 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?

JavaScript I deleted CSS that replaced it In every engine since
ResizeObserver + class toggle for component breakpoints container-type + @container Firefox 110 (Feb 2023)
Parent class toggles driven by child or form state :has() Firefox 121 (Dec 2023)
Measuring the tallest card and writing pixel heights grid-template-rows: subgrid Chrome 117 (Sept 2023)
Specificity hacks, !important chains, injection-order tricks @layer Safari 15.4 / Chrome 99 (2022)
Scroll-lock utility that saved and restored scroll position body:has(dialog[open]) { overflow: hidden } Firefox 121 (Dec 2023)

What is still not safe to ship in 2026?

CSS anchor positioning and keyword size interpolation are still Chromium-only, so I ship them only where losing them degrades cleanly. Anchor positioning — anchor-name, position-anchor, position-area — tethers a popover to its trigger without a JavaScript positioning library, and it has been in Chrome since version 125 without matching Safari and Firefox releases. interpolate-size: allow-keywords plus calc-size() finally animates height: auto, and it is in the same Chromium-only bucket.

@supports (anchor-name: --trigger) {
  .popover { position-anchor: --trigger; position-area: block-end span-inline-end; }
}
Enter fullscreen mode Exit fullscreen mode

For expanding panels I use the grid-fraction trick as the everywhere-baseline, and treat calc-size() as a bonus rather than the mechanism:

.accordion { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 200ms ease; }
.accordion[data-open] { grid-template-rows: 1fr; }
.accordion > .accordion__inner { overflow: hidden; }
Enter fullscreen mode Exit fullscreen mode

The rule I follow: if a feature changes information architecture — whether the user can read the content or complete the task — it has to be supported in all three engines. If it only changes polish, I gate it behind @supports and let older engines get the plain version. That line has kept me from shipping a popover that lands 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; media queries still own page-level and environment concerns the container cannot know about — 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; modern engines optimize it. Keep the subject narrow — prefer .card:has(> img) over *:has(img) — and profile style recalculation in DevTools if a specific page feels slow.

Q: Why doesn't my container query match anything?
A: Almost always because the element is trying to query itself. @container resolves against an ancestor that declares container-type, never against the element the rule styles. 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. grid-template-rows: subgrid adopts the parent's track lines, so siblings align with each other instead of each sizing itself.

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 with @import url("x.css") layer(vendor) if you need to override them.


Originally published on devya.dev. Also on eng-ahmed.com. Built by Devya Solutions.

Top comments (0)