Medium wave · 540–1600 kHz77 stn
1580kHz

Your Accordion Doesn't Need JavaScript

A towering brass cabinet of stacked hinged drawers in a dark steampunk workshop, one drawer sliding open on its own to spill soft neon teal light, while a discarded tangle of copper wires, pistons, and clockwork motors lies disconnected on the fog-covered floor beside it

Open the bundle analyzer on a typical content site and look for the FAQ page. There is a good chance it ships a framework runtime, a component library, and a state hook whose entire job is to flip one boolean per question. The page is static. The answers are static. The only interactive thing on it is “show this paragraph.”

The browser has done that natively for years. So has “show this menu,” “show this tooltip-ish panel,” and “show this modal.” If your static site hydrates components for any of these, you are paying in bytes, in main-thread time, and in accessibility bugs for behavior the platform already ships.


The Disclosure Is an Element

An accordion is a stack of disclosures: a control that shows and hides a region. HTML has had one since long before most current frameworks existed:

<details>
  <summary>Do you support custom domains?</summary>
  <p>Yes. Point a CNAME at your site and add the domain in settings.</p>
</details>

That is the whole implementation. With zero JavaScript you get:

  • Keyboard support. The summary is focusable and toggles on Enter and Space.
  • Exposed state. Assistive technology announces it as an expandable control and reports whether it is expanded.
  • Server-rendered open state. Add the open attribute and it renders expanded, before any script runs, with no layout shift.
  • Findable content. In Chromium-based browsers, find-in-page searches inside closed panels and opens the one that matches. A display: none panel toggled by React state is invisible to Ctrl+F.

Compare that to the typical hand-rolled version: a div with an onClick, a useState, an aria-expanded someone remembered to add, an aria-controls someone did not, and a content region that does not exist in the HTML until hydration finishes. Every one of those is a place to get it wrong, and most component libraries got at least one wrong at some point.


Exclusive Accordions Are an Attribute Now

The usual objection is “but only one panel should be open at a time.” That used to require script. Now it is the name attribute:

<details name="faq">
  <summary>How is billing calculated?</summary>
  <p>Per seat, per month, prorated to the day.</p>
</details>

<details name="faq">
  <summary>Can I cancel anytime?</summary>
  <p>Yes. Your plan runs to the end of the current period.</p>
</details>

Every details element sharing a name forms a group, and opening one closes the others. It works like radio buttons for disclosures, and it is supported in all three major engines. The last real reason to reach for an accordion component on a content page is gone.


Styling It Without Fighting It

The default disclosure triangle is the part people dislike, and it is the easiest part to replace:

summary {
  list-style: none;
  cursor: pointer;
}

summary::-webkit-details-marker {
  display: none;
}

summary::after {
  content: "+";
  float: right;
}

details[open] > summary::after {
  content: "−";
}

The [open] attribute selector is your state. No class toggling, no data attributes mirrored from a store. The DOM already knows whether the panel is open, so your CSS just asks it.

Animating the height is the one piece that used to require JavaScript, because you cannot transition to height: auto. Chromium now offers interpolate-size: allow-keywords and a ::details-content pseudo-element that make a pure-CSS open animation possible:

:root {
  interpolate-size: allow-keywords;
}

details::details-content {
  block-size: 0;
  overflow: hidden;
  transition: block-size 0.25s ease, content-visibility 0.25s allow-discrete;
}

details[open]::details-content {
  block-size: auto;
}

Treat this as progressive enhancement. Browsers that do not support it snap open instantly, which is fine. A disclosure that opens without a slide is not a bug. A disclosure that does not open until 400 KB of JavaScript has parsed is.


The second most common hydrated widget on content sites is the dropdown: the mobile nav, the “share” menu, the language picker. These are popovers, and HTML has an attribute for them too:

<button popovertarget="site-nav">Menu</button>

<nav id="site-nav" popover>
  <a href="/posts/">Posts</a>
  <a href="/categories/">Categories</a>
  <a href="/about/">About</a>
</nav>

With no script at all, the button toggles the panel, the panel renders in the top layer (so no z-index arms race with your sticky header), Escape closes it, and clicking outside closes it. That last behavior is light dismiss, and it is the thing hand-rolled dropdowns get wrong most often: the click-outside listener that leaks, fires during the opening click, or forgets about touch.

Popovers also hand focus back sensibly when they close, which is another detail custom code routinely skips. Positioning a popover next to its trigger is where support still varies. CSS anchor positioning handles it in newer engines, and a fixed or centered placement is a perfectly good fallback for a mobile nav.


Modals Are a Dialog, and Close Without Script

A true modal (focus trapped, page behind it inert) is the dialog element opened with showModal(). Opening it has traditionally needed one line of JavaScript, and that is fine. One line is not a framework:

<button type="button" id="open-subscribe">Subscribe</button>

<dialog id="subscribe">
  <form method="dialog">
    <p>Get new posts by RSS or email.</p>
    <button>Close</button>
  </form>
</dialog>

<script>
  document.getElementById('open-subscribe').addEventListener('click', () => {
    document.getElementById('subscribe').showModal();
  });
</script>

Notice what the script does not do. It does not trap focus, because showModal() makes everything outside the dialog inert. It does not handle Escape, because the browser does. It does not wire up the close button, because a form with method="dialog" closes its dialog on submit with no code. The ::backdrop pseudo-element gives you the dimmed overlay in CSS.

Newer browsers are also shipping invoker commands (command="show-modal" with commandfor="subscribe" on the button), which removes that last line of script entirely. Check support for your audience before you drop the fallback, but the direction is clear: the platform is absorbing the last bits of glue code.


What This Means for Your Islands

On an Astro site, every one of these patterns turns a hydrated island into plain markup. That is not a micro-optimization. Each island carries its framework runtime (shared, but still downloaded), its component code, its serialized props, and a hydration pass that has to run before the control works. On a slow phone, “before the control works” is a long, visible window where the visitor taps and nothing happens.

Native elements are interactive the moment the HTML is parsed. There is no hydration gap because there is nothing to hydrate. Your interaction latency for “open this panel” drops to whatever the browser needs to run its own C++.

The audit is quick. Search your components for the shapes these elements replace:

grep -rn "useState(false)" src/components | grep -i -E "open|expand|show|toggle"

Every hit is a candidate. If the component’s job is to show and hide markup that already exists in the server output, it is a details, a popover, or a dialog wearing a costume.


Where You Still Need Script

Native elements are not the answer to everything, and pretending otherwise is how teams end up rebuilding the library badly.

  • Deep links into a closed panel. If someone shares a link to #refund-policy and that heading sits inside a closed details, the browser will not open it for you. A ten-line script that reads location.hash, finds the enclosing details, and sets open covers it. If your FAQ answers are linkable (and they should be), write that script.
  • Headings in the summary. A summary is exposed as a button, and some screen readers flatten its contents, so a heading inside it may not show up in heading navigation. If your FAQ relies on heading structure, test with an actual screen reader rather than assuming.
  • Real application state. A filter panel whose open state drives a URL query, a tabbed editor that syncs with a server, a combobox with async results. These are components with logic, not disclosures, and they earn their JavaScript.

The line is simple: if the interaction is “reveal content that is already in the HTML,” the browser owns it. If the interaction is “compute something and render the result,” you own it.


The Takeaway

Static-first is not only about where pages are rendered. It is about refusing to ship code for things the platform already does. Accordions, exclusive accordions, dropdown menus, and modals are now HTML elements and attributes with keyboard support, accessibility semantics, and light dismiss built in.

Delete the toggle components. Ship details, popover, and dialog. Keep your JavaScript budget for the parts of your site that actually think.