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.

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>andpopover. - 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.
More from the blog
Front-End
Explore How Link in Bio Pages Serve Online Gaming Communities
Most social platforms give you exactly one clickable link in your profile. For an online gaming community, that is a problem.
Browsers
Why Online Game Players Should Keep Their Browser Up to Date
Browser update prompts are easy to ignore. The little arrow or the "relaunch to update" button sits in the corner for days...
Back-End
How Online Game Platforms Scale Their Servers on Busy Nights
Friday evening, 8 p.m. local time. A new season of an online game goes live, a popular streamer starts playing, and within ten...