Replacing custom JavaScript with native CSS sounds like a cleanup task. In practice, it is a change in ownership. Code that used to manage open state, focus, positioning, or validation hands that responsibility back to the browser.

The risky part is not writing the new CSS. It is deleting the old assumptions without losing behavior that mattered. A safe migration starts with what the interface does, not with a search for event listeners to remove.

Audit responsibilities, not files

A 200-line menu script rarely has one job. It may toggle visibility, move focus, close on Escape, dismiss on an outside click, update an ARIA attribute, lock page scrolling, and send analytics. Native HTML or CSS might replace five of those jobs and leave two in JavaScript.

Before choosing a replacement, write down the responsibilities you can observe:

  • What opens the interface, and what closes it?
  • Can the rest of the page still be used while it is open?
  • Where does keyboard focus move?
  • Which visual state is mirrored in a class or attribute?
  • Which callbacks perform business logic or analytics?

This separates browser work from product work. CSS can replace a class used only for presentation. It should not replace a purchase event, a data request, or a rule that exists because of the product rather than the interface.

Capture the current behavior before changing it

Open the interface with a mouse and a keyboard. Press Escape. Click outside it. Tab through it. Zoom the page. Resize the viewport. Record the DOM state that changes and the script that caused it.

trigger.addEventListener("click", toggleMenu);
document.addEventListener("pointerdown", closeOutside);
document.addEventListener("keydown", closeOnEscape);

function toggleMenu() {
  const isOpen = menu.classList.toggle("is-open");
  trigger.setAttribute("aria-expanded", String(isOpen));
}

This excerpt tells you more than the class name. The script owns open state, light dismiss, Escape handling, and the relationship between the trigger and its panel. That is concrete evidence for a native popover, assuming the panel is non-modal.

Do not migrate from a static HTML snapshot. Many replacement opportunities only appear after a click, hover, focus change, or scroll. Inspect the rendered behavior and the code that drives it.

Choose the native primitive that owns the behavior

Start with the behavior contract, then choose a platform feature. A disclosure belongs to <details>. A modal that pauses the page belongs to <dialog>. A non-modal floating panel may belong to the Popover API. A parent style that depends on a child may belong to :has().

This is where the rule of least power helps. Use the least powerful language that can express the job. HTML describes meaning and built-in behavior. CSS describes presentation and conditional styles. JavaScript remains available for application logic and gaps the platform does not cover.

The test is not whether a new feature looks similar. It must own the same important behavior. A popover provides top-layer rendering, Escape handling, and light dismiss. A class toggle only provides the state you programmed.

Migrate one complete pattern

For a non-modal account menu, the native version can remove most of the state code:

Custom state

<button aria-expanded="false">
  Account
</button>
<nav class="menu" hidden>...</nav>

Native state

<button popovertarget="account-menu">
  Account
</button>
<nav id="account-menu" popover>...</nav>
#account-menu {
  margin: 0;
  inset: auto 1rem auto auto;
}

#account-menu:popover-open {
  display: grid;
  gap: 0.5rem;
}

The browser now owns the open state and common dismissal behavior. JavaScript can still listen for toggle when analytics genuinely needs to know that the menu opened. That small listener is different from rebuilding the interaction.

Do not use this example for a modal. A modal needs the focus and inertness behavior of <dialog> opened with showModal(). Similar-looking overlays can have different interaction contracts.

Keep the old experience usable while support changes

A migration does not need two complete implementations. Start with useful HTML, then add the native enhancement behind a support query when your browser policy requires it.

.menu {
  display: block;
}

@supports selector(:popover-open) {
  .menu:not(:popover-open) {
    display: none;
  }
}

The exact fallback depends on the component. A disclosure can begin as visible content. A layout improvement can keep the old stacked layout. A behavior that must work identically in an older supported browser may still need a small script or a polyfill.

Browser support is a product decision, not a dare. Check the feature against the browsers your users have, then decide whether the baseline is acceptable when the enhancement is missing.

Delete the mirrored state, not only the event listener

The maintenance win appears when the old source of truth disappears. If the browser owns popover state but your code still maintains .is-open, aria-expanded, a store value, and a hidden attribute, you now have more state than before.

After the native version passes its behavior tests, remove the old path in one pass:

  • Event listeners used only to recreate native opening and dismissal.
  • Classes and data attributes that mirror the same state.
  • CSS selectors that only supported the custom state model.
  • Dependencies imported only for the replaced interaction.
  • Tests that assert implementation details instead of behavior.

Keep analytics and business callbacks, but attach them to the native event or the action that matters. A migration is successful when there is one owner for each state.

Test behavior, not the new markup

A test that checks for popover proves that an attribute exists. It does not prove that a keyboard user can open the menu, move through its links, close it, and return to the trigger without getting lost.

Run the same behavior matrix before and after the migration:

  • Pointer, keyboard, touch, zoom, and reduced-motion preferences.
  • Opening, closing, focus return, outside interaction, and Escape.
  • Long content, narrow containers, translated labels, and nested scroll areas.
  • Every browser included in the project's support policy.

Then measure the result in terms the team can maintain: fewer listeners, fewer state variables, a smaller dependency surface, and fewer places where a DOM change can break the interaction. Bundle size and main-thread work may improve too, but only claim what the diff and profiler show.

This is slower than replacing every matching pattern in one sweep. It is also much easier to review. One behavior changes owner, its evidence stays visible, and the old code can be deleted with confidence.

Primary references