I once wrote a small pile of JavaScript to make a card component responsive. Not to the viewport — to itself. The same card appeared in a wide main column and a narrow sidebar, and it needed to switch from a stacked layout to a side-by-side one based on how much room it personally had. Media queries couldn't help, because they only know about the viewport, not the box. So I reached for a ResizeObserver, measured the card, and toggled a class when it crossed a width. It worked, and it meant every instance of that card now carried a little observer and a little state, forever, to answer a question that was really about layout.
That script is gone now. So is the click-outside handler I wrote for a dropdown, and the watcher that added an is-invalid class to a form row when the input inside it went bad. Each of them got replaced, not by a better script, but by a platform feature that didn't exist when I wrote the original. The interesting part isn't that the platform caught up. It's how much glue code turned out to be standing in for things CSS and HTML can now say directly.
Layout that responds to its container
The card problem is a container query. You declare an element a query container by giving it a container-type, and then you write size conditions against that container instead of the viewport:
.card-host {
container-type: inline-size;
}
@container (min-width: 30rem) {
.card {
display: grid;
grid-template-columns: auto 1fr;
}
}
That's the entire ResizeObserver replaced. The card is now genuinely context-independent: drop it in the wide column and it goes side-by-side, drop it in the sidebar and it stacks, and there's no observer, no class, no per-instance state doing the bookkeeping. Container queries have been Baseline — supported across all major browsers — since early 2023, so for most audiences this is simply the way to do it now. The one thing to keep straight is that the well-supported part is size queries; the newer style and scroll-state container queries haven't reached the same level, so don't assume the whole family ships together.
State reflected without a watcher
The form-row problem is :has(), the relational selector that finally lets an element be styled by what it contains. The class I used to toggle from JavaScript when an input became invalid is now a rule that reads the input's own state:
.field:has(input:user-invalid) {
border-color: var(--color-danger);
}
No listener on the input, no code path that adds and removes a class, no chance of the class going stale. The dependency — "this row cares whether its input is valid" — is expressed once, declaratively, and the browser keeps it true. :has() reached Baseline across browsers at the end of 2023, which makes it the newest of the widely-supported features here, and the one I'd double-check against a very old support target. But as of today it's solid, and it quietly retires a whole genre of parent-state glue code.
Overlays the platform now owns
The dropdown is the Popover API. A native popover shows and hides declaratively, renders in the top layer above everything else, and comes with light-dismiss — click outside or press Escape and it closes — built in. The markup is a button that points at an element with the popover attribute, and there is no script at all:
<button popovertarget="menu">Actions</button>
<div id="menu" popover>
<!-- menu content -->
</div>
That deletes the click-outside handler, the Escape listener, the z-index juggling, and the show/hide toggle in one move. This is the feature to be careful about, though, and I'll be precise rather than enthusiastic: the Popover API only became Baseline "newly available" in early 2025, meaning it crossed all three engines recently rather than years ago. It works in current browsers, but older ones in the wild still lack it, so unlike container queries and :has() this one genuinely warrants checking your support floor and offering a fallback. Two related things are further out still: hover-triggered "interest" invokers are experimental and effectively one-engine, and anchoring the popover to its trigger is a separate feature — CSS anchor positioning — with weaker support than the popover itself. Native show/hide is ready; native tethered placement isn't, quite.
Where the script still belongs
None of this means the platform has absorbed JavaScript's job. It means a specific slice of it — declarative layout and declarative state reflection — moved to where it always belonged. The line I draw now is about computation and orchestration. If I need an actual measured value to do math with, ResizeObserver is still the tool, because a container query reacts to a size band but won't hand me the pixel number. If state has to travel across routes or components or reflect something the server said, that's application state, and :has() reacting to one DOM subtree is no substitute. Rich keyboard interaction beyond a popover's dismiss — a roving-tabindex menubar, typeahead, an arrow-key grid — is still script and ARIA. And anywhere the support floor sits below a feature's Baseline, progressive enhancement or a library is the honest choice, which today applies to the Popover API far more than to the CSS features around it.
Cascade layers and subgrid belong to the same story, on the settled end of it. @layer lets you order the cascade explicitly instead of fighting specificity or reaching for !important, and subgrid lets a nested grid borrow its parent's tracks so cards line up across a row without a script measuring them — both Baseline and widely available for a couple of years now.
Reach for the Platform First
What changed in my work wasn't that I learned five features. It's the order I check things in. The old habit was to solve a UI behavior in JavaScript because that's where behavior lived, and CSS was for paint. Now the first question is whether the platform can state the thing directly — is this really a layout response, a containment relationship, an overlay — and I only write the script when the answer is genuinely no. Most of the time the answer is a few lines of CSS and a component that carries less code than it used to. The JavaScript I stopped writing wasn't bad code. It was code standing in for a sentence the platform can now say on its own.
You can see each of these running with its source and its support status beside it — container queries, :has(), the Popover API, and subgrid — on the web-platform demo.


