Back to blog

CSS Can Replace More of Your JavaScript in 2026

Popovers, anchor positioning, scroll-driven animations and stylable selects now ship in real browsers. What each one replaces, the code to use, and where you still need a fallback.

Close-up of a code editor showing SVG markup with linear gradients and filters, highlighted in pink, orange and blue on a dark background.
Photo by Florian Olivo on Unsplash

On October 27, Firefox 159 shipped scroll-driven animations. That was the last major browser to turn them on, so reading progress bars and reveal-on-scroll effects now work in Chrome, Edge, Safari and Firefox with no JavaScript at all.

Over the last two years, browsers have shipped CSS and HTML features that cover jobs we used to hand to JavaScript: menus, tooltips, dropdown positioning, scroll effects and custom-styled form controls. Less JavaScript means less code to download, less to break, and fewer things running on the main thread while someone tries to scroll.

Below are the features worth knowing now, what each one replaces, a working example, and the browser versions that support it. Support numbers come from MDN's browser compatibility data as of November 2026.

Popover and invoker commands: menus without toggle code

What it replaces: the click handler that toggles a class, the Escape-key listener and the click-outside listener.

My own site has a script called header.js that toggles the mobile menu, updates aria-expanded, swaps the icon and closes the menu on Escape. A menu built with HTML attributes instead:

<button commandfor="more-menu" command="toggle-popover">More</button>

<div id="more-menu" popover>
  <a href="/extensions/">Extensions</a>
  <a href="/blog/">Blog</a>
</div>

For comparison, the JavaScript version of that menu needs roughly this:

const button = document.querySelector(".more-button");
const menu = document.getElementById("more-menu");

const close = () => {
  menu.hidden = true;
  button.setAttribute("aria-expanded", "false");
};

button.addEventListener("click", () => {
  const open = menu.hidden;
  menu.hidden = !open;
  button.setAttribute("aria-expanded", String(open));
});

document.addEventListener("keydown", (e) => {
  if (e.key === "Escape") close();
});

document.addEventListener("click", (e) => {
  if (!menu.contains(e.target) && !button.contains(e.target)) close();
});

That's about twenty lines before any styling, and every line is a place for a bug, such as forgetting to return focus to the button or letting two menus stay open at once.

The popover attribute hides the element until something opens it. The button's commandfor and command attributes tell the browser which element to toggle. A popover opened this way closes when you press Escape or click anywhere outside it. It also opens in the browser's top layer, above everything else on the page, so z-index doesn't come into it.

For newbies: an "invoker" is any button that controls another element. command="toggle-popover" is one command. Others include show-modal and close for <dialog> elements.

Support:

  • popover: Chrome and Edge 114, Firefox 125, Safari 17
  • commandfor and command: Chrome and Edge 135, Firefox 144, Safari 26.2

If you need older browsers, popovertarget="more-menu" on the button does the same toggle and has the same support as popover itself.

Anchor positioning: dropdowns and tooltips without a library

What it replaces: positioning libraries like Floating UI and Popper, and the hand-written code that measures a button with getBoundingClientRect() and recalculates on every scroll and resize.

Anchor positioning lets you attach one element to another in CSS. Combined with the popover above:

.more-button {
  anchor-name: --more-button;
}

#more-menu {
  position-anchor: --more-button;
  position-area: bottom span-right;
  position-try-fallbacks: flip-block;
  inset: auto;
  margin: 0.25rem 0 0;
}

anchor-name gives the button a name. position-anchor points the menu at it. position-area places the menu below the button, lined up with its left edge and free to extend right. position-try-fallbacks: flip-block moves the menu above the button when there isn't room below, which is the part that used to take the most JavaScript.

The inset: auto line matters for popovers. The browser's default popover styles stretch the element across the screen and center it, and resetting inset lets position-area take over.

For the experts: the initial value of position-anchor has changed more than once as the spec settled, and browsers updated at different times. Set it explicitly, as above, rather than relying on the default.

Support:

  • anchor-name: Chrome and Edge 125, Firefox 147, Safari 26
  • position-try-fallbacks: Chrome and Edge 128, Firefox 147, Safari 26
  • position-area: Chrome and Edge 129, Firefox 147, Safari 26

Firefox 147 shipped in January 2026, which was the last engine to add it.

Scroll-driven animations: progress bars without scroll listeners

What it replaces: window.addEventListener("scroll", ...) handlers that read the scroll position and update a style on every frame, and IntersectionObserver code that adds an "in view" class to fade elements in.

A reading progress bar:

<div class="read-progress" aria-hidden="true"></div>
@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: scroll()) {
    .read-progress {
      position: fixed;
      inset: 0 0 auto;
      height: 4px;
      background: currentColor;
      transform-origin: left;
      animation-name: grow;
      animation-timing-function: linear;
      animation-fill-mode: both;
      animation-timeline: scroll(root);
    }
  }
}

@keyframes grow {
  from { transform: scaleX(0); }
  to { transform: scaleX(1); }
}

animation-timeline: scroll(root) ties the animation to how far the page has scrolled. At the top, the bar is at 0%. At the bottom, it's at 100%.

Two details are easy to miss. First, the animation shorthand resets animation-timeline, so either use the longhand properties as above or put animation-timeline after the shorthand. Second, the @supports and prefers-reduced-motion wrappers mean browsers without support, and people who've asked for less motion, get a div with no height. Nothing breaks.

To fade elements in as they scroll into view, use view() instead of scroll():

@supports (animation-timeline: view()) {
  .reveal {
    animation-name: fade-in;
    animation-fill-mode: both;
    animation-timeline: view();
    animation-range: entry 0% cover 30%;
  }
}

@keyframes fade-in {
  from { opacity: 0; translate: 0 2rem; }
}

animation-range controls when the animation runs. This one starts as the element enters the viewport and finishes when it's 30% of the way through. Animating opacity, transform and translate keeps the work cheap for the browser.

Support: Chrome and Edge 115, Safari 26, Firefox 159.

Customizable select: style the dropdown itself

What it replaces: custom dropdown components built from divs, which then need keyboard handling, focus management and screen reader support rebuilt from scratch.

For years, a native <select> was nearly impossible to style, so teams replaced it with JavaScript widgets that were often less accessible than the original. Now you can opt in to a fully stylable version:

@supports (appearance: base-select) {
  select,
  ::picker(select) {
    appearance: base-select;
  }

  select {
    border: 2px solid currentColor;
    border-radius: 0.5rem;
    padding: 0.5rem 0.75rem;
  }

  ::picker(select) {
    border-radius: 0.5rem;
  }

  option:checked {
    font-weight: 700;
  }
}

appearance: base-select on both the select and its ::picker(select) (the dropdown list) switches to the stylable version. The browser keeps the keyboard support, form submission and accessibility of a real <select>. In supporting browsers, options can also hold richer content, like an icon next to the text.

Support: Chrome and Edge 135, Safari 27. Firefox has it behind a flag. That's fine, because a browser without support shows a normal native <select>, which still works.

Small wins worth knowing

:has(), the parent selector. Style an element based on what's inside it, a job that used to need JavaScript to add a class to the parent:

.plan:has(input:checked) {
  outline: 3px solid currentColor;
}

Support: Chrome and Edge 105, Firefox 121, Safari 15.4.

Exclusive accordions. Give several <details> elements the same name and only one stays open at a time:

<details name="faq"><summary>Is it free?</summary><p>Yes.</p></details>
<details name="faq"><summary>Does it track me?</summary><p>No.</p></details>

Support: Chrome and Edge 120, Firefox 130, Safari 17.2.

Auto-growing text areas. One line replaces the script that resizes a <textarea> as someone types:

textarea {
  field-sizing: content;
  min-height: 4rem;
}

Support: Chrome and Edge 123, Firefox 152, Safari 26.2.

Still JavaScript, for now

A few features are close but not ready to rely on everywhere:

  • interestfor, which opens a popover on hover or focus for tooltips, is in Chrome and Edge 142 and not yet in Firefox or Safari.
  • closedby on <dialog>, which lets a dialog close when you click outside it, is in Chrome and Edge 134 and Firefox 141. Safari only has it in a preview release.
  • interpolate-size, which lets you animate to height: auto, is Chrome and Edge only.

Some jobs belong to JavaScript no matter what. Fetching data, saving a choice between visits and anything that needs to remember state all stay in scripts. The theme toggle on this site saves your light or dark choice in localStorage, and CSS can't do that.

Finding candidates in your own code

You don't need to audit everything at once. A few searches turn up most of the JavaScript these features can replace:

git grep -n "addEventListener(\"scroll\""   # scroll effects and progress bars
git grep -n "IntersectionObserver"          # fade-in-on-scroll
git grep -n "getBoundingClientRect"         # hand-rolled positioning
git grep -n "classList.toggle"              # menus, accordions, toggles
git grep -n "key === \"Escape\""            # close-on-Escape handlers

Each result isn't automatically a replacement. An IntersectionObserver that lazy-loads data has to stay. One that adds an is-visible class so a card can fade in is a good candidate for view(). Check your package list too. A positioning library in your dependencies usually means dropdowns or tooltips that anchor positioning can handle.

Start with the code that runs most often. Scroll listeners fire constantly while someone scrolls, so moving one to CSS helps more than replacing a menu that opens twice per visit.

Switching without breaking anything

  1. Wrap new features in @supports. Browsers that don't understand the feature skip the block entirely.
  2. Make sure the fallback still works. A native <select>, content that's visible without the animation, and a menu that opens with the older popovertarget attribute are all fine fallbacks.
  3. Test with a keyboard. Tab to the control, open it with Enter or Space, close it with Escape. Then try a screen reader. The native features handle most of this, but your CSS can still hide focus outlines or reorder things visually.
  4. Delete the JavaScript last. Ship the CSS version, check it in every browser you support, then remove the old script in its own commit so it's easy to undo.

Open your site's JavaScript, find the first scroll listener or menu toggle, and check it against the support lists above.