The Great CSS Expansion

CSS now does what Floating UI, GSAP ScrollTrigger, Framer Motion, and react-select used to require JavaScript for. Here is exactly how much that saves, why these libraries were painful beyond their size, and what the platform still hasn't figured out.

The Great CSS Expansion

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."

LibrarySize (min+gz)Why it's painful
@floating-ui/dom8.1 kBCollision detection (flip, shift, autoPlacement) needs careful tuning per use case — the library has no knowledge of your overflow context or scroll containers
@popperjs/core14.1 kBOlder API, same fundamental problem: pure JavaScript geometry that has to re-run on every scroll and resize event
tippy.js14.1 kBBuilt 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.

CSS
.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


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.

LibrarySize (min+gz)Why it's painful
@radix-ui/react-popover19.6 kBRequires 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 kBNot 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
  • ::backdrop for visual overlay
  • Focus management
  • The top layer — popovers always render above other content without z-index wrestling
HTML
<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


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.

LibrarySize (min+gz)Why it's painful
@radix-ui/react-dialog10.6 kBShips its own focus management and portal logic on top of focus-trap
focus-trap (includes tabbable)7.4 kBManually 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.

HTML
<dialog id="my-dialog">
  <p>This is a proper modal</p>
  <button onclick="this.closest('dialog').close()">Close</button>
</dialog>
JS
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


Popover vs. Dialog: What's the Difference?

The key difference comes down to whether the rest of the page stays interactive:

Popover APIDialog Element
Blocks background interactionNoYes
Light dismiss (click outside)YesNo
Focus trapNoYes
Scroll lockNoYes
Use forTooltips, menus, toastsConfirmations, forms, alerts
APIDeclarative HTML onlyRequires 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.

LibrarySize (min+gz)Why it's painful
gsap core26.6 kBRequired even if you only want ScrollTrigger; adds significant weight for projects that only need scroll-triggered animations
gsap/ScrollTrigger18.3 kBRuns 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 kBuseScroll 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.

CSS
@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


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.

LibrarySize (min+gz)Why it's painful
motion (formerly framer-motion)57.4 kBReact-only, introduces hydration complexity in SSR apps, and brings its own animation engine even when you only want page transitions
react-transition-group4.0 kBOnly 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.

JS
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:

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


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.

LibrarySize (min+gz)Why it's painful
react-select29.1 kBNotoriously 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-select23.7 kBDoes not integrate with native <form> submission without extra hidden inputs; ships its own keyboard nav, ARIA management, and dropdown positioning
downshift14.3 kBLow-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.

CSS
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


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.

HTML
<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.

HTML
<!-- 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)

LibrarySize (min+gz)Why it's painful
masonry-layout6.7 kBMeasures 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-layout9.1 kBAdds 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.

CSS
.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


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.

CSS
textarea {
  field-sizing: content;
}

Chrome shipped this in 2024. A small thing, but deeply satisfying.


Demos and Resources


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:

CSS
.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:

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:

CSS
.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


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:

CSS
.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:

LibraryMin + gzipReplaced by
@popperjs/core14.1 kBCSS Anchor Positioning
@floating-ui/dom8.1 kBCSS Anchor Positioning
tippy.js14.1 kBCSS Anchor Positioning + Popover API
@radix-ui/react-popover19.6 kBPopover API + Anchor Positioning
@radix-ui/react-dialog10.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 core26.6 kBCSS @keyframes / animation
gsap/ScrollTrigger18.3 kBCSS Scroll-Driven Animations
motion (formerly framer-motion)57.4 kBView Transitions API + CSS Scroll-Driven Animations
react-transition-group4.0 kBView Transitions API
react-select29.1 kBCustomizable <select>
@radix-ui/react-select23.7 kBCustomizable <select>
downshift14.3 kBCustomizable <select> / <datalist>
masonry-layout6.7 kBCSS Grid Lanes
isotope-layout9.1 kBCSS 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.

Pavel Laptev

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.

Stay in the Loop

Subscribe to get fresh updates, insights, and
exclusive content delivered straight to your inbox.
No spam, just great reads. 🚀