6 UI Patterns Where CSS Replaces JavaScript (2026) — Bundle Savings by Browser Support Coverage
I'll admit it: until two years ago, my standard routine for building a tooltip was to install Popper.js via npm, attach requestAnimationFrame to a scroll event, and toggle classes inside an Intersection Observer callback. Then one day a colleague said, "You can do that with CSS," and I started looking at all the JS code I'd been stacking up ever since.
They were right. Removing Popper.js shaves roughly 10KB (gzipped) from your bundle, and ripping out ScrollMagic cuts 35KB more. The reason a progress bar runs smoother without a scroll event listener is that the browser handles it directly on the GPU. I've put together a code-backed walkthrough of which UI interactions modern CSS has absorbed and how to apply them in production — ordered by widest browser coverage and greatest replacement impact.
Core Concepts
"CSS for UI behavior, JS for business logic"
The core philosophy behind this shift is separation of concerns. JavaScript has historically owned the full spectrum of UI state management — scroll detection, tooltip positioning, page transition animations, form field resizing, and more. But between 2023 and 2026, new CSS specifications shipped in browsers have been gradually taking over that role.
Why are CSS animations smoother? CSS animations that operate on
transformandopacityare processed directly on the GPU. They don't touch the main thread where JavaScript runs, so even when heavy JS work is in progress, animations don't stutter. That's why hitting 60fps is so much easier.
The table below shows the support status of key features as of May 2026. This article covers examples in order of widest browser coverage and greatest replacement impact.
| CSS Feature | Chrome | Firefox | Safari | Browser Coverage |
|---|---|---|---|---|
:has() selector |
105+ | 121+ | 15.4+ | ~94% |
| Popover API | 114+ | 125+ | 17+ | ~91% |
| View Transitions API | 111+ | 144+ | 18+ | ~89% |
| Scroll-Driven Animations | 115+ | Not supported | Not supported | ~65% |
| CSS Anchor Positioning | 125+ | Not supported | Not supported | ~65% |
field-sizing: content |
123+ | Not supported | Not supported | ~65% |
field-sizing: content— Applied to atextareaorinput, it automatically adjusts the field height based on content. It replaces JavaScript'sscrollHeightcalculation with a single line of CSS. Since Firefox and Safari don't support it yet, it's recommended to use it with@supports.
Some features still have uneven browser support. The Interop 2026 project has made cross-browser compatibility for View Transitions, Scroll-Driven Animations, and Anchor Positioning its top priority, so the situation is likely to look quite different by the second half of 2026.
Practical Application
Example 1: Detecting Parent State — The :has() Selector (94% coverage)
/* Change entire form border when there's an error inside */
.form:has(.error-message) {
border: 2px solid #ef4444;
border-radius: 8px;
}
/* Highlight the associated label when a checkbox is checked */
.option-row:has(input[type="checkbox"]:checked) .label {
color: #6366f1;
font-weight: 600;
}
/* Enable the submit button when the form has no invalid inputs */
.form:not(:has(input:invalid)) .submit-button {
opacity: 1;
pointer-events: auto;
}These are patterns that previously required attaching JavaScript event listeners and toggling classes on parent elements based on conditions. :has() lets you express "if this element contains ~" as a CSS selector.
With 94% coverage, this is a feature you can ship to production right now. It comes up constantly in real-world work, and when I first shared it with my team, "wait, this is actually CSS?" was the most common reaction. Yes, it is.
Caution: Deeply nesting complex
:has()selectors at a global scope on large DOMs can degrade performance, because the browser has to recalculate the entire tree on every DOM change. Avoid chains likebody:has(.modal-open) .sidebar:has(.active-item) ul liand keep selector scope as narrow as possible.
Example 2: Popovers — Not a Single Line of JavaScript (91% coverage)
<button popovertarget="user-menu">Open Menu</button>
<div id="user-menu" popover role="menu">
<ul>
<li>Profile</li>
<li>Settings</li>
<li>Logout</li>
</ul>
</div>The single popover attribute handles all of the following automatically:
| Behavior | Description |
|---|---|
| Open/close toggle | The button connected via popovertarget handles it automatically |
| Close on ESC | Provided by the browser out of the box |
| Close on outside click | Light dismiss default behavior |
| Focus management | Moves focus when popover opens, returns to original position when it closes |
On the accessibility front, the popover attribute has the browser automatically handle light dismiss, ESC key, and focus return. That said, semantic attributes like role="menu" and aria-label still need to be added manually. If you've ever suffered through accessibility issues while building a custom modal or dropdown from scratch, you'll appreciate how much it means to have the browser handle this for you.
Example 3: Page Transition Animations — View Transitions (89% coverage)
View Transitions occupy a somewhat unique position. They require a JavaScript trigger, but the actual animation rendering is entirely CSS's domain — a JS+CSS collaboration pattern. JS tells the browser when the DOM is about to change; CSS dresses up the transition.
// What JS does: tell the browser when the DOM is changing
document.startViewTransition(() => {
document.querySelector('.content').innerHTML = newContent;
});/* The outgoing page */
::view-transition-old(root) {
animation: slide-out 300ms ease-in;
}
/* The incoming page */
::view-transition-new(root) {
animation: slide-in 300ms ease-out;
}
@keyframes slide-out {
to { transform: translateX(-100%); opacity: 0; }
}
@keyframes slide-in {
from { transform: translateX(100%); opacity: 0; }
}
::view-transition-oldand::view-transition-new— When a view transition starts, the browser captures the current screen as a snapshot and manages it asold, with the new screen managed asnew. Each can be animated independently using CSS pseudo-elements.
I was skeptical at first — "can this really replace all SPA transition logic?" — but it reached Baseline Newly Available status in October 2025, making it usable across Chrome, Edge, Firefox, and Safari. For teams managing thousands of lines of page transition logic in an SPA, this is a significant change.
Example 4: Scroll Progress Bar — Saying Goodbye to window.scroll Events (65% coverage)
This is the most common use case. We've all implemented a scroll progress bar at the top of a blog post at some point. The old approach looked like this:
// Touches the DOM on the main thread on every scroll
window.addEventListener('scroll', () => {
const scrollTop = document.documentElement.scrollTop;
const scrollHeight = document.documentElement.scrollHeight - window.innerHeight;
const progress = (scrollTop / scrollHeight) * 100;
document.querySelector('.progress-bar').style.width = `${progress}%`;
});On heavy pages this causes jank, and wrapping it in requestAnimationFrame only goes so far. Here's how you can do it now:
@keyframes grow-bar {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
.progress-bar {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 4px;
background: #6366f1;
transform-origin: left;
/* Omitting animation-duration is correct —
in Scroll-Driven Animations, scroll position itself replaces the timeline */
animation: grow-bar linear;
animation-timeline: scroll(root);
}| Code element | Role |
|---|---|
animation-timeline: scroll(root) |
Uses the root (full page) scroll position as the timeline |
animation: grow-bar linear |
Animation progresses linearly from 0% to 100% scroll |
transform-origin: left |
Fixes the scaleX transform origin to the left |
Zero lines of JavaScript, running smoothly on the GPU.
Browser support warning: Chrome 115+ only (as of May 2026). Firefox and Safari are not yet supported, so it's recommended to write this inside an
@supports (animation-timeline: scroll()) { ... }block with a JS fallback in place.
Example 5: Scroll-Triggered Element Entrance — Instead of Intersection Observer (65% coverage)
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(24px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card {
/* both: maintains the `from` state before the animation starts and the `to` state after it ends */
animation: fade-in-up linear both;
animation-timeline: view();
animation-range: entry 0% entry 30%;
}animation-timeline: view() uses the element's entry and exit from the viewport as its timeline. To unpack animation-range: entry 0% entry 30%:
entry 0%: the moment the very bottom edge of the element first touches the bottom of the viewportentry 30%: the moment 30% of the element has entered the viewport
The animation completes between these two points. When I first posted this in a team code review, two separate comments asked "this really has no JS?" If you want to dial in the timing by tweaking the values in real time, the Scroll-Driven Animations demo site is excellent — getting it right visually is much faster than guessing.
Browser support warning: Same as Example 4 — Chrome 115+ only. In unsupported browsers, make sure to set a fallback so elements are visible from the start.
Example 6: Tooltip Positioning — Parting Ways with Popper.js (65% coverage)
Even I thought at first, "wait, CSS can really do that?" Tooltip positioning has always felt like it requires a library like Floating UI or Popper.js, since you need to account for scroll, viewport boundaries, overflow, and more.
/* 1. Give the anchor element an anchor name */
.anchor-button {
anchor-name: --my-btn;
}
/* 2. Position the tooltip relative to the anchor */
.tooltip {
position: absolute;
position-anchor: --my-btn;
top: anchor(bottom);
left: anchor(center);
translate: -50% 8px;
/* Automatically flip above if there's no room below */
position-try-fallbacks: --flip-up;
}
@position-try --flip-up {
top: auto;
bottom: anchor(top);
translate: -50% -8px;
}<button class="anchor-button">Save</button>
<div class="tooltip" role="tooltip">Saves your changes</div>The anchor() function references a specific edge of the anchor element (top, bottom, left, right, center). "Can it handle viewport boundaries like Popper.js?" is the obvious question — and position-try-fallbacks is exactly what addresses that. When there isn't enough space below the viewport, the browser automatically tries the --flip-up position. This CSS block replaces what Popper.js handled with 200+ lines of JS.
Browser support warning: Chrome 125+ only. Firefox and Safari are not yet supported, so it's best to start applying this to internal tools or admin pages that are primarily Chrome/Edge. This is an Interop 2026 target feature, so the situation is likely to change in the second half of this year.
Pros and Cons
Of course, it wouldn't be fair to paint an entirely rosy picture. Here's an honest summary of issues I've personally run into or that have come up on my team.
Advantages
| Item | Details |
|---|---|
| Performance | CSS animations based on transform and opacity run on the GPU with no main thread blocking, making 60fps sustainable |
| Code reduction | 150+ lines of JS being replaced by a handful of CSS lines is genuinely common |
| Built-in accessibility | The Popover API has the browser automatically handle focus return, ESC key, and light dismiss |
| Bundle size savings | ~10KB gzip savings from removing Popper.js, ~35KB gzip from removing ScrollMagic |
| Maintainability | Centralizing UI state in CSS noticeably reduces the complexity of JS code |
Disadvantages and Caveats
| Item | Details | Mitigation |
|---|---|---|
| Browser support gap | Scroll-Driven Animations and Anchor Positioning are unsupported in Firefox and Safari (as of May 2026) | Use @supports for feature detection and maintain a JS fallback |
:has() performance trap |
Overusing complex :has() selectors in large DOMs increases layout recalculation cost |
Keep selector scope narrow; verify layout cost with DevTools |
| View Transitions LCP impact | Reports of +70ms LCP increase on mobile | Keep transition duration under 200ms; apply prefers-reduced-motion |
| JS cannot be fully replaced | Data fetching, business logic, and complex state management remain JS territory | Keep roles clearly separated — CSS for presentation, JS for logic |
| Learning curve | New CSS specs are shipping rapidly, requiring ongoing effort to stay current | Develop a habit of periodically checking Baseline indicators and Can I Use |
Using
@supports— Write@supports (animation-timeline: scroll()) { /* supported case */ }and browsers that don't support it will simply ignore the block. Always prepare a fallback so the base UI doesn't break in unsupported browsers.
prefers-reduced-motion— For users who have requested reduced motion in their system settings, minimizing or removing animations is an accessibility principle. Apply it with@media (prefers-reduced-motion: reduce) { .card { animation: none; } }.
The Most Common Real-World Mistakes
-
Shipping to production without
@supports— Scroll-Driven Animations and Anchor Positioning don't work in Firefox or Safari. Deploying without a fallback can completely break the UI for certain users. -
Deeply nesting
:has()selectors at a global scope — Chains likebody:has(.modal-open) .sidebar:has(.active-item) ul liforce the browser to recalculate everything on every DOM change. Keep selector scope narrow and apply it only where needed. -
Indiscriminately applying View Transitions to every screen change — The longer and more complex the transition effect, the more it hurts LCP on mobile. For pages on the critical path, keep the duration under 200ms and always handle
prefers-reduced-motion.
Wrapping Up
The center of gravity in frontend development is shifting — modern CSS takes ownership of UI "motion," while JavaScript focuses on "data and logic." There's no need to change everything at once. If you see packages like Popper.js, ScrollMagic, or GSAP in your project's package.json, it's worth exploring whether the corresponding examples in this article could serve as replacements. Removing Popper.js alone saves roughly 10KB, and removing ScrollMagic saves roughly 35KB from your bundle.
Three steps you can start on right now:
-
Start with the widest-supported features — The
:has()selector has 94% coverage and can be used in production today. If your codebase has patterns where event listeners toggle classes on parent elements, try replacing them with:has(). -
Consider the Popover API first when writing new components — Next time you build a tooltip, dropdown, or modal from scratch, start with the
popoverattribute. Having the browser handle accessibility for you is a surprisingly satisfying experience. With 91% coverage, it's safe to use in most production environments. -
Check out Scroll-Driven Animations on the demo site before writing any code — Scroll-based animations are hard to appreciate from code alone. Interact with the demos directly, inspect the timeline in Chrome DevTools' animation panel, and you'll pick it up much faster.
References
Official Documentation
Further Reading
- What's New in View Transitions (2025 Update) | Chrome for Developers
- Scroll-Triggered Animations Coming | Chrome for Developers
- CSS in 2026 — New Features Reshaping Frontend Development | LogRocket
- What You Need to Know About Modern CSS (2025 Edition) | Frontend Masters
- Menus, Toasts and More with Popover API & Anchor Positioning | Frontend Masters
- Replace JavaScript Animations with View Transitions | Builder.io
- Anchor Positioning and Popover API for a JS-Free Site Menu | CSS-IRL
- 10 CSS Features That Replace JavaScript (2026) | BigDevSoon
- Scroll-Driven Animations | Josh W. Comeau
- Announcing Interop 2026 | WebKit
Practice Tools
- Scroll-Driven Animations Demo Site
- Can I Use — Check CSS feature support status by browser