Pick a reasonably featured web app and audit its node_modules. Somewhere in there you will find Floating UI or Popper keeping a tooltip anchored to a button. A Radix or Headless UI package managing a modal's focus trap. GSAP ScrollTrigger wiring scroll position to an animation. React Select rebuilding a <select> from scratch because the native one cannot be styled. Before you have written a single line of product code, you are shipping hundreds of kilobytes of minified, gzipped JavaScript just to handle UI patterns the browser always should have covered.
And that weight comes with hidden costs beyond kilobytes. These libraries are written for the general case. Your use case is specific. Sooner or later you hit the edge they didn't design for: a tooltip that needs to flip across two axes, a modal that breaks inside a shadow DOM, a scroll animation that fights the browser's compositor, a select that doesn't integrate cleanly with your form library. You file the GitHub issue. You write the workaround. You pin the version and hope the next major doesn't break it again.
Web development follows a familiar cycle. First we glue together a solution with whatever we have — JavaScript, image hacks, Flash, anything. Then the platform matures, and CSS or HTML eventually makes that same workaround native. Rounded corners, custom fonts, smooth scrolling, sticky positioning: all of these started as JavaScript-heavy hacks before CSS turned them into a single declaration.
We are in another one of those transition moments. A new wave of long-requested CSS features is finally landing, and many of them are explicitly designed to replace patterns that used to require JavaScript. Not as approximations — as first-class platform primitives that handle the edge cases, run in the right thread, and need zero dependencies.
This is a tour of what is shipping, what it replaces, how much JavaScript you can delete, and what the platform still has not figured out.
*The ~322 kB figure is the sum of 16 specific libraries measured from their minified, gzipped bundles on Bundlephobia. The full table is at the end.
Anchor Positioning
For as long as tooltips, dropdowns, popovers, and floating menus have existed on the web, JavaScript has been responsible for keeping them attached to their trigger elements. You either reached for Popper.js, Floating UI, or wrote your own getBoundingClientRect loop. The browser had no concept of "keep this element near that one."
| Library | Size (min+gz) | Why it's painful |
|---|---|---|
| @floating-ui/dom | 8.1 kB | Collision detection (flip, shift, autoPlacement) needs careful tuning per use case — the library has no knowledge of your overflow context or scroll containers |
| @popperjs/core | 14.1 kB | Older API, same fundamental problem: pure JavaScript geometry that has to re-run on every scroll and resize event |
| tippy.js | 14.1 kB | Built on top of @popperjs/core, so you pay for both; opinionated theming system that fights your design tokens the moment you deviate from its defaults |
CSS Anchor Positioning changes this fundamentally. You declare one element as an anchor with anchor-name and then position another element relative to it using the anchor() function and position-try. The browser handles the math, and it handles overflow too — if the tooltip would clip off the bottom of the viewport, you can define fallback positions and the browser tries them in order.
.button {
anchor-name: --my-button;
}
.tooltip {
position: absolute;
position-anchor: --my-button;
top: anchor(bottom);
left: anchor(left);
}
No JavaScript. No resize observers. No scroll listeners. The browser keeps the floating element anchored across scroll, resize, and layout shifts automatically.
Chrome shipped a comprehensive implementation in 2024 (Chrome 125), and Firefox and Safari have partial implementations in progress. Some individual properties like anchor-name and the basic anchor() function already work across all modern browsers — it's the more advanced features like position-try and position-visibility that are still rolling out. The status widget below shows full baseline coverage.
And even if baseline support is still in progress, the demo below should work in most latest browsers, so you can play with it right now.
Demos and Resources
- Position-anchor on MDN with syntax, examples, and browser compatibility.
- Introducing the CSS anchor positioning API by Una Kravets
- Anchor positioning API with many demos and explanations on the web.dev Learn pages.
- And a very nice tour of the API and its capabilities in Basics of the CSS Anchor Positioning by Ahmad Shadeed.
Popover API
Anchor Positioning and the Popover API solve different halves of the same problem. Anchor Positioning handles where a floating element appears. The Popover API handles whether it appears — toggling visibility, light-dismiss, keyboard behavior, and accessibility. A complete tooltip needs both.
| Library | Size (min+gz) | Why it's painful |
|---|---|---|
| @radix-ui/react-popover | 19.6 kB | Requires a Provider, owns its own portal rendering, and forces you through its compound component API even for a simple tooltip |
| @headlessui/react (full) | 59.1 kB | Not tree-shakeable by component — you pay for the whole package; fighting the abstraction is common when your use case doesn't fit its model |
Before the Popover API, building accessible popovers, menus, and non-modal overlays meant managing focus traps, aria-expanded state, keyboard events, and click-outside detection — all in JavaScript. Libraries like Headless UI and Radix exist largely because this is so tedious to get right.
The HTML popover attribute and its companion popovertarget give you a native, accessible popover with a single attribute. The browser handles:
- Show/hide toggling
- Light dismiss (click outside closes it)
- Keyboard dismissal with Escape
::backdropfor visual overlay- Focus management
- The top layer — popovers always render above other content without z-index wrestling
<button popovertarget="menu">Open menu</button>
<div id="menu" popover>
<p>I am a popover</p>
</div>
This is already baseline across Chrome, Firefox, and Safari. For many use cases, it replaces the entire interaction model that modal/dropdown libraries have been solving.
The Popover API works well for tooltips, dropdown menus, context menus, notification toasts, teaching callouts, and any non-modal overlay that should dismiss when the user clicks away. It is not a replacement for modal dialogs — those belong to the <dialog> element covered in the next section.
Demos and Resources
- Popover attribute on MDN with syntax, examples, and browser compatibility.
- Popover and dialog elements on the web.dev Learn pages.
- Introducing the Popover API by Una Kravets
- Poppin' In by Geoff Graham, with a great demo and explanation of the API.
- Open UI and the Popover API by Brecht De Ruyte
Dialog Element
Where the Popover API is for non-blocking overlays that dismiss when you click away, <dialog> is for true modal experiences — the kind that demand attention before anything else can happen. The standard JavaScript approach involved a backdrop div, a positioned container, manually setting aria-modal, trapping focus inside, restoring focus on close, and preventing scroll on the body.
| Library | Size (min+gz) | Why it's painful |
|---|---|---|
| @radix-ui/react-dialog | 10.6 kB | Ships its own focus management and portal logic on top of focus-trap |
| focus-trap (includes tabbable) | 7.4 kB | Manually enumerates focusable elements and intercepts Tab events — known bugs with iframes and shadow DOM that the browser's native <dialog> handles correctly |
The native <dialog> element handles all of this. Combined with the ::backdrop pseudo-element and the .showModal() method, you get a fully accessible, focus-trapping modal with one element and about three lines of JavaScript to open it.
<dialog id="my-dialog">
<p>This is a proper modal</p>
<button onclick="this.closest('dialog').close()">Close</button>
</dialog>
document.getElementById('my-dialog').showModal();
The "modal trap" part — where Tab cycles only inside the dialog and Escape closes it — is handled by the browser. Baseline support since 2022.
Demos and Resources
- Dialog element on MDN with syntax, examples, and browser compatibility.
- Dialog element on the web.dev Learn pages.
- The HTML Dialog Element: Your Native Solution for Accessible Modals and Popups by Ilham Bouktir.
- How to Open and Close HTML Dialogs by Aleksandr Hovhannisyan.
Popover vs. Dialog: What's the Difference?
The key difference comes down to whether the rest of the page stays interactive:
| Popover API | Dialog Element | |
|---|---|---|
| Blocks background interaction | No | Yes |
| Light dismiss (click outside) | Yes | No |
| Focus trap | No | Yes |
| Scroll lock | No | Yes |
| Use for | Tooltips, menus, toasts | Confirmations, forms, alerts |
| API | Declarative HTML only | Requires JS (.showModal()) |
The control model reflects this split. A popover needs no JavaScript at all — popovertarget wires the button to the panel entirely in HTML. A dialog opened with .show() is non-modal and mostly useless; the real behavior comes from .showModal(), which is a JavaScript call. You can close both with Escape, but only the dialog traps focus and prevents interaction with the rest of the page until it is dismissed.
Scroll-Driven Animations
Scroll-linked animations used to mean one thing: a JavaScript scroll listener, often requestAnimationFrame, tracking window.scrollY and updating CSS custom properties or transform values on every frame. At scale this becomes a performance problem. Libraries like GSAP ScrollTrigger exist largely to make this pattern manageable.
| Library | Size (min+gz) | Why it's painful |
|---|---|---|
| gsap core | 26.6 kB | Required even if you only want ScrollTrigger; adds significant weight for projects that only need scroll-triggered animations |
| gsap/ScrollTrigger | 18.3 kB | Runs on the main thread — scroll animations compete with everything else JS is doing; start/end/scrub timing requires browser iteration to get right |
| motion (formerly framer-motion) | 57.4 kB | useScroll and useTransform are popular for scroll-linked effects, but they run in JavaScript and re-render on every scroll tick — exactly what CSS scroll-driven animations eliminate |
The CSS Scroll-Driven Animations spec introduces animation-timeline: scroll() and animation-timeline: view(). You attach a CSS animation to scroll progress directly — no JavaScript involved.
@keyframes fade-in {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.card {
animation: fade-in linear;
animation-timeline: view();
animation-range: entry 0% entry 30%;
}
The browser runs this on the compositor thread, which means it does not block JavaScript and does not stutter under heavy load. Chrome has shipped this. Firefox is in progress. For production use, the JavaScript fallback is straightforward since the animation is purely decorative.
Demos and Resources
- CSS scroll-driven animations on MDN with syntax, examples, and browser compatibility.
- animation-timeline on MDN with syntax and examples.
- A Practical Introduction to Scroll-Driven Animations with CSS scroll() and view() by Adam Argyle
- Scroll-driven animations case studies by Swetha Gopalakrishnan and Saurabh Rajpal
View Transitions
Single-page application transitions — the kind where navigating between routes animates the old page out and the new page in — have always required JavaScript. React has react-transition-group, Vue has its <Transition> component, and custom solutions involve cloning elements, absolute positioning, and coordinating opacity and transform timings manually.
| Library | Size (min+gz) | Why it's painful |
|---|---|---|
| motion (formerly framer-motion) | 57.4 kB | React-only, introduces hydration complexity in SSR apps, and brings its own animation engine even when you only want page transitions |
| react-transition-group | 4.0 kB | Only provides CSS class toggling — cross-route element morphing still requires manual DOM cloning on top |
The View Transitions API lets the browser handle this. You wrap a state change in document.startViewTransition() and the browser automatically captures the before and after states, cross-fades them, and lets you customize the transition with CSS.
document.startViewTransition(() => {
updateTheDom();
});
For same-document transitions that's the entire API surface. For cross-document navigation (actual page loads), you can enable it with a single line of CSS:
@view-transition {
navigation: auto;
}
Named view transitions let you animate specific elements between pages — like a card expanding into a detail page — without any layout cloning tricks. Chrome shipped this. The Safari and Firefox roadmaps are active.
Demos and Resources
- View Transition API on MDN
- Smooth transitions with the View Transition API by Bramus
- Some practical examples of view transitions to elevate your UI by Declan Chidlow
- Some nice lightbox example in Dialog view transitions by Thomas Günther
Select Customization
Custom select dropdowns are one of the most duplicated pieces of UI on the web. Because the native <select> element was unstyable, every design system built its own from scratch: a hidden native select for accessibility, a custom div acting as the visible trigger, a positioned list of options, keyboard navigation, screen reader announcements. It is thousands of lines of code to replace a form element.
| Library | Size (min+gz) | Why it's painful |
|---|---|---|
| react-select | 29.1 kB | Notoriously hard to style to a design system — styles and classNames APIs are deep and teams routinely end up with hundreds of lines of overrides |
| @radix-ui/react-select | 23.7 kB | Does not integrate with native <form> submission without extra hidden inputs; ships its own keyboard nav, ARIA management, and dropdown positioning |
| downshift | 14.3 kB | Low-level enough to be flexible, but you still have to wire up every behaviour yourself — positioning, styling, and ARIA state management are all your problem |
The Customizable Select proposal (formerly "Selectlist") gives you full CSS control over <select> and its parts. You can style the button, the dropdown container, and individual <option> elements. You can even put arbitrary HTML inside options.
select {
appearance: base-select;
}
select::picker(select) {
border: 1px solid #ccc;
border-radius: 8px;
}
This is the feature the web has needed for over a decade. Chrome shipped an initial implementation behind a flag. The spec is still evolving but the direction is clear.
Demos and Resources
- Customizable select elements on MDN with syntax, examples, and browser compatibility.
- Abusing Customizable Selects by Patrick Brosset
- The
<select>element can now be customized with CSS by Adam Argyle - A fun demo by Temani Afif
Smaller Things Worth Noting
Focus Group
Arrow-key navigation inside composite widgets — toolbars, tab lists, radio groups, menus — has always meant writing the same boilerplate in JavaScript: attach keydown listeners, check ArrowRight/ArrowLeft/ArrowUp/ArrowDown, update tabindex manually, remember the last-focused element when the user tabs back in. Every UI library ships its own version of this. React has roving-tabindex patterns, Angular CDK has ListKeyManager, Fluent UI has FocusZone.
The focusgroup HTML attribute, proposed by Open UI, makes this declarative. Add it to a container and the browser handles arrow-key navigation among its focusable children automatically — no JavaScript required.
<div role="toolbar" focusgroup aria-label="Text Formatting">
<button type="button" tabindex="-1">Bold</button>
<button type="button" tabindex="-1">Italic</button>
<button type="button" tabindex="-1">Underline</button>
</div>
The attribute takes optional values to tune behavior: inline or block to restrict navigation to one axis, wrap to loop around, and no-memory to always return focus to the first item rather than the last-focused one.
<!-- Tab list: left/right only, wraps, no memory so selected tab gets focus on re-entry -->
<div role="tablist" focusgroup="inline wrap no-memory">
<button role="tab" tabindex="0" aria-selected="true">Mac</button>
<button role="tab" tabindex="-1" aria-selected="false">Windows</button>
<button role="tab" tabindex="-1" aria-selected="false">Linux</button>
</div>
Nested focusgroups work too — a horizontal menubar and its vertical submenus each get their own axis, so orthogonal arrow keys stay available for opening/closing menus.
The focusgroup attribute is currently a non-active Open UI proposal — no browser has shipped it yet — but the Scoped Focusgroup Explainer is an actively narrowed version being pushed toward implementation. The pattern it replaces is ubiquitous enough that this one feels inevitable.
Masonry Layout (Now Grid Lanes)
| Library | Size (min+gz) | Why it's painful |
|---|---|---|
| masonry-layout | 6.7 kB | Measures heights after paint and applies absolute positioning — you must call .layout() manually after every DOM change, image load, or resize; CSS transitions break without extra workarounds |
| isotope-layout | 9.1 kB | Adds filter and sort on top of masonry-layout (which it depends on), runs entirely on the main thread, and has the same post-paint reflow problem |
Pinterest-style masonry grids — items packed into columns with varying heights — have been a JavaScript layout problem since forever. Masonry.js and Isotope are still widely used because CSS Grid cannot do this natively: items always start from a strict row line.
The CSS Masonry proposal adds grid-template-rows: masonry (and the column variant). The browser handles item placement to fill gaps, no JavaScript needed.
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: masonry;
}
Firefox has had an experimental implementation behind a flag for years. The spec debate between a grid-based approach versus a standalone display: masonry property has slowed things down, but both Chrome and Firefox are actively working toward shipping something. The proposal has since been renamed to Grid Lanes, reflecting the decision to keep it within the CSS Grid specification rather than introduce a separate display type.
Demos and Resources
- Introducing CSS Grid Lanes by Jen Simmons, Elika Etemad, and Brandon Stewart
- Masonry Layout is Now grid-lanes by Sunkanmi Fafowora
Field Sizing
field-sizing: content makes <textarea> and <input> elements automatically grow to fit their content. That auto-expanding textarea you implemented with a JavaScript input listener and scrollHeight manipulation? Gone.
textarea {
field-sizing: content;
}
Chrome shipped this in 2024. A small thing, but deeply satisfying.
Demos and Resources
- Some nice nice breakdown of the feature by Geoff Graham
- field-sizing on MDN
Scroll State Queries
CSS scroll-state queries, shipping in Chrome 133, extend the container query model to expose three pieces of browser-managed scroll state directly to CSS — no JavaScript required.
Stuck — detecting whether a position: sticky element is currently stuck used to require a sentinel element watched by IntersectionObserver. Now:
.sticky-header {
container-type: scroll-state;
position: sticky;
top: 0;
> nav {
@container scroll-state(stuck: top) {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
}
}
Snapped — knowing which item is the currently snapped element in a scroll snap container used to mean listening to scrollsnapchange events in JavaScript and toggling classes. Now the snapped item can style itself and its children purely from CSS:
.carousel-item {
container-type: scroll-state;
scroll-snap-align: center;
> * {
@container not scroll-state(snapped: x) {
opacity: 0.25;
}
}
}
Scrollable — showing scroll shadows or "scroll to top" buttons only when there is actually content to scroll to required JavaScript scroll listeners or IntersectionObserver tricks. Now:
.scroll-container {
container-type: scroll-state;
overflow: auto;
> .back-to-top {
@container not scroll-state(scrollable: top) {
display: none;
}
}
}
All three replace patterns that previously needed JavaScript event listeners or observer APIs. Chrome 133 shipped in January 2025. Firefox and Safari support is in progress.
Demos and Resources
- Scroll state queries on MDN with syntax, examples, and browser compatibility.
- CSS scroll-state() by Adam Argyle
- Scrollytelling on Steroids With Scroll-State Queries by Lee Meyer
Native CSS if()
CSS custom properties with fallbacks have always been an approximation of conditional logic. The upcoming if() function in CSS allows genuinely conditional values:
.button {
background: if(style(--variant: primary): blue; else: gray);
}
Combined with the existing @container style() queries, this starts to look like real component-level logic in CSS. Experimental implementations are in progress.
How Much Can You Actually Save?
The numbers above are not hypothetical. Here is what the libraries being replaced actually cost, and what native CSS lets you delete:
| Library | Min + gzip | Replaced by |
|---|---|---|
| @popperjs/core | 14.1 kB | CSS Anchor Positioning |
| @floating-ui/dom | 8.1 kB | CSS Anchor Positioning |
| tippy.js | 14.1 kB | CSS Anchor Positioning + Popover API |
| @radix-ui/react-popover | 19.6 kB | Popover API + Anchor Positioning |
| @radix-ui/react-dialog | 10.6 kB | <dialog> element |
| focus-trap (includes tabbable) | 7.4 kB | <dialog> native focus trap |
| @headlessui/react (full) | 59.1 kB | <dialog>, Popover API, <select> |
| gsap core | 26.6 kB | CSS @keyframes / animation |
| gsap/ScrollTrigger | 18.3 kB | CSS Scroll-Driven Animations |
| motion (formerly framer-motion) | 57.4 kB | View Transitions API + CSS Scroll-Driven Animations |
| react-transition-group | 4.0 kB | View Transitions API |
| react-select | 29.1 kB | Customizable <select> |
| @radix-ui/react-select | 23.7 kB | Customizable <select> |
| downshift | 14.3 kB | Customizable <select> / <datalist> |
| masonry-layout | 6.7 kB | CSS Grid Lanes |
| isotope-layout | 9.1 kB | CSS Grid Lanes |
| Total | ~322 kB |
All sizes are minified + gzipped from Bundlephobia.
Conservative scenario — a typical content app that uses Floating UI for a tooltip, Radix Dialog for a modal, and GSAP ScrollTrigger for scroll animations: that is roughly 44 kB gone (@floating-ui/dom 8.1 + @radix-ui/react-dialog 10.6 + focus-trap 7.4 + gsap/ScrollTrigger 18.3), not counting the GSAP core you may be able to drop entirely.
Aggressive scenario — a design-system-heavy SPA that uses Headless UI across the board, Motion (formerly Framer Motion) for page transitions, and react-select for a custom dropdown: replacing those three alone removes ~146 kB of JavaScript from your bundle.
Beyond Kilobytes
Bundle size is the visible part of the cost. The less visible part is parse and execution time. Every kilobyte of JavaScript has to be downloaded, parsed, and executed before it can do anything. On a mid-range Android device, 100 kB of JavaScript takes measurably longer to execute than 100 kB of CSS — because CSS is parsed on a separate thread and is cheaper to apply. Libraries that run on the main thread (GSAP ScrollTrigger, Masonry.js, Isotope) block everything else while they work.
This shows up directly in Core Web Vitals:
- INP (Interaction to Next Paint) improves when there is less JavaScript competing for the main thread during user interactions. Replacing scroll listeners and resize observers with CSS-native equivalents reduces main-thread contention.
- LCP (Largest Contentful Paint) can improve when less render-blocking JavaScript delays the first meaningful paint — particularly relevant when these libraries are imported eagerly.
- CLS (Cumulative Layout Shift) is reduced when layout calculations happen natively in the browser's layout engine rather than in JavaScript that runs after paint. Masonry.js and Isotope are a common source of CLS because their absolute-positioning pass happens after the browser has already laid out the page.
There is also the maintenance cost: peer dependency conflicts when a library requires a specific React version, breaking changes in major releases, GitHub issues for the edge case that applies to your project and nobody else's. Native CSS features do not have major versions.
What CSS Still Has Not Solved
For all the ground CSS is covering, there are two categories of interaction that remain firmly in JavaScript territory.
Drag and Drop
Native HTML drag and drop (draggable, ondragover, ondrop) is technically available, but it is notoriously awkward — poor touch support, limited control over drag images, inconsistent behavior across browsers. Every serious drag-and-drop implementation uses a JavaScript library: dnd-kit, react-beautiful-dnd, SortableJS.
There is no CSS or HTML proposal on the horizon that would make this declarative. Drag and drop involves complex gesture tracking, hit testing, scroll-while-dragging, and accessibility semantics that are genuinely hard to abstract. This one will stay JavaScript for the foreseeable future.
Overlay Scrollbars
Overlay scrollbars — the kind that float over content and disappear when not in use, like macOS trackpad scrollbars — cannot be requested with CSS. CSS does give you scrollbar-color and scrollbar-width (both Baseline 2024/2025) to style the thumb and track, and scrollbar-gutter to manage layout space. But none of these control where the scrollbar renders relative to the content. On Windows, Chrome uses fixed scrollbars that consume layout space regardless of how you style them.
The scrollbar-style: overlay property has been proposed to the CSS Working Group to fill this gap — it would let authors request overlay behavior while keeping the scrollbar visible and accessible, unlike scrollbar-width: none which hides it entirely. Microsoft is contributing Fluent overlay scrollbar support to Chromium behind a flag. But there is no spec resolution yet and no shipping implementation. Until that lands, anything requiring true overlay behavior on Windows stays in JavaScript.
The Pattern Continues
The web platform is slow and then suddenly fast. Custom fonts via image sprites, then @font-face. Rounded corners via background images, then border-radius. Smooth scrolling via JavaScript, then scroll-behavior. Sticky headers via position: fixed hacks, then position: sticky.
The features above are the current generation of that same catch-up cycle. Some are already shipping, some are behind flags, some are still in spec debate. But the direction is clear: if you are building UI components from scratch in JavaScript because CSS could not do it, double check. The answer may have changed.

Written by Pavel Laptev
Quack! A digital designer who loves open source. Flaps around making interfaces nice and helps the design and frontend pond whenever he can.



