WebTech1223411 logo WebTech1223411Web tech, read closely
Front-End

What Modern CSS Can Do Now That Used to Need JavaScript

Five years ago, a front-end ticket that said "make the card layout change when the sidebar opens" meant a ResizeObserver, a class toggle and a small pile of state. Today it is about six lines of CSS.

Abstract illustration for What Modern CSS Can Do Now That Used to Need JavaScript

Five years ago, a front-end ticket that said "make the card layout change when the sidebar opens" meant a ResizeObserver, a class toggle and a small pile of state. Today it is about six lines of CSS. That shift has happened quietly across dozens of patterns, and a lot of codebases still carry JavaScript written for problems the stylesheet now solves on its own.

This is not an argument that JavaScript is bad. It is an argument for checking, before you reach for a script, whether the browser already ships the behaviour you are about to rebuild. Usually the native version is faster, survives slow networks better and is easier for the next person to read.

Container queries replaced the resize listener

Media queries answer one question: how wide is the viewport? Components rarely care about that. A product card in a three-column grid and the same card in a narrow sidebar sit inside the same viewport, yet need completely different layouts. For years the fix was JavaScript that measured the parent and added a class like is-narrow.

Container queries let the component ask about its own container instead. You mark the wrapper with container-type: inline-size, then write @container (min-width: 420px) rules on the children. The card rearranges itself wherever it lands. No observer, no layout thrash from reading widths in a loop, no flash of the wrong layout while the script loads.

On a dashboard I rebuilt last spring, removing the measuring script cut about 4 KB of JavaScript and, more importantly, removed a visible jump on first paint. The CSS version is correct before any script has even been downloaded.

The :has() selector ended a lot of class juggling

CSS could always style a child based on its parent. It could not do the reverse. So whenever a form field was invalid and you wanted its whole row highlighted, or a card contained an image and needed different padding, you wrote JavaScript to add a class to the parent.

:has() removes that step. .field:has(input:invalid) styles the row when its input fails validation. .card:has(img) adjusts cards that contain images. body:has(dialog[open]) can stop the page behind a modal from scrolling. Each of those used to be an event listener that someone had to remember to clean up.

The selector is supported in every current major browser, and it is fast enough for normal use. Where it gets expensive is very broad patterns on huge documents, such as :has() on *. Scope it to a component and you will not notice it.

Native dialogs, popovers and details elements

Modals are the classic example of JavaScript doing work the platform now does better. A homemade modal needs focus trapping, an Escape key handler, a backdrop, scroll locking and a way to return focus to the button that opened it. Most homemade modals get at least one of those wrong.

The <dialog> element opened with showModal() handles the backdrop, Escape and the inert background for you. The popover attribute goes further: a button with popovertarget opens and closes a menu with no script at all, with light dismiss when you click outside. For simple disclosure widgets, <details> and <summary> still beat any accordion library on accessibility. If you care about how this plays out for keyboard users, the article on keyboard navigation as an accessibility test shows why native elements matter so much.

Scroll snap, smooth scroll and scroll-driven animation

Carousels were once the heaviest thing on many landing pages. Scroll snap turns an overflowing flex row into a carousel with two properties: scroll-snap-type: x mandatory on the track and scroll-snap-align: start on each slide. Touch, trackpad and keyboard scrolling all work because it is still just scrolling.

Scroll-driven animations, now shipping in Chromium and arriving elsewhere, let a reading progress bar or a fade-in effect follow scroll position through animation-timeline: scroll(). The old version listened to every scroll event on the main thread. The CSS version can run on the compositor, which is the difference between smooth and janky on a mid-range phone.

Where the "use CSS for everything" advice goes wrong

There is a popular line online that you should never use JavaScript for anything CSS can do. I disagree with the absolute version of that rule, for three reasons.

First, browser support is uneven at the edges. Scroll-driven animation is a good example: if it is decorative, ship it and let older browsers skip it. If it carries meaning, you need a fallback, and sometimes the fallback is a script.

Second, clever CSS can be harder to maintain than plain JavaScript. A checkbox hack that drives a whole menu through sibling selectors is technically script-free, but nobody on your team will understand it in six months, and screen readers will announce a checkbox that is not really a checkbox.

Third, state that has to persist or be shared belongs in code. CSS can show a menu as open. It cannot remember that the user closed the cookie banner yesterday or sync a filter with the URL. The useful rule is narrower: use CSS for presentation and simple interaction, use JavaScript for state and data.

A quick audit you can run this week

Search your codebase for a few patterns. Each one is a candidate for deletion:

  • ResizeObserver or window resize listeners that only add or remove classes. Try container queries.
  • Code that adds a class to a parent based on a child state. Try :has().
  • Custom modal or dropdown components. Compare them against <dialog> and popover.
  • Scroll listeners that drive progress bars or reveal effects. Try scroll-driven animation with a static fallback.
  • Carousel libraries on pages that only need swipeable rows. Try scroll snap.

Do not rip everything out at once. Replace one pattern, measure the bundle size and the interaction timings, and check it with a keyboard and a screen reader. Browser DevTools make this easier than most people realise, and our piece on DevTools features developers never open covers the coverage panel that shows exactly how much of your script is unused.

Why this matters beyond bundle size

Smaller bundles are the obvious win, but the bigger one is resilience. CSS applies the moment the stylesheet arrives. JavaScript has to download, parse, execute and attach listeners, and any of those can fail on a train with one bar of signal. A layout that depends on a script is broken until that script runs. A layout written in CSS is correct from the first paint, which is also why it helps your Largest Contentful Paint and layout shift scores.

There is a maintenance argument too. Native features are documented, tested by browser vendors across millions of sites and fixed when they break. Your custom dropdown is tested by you. Every time you swap a homemade component for a platform feature, you hand a bit of your maintenance burden to people with much bigger test suites.

If you want to see how this fits into the larger picture of how pages are loaded and painted, start with what happens between typing a URL and seeing a page. Understanding the rendering pipeline makes it obvious why work done in CSS is so often cheaper than the same work done in script.

The front-end platform moves faster than most of our habits. Every year or so, it is worth going back through a project and asking which of your scripts are now solving problems the browser solved for you. More of them than you expect will turn out to be dead weight. You can find more on this in our Front-End section.

LV
Lorenzo Vidal

Lorenzo started as a visual designer and moved into CSS after watching too many of his layouts fall apart on phones. He writes about front-end craft, interfaces and accessibility, and still tests everything with the keyboard first.

More posts by Lorenzo

More from the blog