Tabs and Accordions Without Libraries: details/summary + Progressive Enhancement

Was this helpful?

Somewhere in your production estate there’s a page whose “simple” FAQ accordion drags in 180KB of JavaScript, blocks rendering, and still fails keyboard navigation. You can feel it: the user’s patience leaving, your Lighthouse score whining, and your on-call future getting a new ticket titled “Accordion doesn’t open on iPad.”

This is the better way: ship semantic HTML first, then enhance. Use <details>/<summary> where it fits, and only write JavaScript where it actually earns its keep.


The approach: progressive enhancement, not progressive regret

If you’re building UI components for the web, you’re not just shipping pixels. You’re shipping failure modes: how the page behaves when JavaScript loads late, when CSS doesn’t load, when the user navigates by keyboard, when the browser is old, when the corporate proxy injects nonsense, or when your own bundler quietly doubles the payload because someone imported a utility from “the shared UI package.”

Progressive enhancement is the boring discipline of making the baseline experience work with the least assumptions. Then you enhance it for capability, not for fashion. The baseline should be readable, navigable, and operable with the most primitive stack you can tolerate: server-rendered HTML, minimal CSS, and zero JavaScript dependencies for core interaction when possible.

Opinion: If your accordion requires a JavaScript framework to open, it’s not an accordion. It’s a light-up sign that says “I am a state machine with no backup plan.”

Native <details>/<summary> is the cleanest progressive enhancement story for disclosures and accordions. Tabs are trickier: there’s no native “tab” element, and trying to force <details> into tab semantics is how you get a UI that looks like tabs but reads like a bag of toggles.

Two goals drive everything here:

  • Resilience: if scripts fail, content is still reachable and interaction still makes sense.
  • Observability: if it breaks, you can diagnose it quickly with normal tools, not a priest and a minified bundle.

And yes, you can still have nice animation, URL-deep-linking, and “only one open” accordion behavior. Just earn it incrementally.

One quote for the road, because it’s still true in UI land: “Hope is not a strategy.” — Vince Lombardi

Joke #1: A UI library is like a houseplant: it looks harmless until you realize it needs constant attention and somehow attracts bugs.


Facts and historical context you can use in arguments

These aren’t trivia for trivia’s sake. These are the kinds of facts that help you win design reviews and prevent teams from cargo-culting a heavy component library “because everyone does.”

  1. <details> is a real HTML element. It’s not a div with vibes; it’s a standardized disclosure widget with built-in toggling and a defined accessibility mapping in modern browsers.
  2. Early browser behavior was inconsistent. For years, some engines treated <summary> focus/keyboard handling differently; many teams wrote polyfills. Today, it’s largely stable, but legacy assumptions linger in old code.
  3. The “accordion” pattern is older than web apps. Desktop UIs had disclosure triangles and expanding panels long before SPA frameworks; the web is catching up by reusing the same mental model, now with semantics.
  4. Tabs and accordions serve different jobs. Tabs imply a single “current” view among peers; accordions imply multiple expandable disclosures. Users interpret them differently, and screen readers announce them differently.
  5. Progressive enhancement predates modern frameworks. It grew out of the reality that the web is heterogeneous: slow networks, partial support, and failures are normal, not edge cases.
  6. ARIA is not a replacement for semantics. ARIA can describe behavior, but if you can use a native element, you typically should. ARIA is sharp; it cuts both ways.
  7. Framework tabs often break when hydrated late. The server renders “Tab A,” the client hydrates “Tab B,” and you get a flicker plus state mismatch. Native disclosure avoids a lot of this because the browser owns the baseline state.
  8. Deep-linking is a recurring requirement. Product teams love “share a link to the third panel.” If you ignore it, someone will bolt on hash logic later and create a new class of bugs.

Accordions with details/summary: the default choice

Use <details> when you have a heading that toggles visibility of content. That’s it. If the interaction is “click title to show/hide body,” don’t negotiate with yourself: start with <details>.

Baseline HTML that works everywhere content works

This baseline has no dependency on CSS or JavaScript. If styles fail, the content is still linear and readable. If scripts fail, the disclosure still opens and closes.

cr0x@server:~$ cat accordion.html
<section aria-label="Shipping FAQs">
  <h2>Shipping</h2>

  <details>
    <summary>When do you ship?</summary>
    <p>Orders placed before 2pm ship the same business day.</p>
  </details>

  <details>
    <summary>Do you ship internationally?</summary>
    <p>Yes. Duties and taxes are calculated at checkout when available.</p>
  </details>

  <details open>
    <summary>How do returns work?</summary>
    <p>Start a return within 30 days. We email a label if eligible.</p>
  </details>
</section>

Note the open attribute. It’s not just for show: it’s your server-rendered default state, and it’s a nice escape hatch for “first item open by default” without scripting.

Styling details/summary without breaking it

The fast way to ruin <details> is to strip <summary> of its marker, remove its focus outline, and then replace it with a div because “design.” Don’t. You can style it and keep it operable.

  • Keep a visible focus indicator on summary.
  • If you customize the marker, do it with CSS, not by removing semantics.
  • Don’t put interactive controls inside summary unless you’ve tested keyboard behavior thoroughly (spoiler: it gets weird).

“Only one open at a time” accordion behavior (optional)

Native <details> doesn’t enforce the “one-open” rule. That’s good: it doesn’t assume your UX. If your designers insist on classic accordion behavior (opening one closes others), enhance it with a tiny script that listens for toggle.

Key principle: if the script fails, users can still open multiple panels. That’s a sane fallback.

cr0x@server:~$ cat accordion-one-open.js
document.addEventListener("DOMContentLoaded", () => {
  document.querySelectorAll("[data-accordion]").forEach((root) => {
    const items = Array.from(root.querySelectorAll("details"));
    root.addEventListener("toggle", (e) => {
      const target = e.target;
      if (!(target instanceof HTMLDetailsElement)) return;
      if (!target.open) return;

      for (const item of items) {
        if (item !== target) item.open = false;
      }
    });
  });
});

Attach it like this:

cr0x@server:~$ cat accordion-enhanced.html
<section data-accordion aria-label="Billing FAQs">
  <h2>Billing</h2>
  <details>
    <summary>Can I get an invoice?</summary>
    <p>Invoices are available in your account within 24 hours.</p>
  </details>
  <details>
    <summary>Do you support ACH?</summary>
    <p>Yes for annual plans; contact support to enable it.</p>
  </details>
</section>

This is progressive enhancement done right: base behavior is native; enhanced behavior is additive; failure mode is acceptable.


Progressive enhancements that don’t sabotage resilience

Enhancements are where teams accidentally rebuild a UI library, then wonder why it behaves like one. If you’re enhancing <details>, keep the shape of the DOM and the native interaction. Don’t fight the element.

Enhancement 1: animated open/close without jank

Animating height is the classic footgun. It triggers layout, can be janky under load, and tends to break when content is dynamic. If you must animate, prefer animating opacity and a small transform, or use the newer CSS approach with content-visibility for large panels. Keep it subtle.

Also: don’t animate in a way that delays content for assistive tech. Animation is eye candy; accessibility is the meal.

Enhancement 2: deep-link to a specific panel

When someone shares “see the third question,” they mean a stable URL that opens the right panel. Do it with IDs and fragment logic.

  • Give each <details> a stable id.
  • On load, if location.hash matches a details ID, open it and scroll it into view.
cr0x@server:~$ cat accordion-deeplink.js
document.addEventListener("DOMContentLoaded", () => {
  const id = decodeURIComponent(location.hash.replace(/^#/, ""));
  if (!id) return;

  const el = document.getElementById(id);
  if (el && el.tagName === "DETAILS") {
    el.open = true;
    el.scrollIntoView({ block: "start" });
    el.querySelector("summary")?.focus();
  }
});

Failure mode if this script doesn’t load: the page still works; the hash just doesn’t auto-open. That’s tolerable.

Enhancement 3: analytics without turning the UI into a telemetry engine

You’ll be asked to log which panels users open. Fine. Use the native toggle event; do not attach click handlers to summary that override default behavior. Your job is to observe, not to seize the wheel.

cr0x@server:~$ cat accordion-analytics.js
document.addEventListener("DOMContentLoaded", () => {
  document.body.addEventListener("toggle", (e) => {
    const d = e.target;
    if (!(d instanceof HTMLDetailsElement)) return;
    if (!d.id) return;

    const payload = { id: d.id, open: d.open, ts: Date.now() };
    navigator.sendBeacon?.("/ui/toggle", JSON.stringify(payload));
  }, true);
});

If sendBeacon isn’t available, nothing breaks. You lose analytics, not UX.

Enhancement 4: print and “no-CSS” sanity

Print styles matter more than you think, because “print” often means “save as PDF,” and “save as PDF” often means “attach to a compliance ticket.” Ensure open panels print expanded, or choose a rule like “print everything expanded.”

One pragmatic pattern: in print CSS, force all details open.


Tabs: when details is wrong, and how to do it anyway

Tabs are not accordions. Tabs are a single-selection widget: one tab is active, and its panel is the current view. That matters for assistive tech, keyboard conventions, and general user expectation. Trying to fake tabs with multiple <details> elements gives you a multi-open widget that looks like tabs but behaves like a stack of toggles.

So what’s the baseline for tabs, if we’re not using a library?

The baseline: a plain list of links

The most resilient tab baseline is… not tabs. It’s a set of links to sections on the page (or separate pages). It loads fast, works with everything, and is trivially deep-linkable.

cr0x@server:~$ cat tabs-baseline.html
<nav aria-label="Account sections">
  <ul>
    <li><a href="#profile">Profile</a></li>
    <li><a href="#security">Security</a></li>
    <li><a href="#billing">Billing</a></li>
  </ul>
</nav>

<section id="profile">
  <h2>Profile</h2>
  <p>Update your name and contact details.</p>
</section>

<section id="security">
  <h2>Security</h2>
  <p>Manage sessions and multi-factor authentication.</p>
</section>

<section id="billing">
  <h2>Billing</h2>
  <p>Invoices, payment methods, and plan changes.</p>
</section>

This is the “works in a text browser” option. And yes, in corporate reality, sometimes that’s what saves you: a broken bundle shouldn’t block a customer from finding “Reset MFA.”

Progressively enhance into real tabs (ARIA + minimal JS)

When you truly need tabs—because the content is peer panels and the UI is dense—enhance from the baseline. You keep the same sections with IDs. You add a tablist that is built from those links. Then you hide/show panels with JavaScript. If JS fails, the user still has the links and sections.



Profile content. In your real app, this would be real forms, not vibes.

And the script:

cr0x@server:~$ cat tabs.js
function activateTab(tab, tabs, panels, { focus = true } = {}) {
  for (const t of tabs) t.setAttribute("aria-selected", String(t === tab));
  for (const p of panels) p.hidden = (p.id !== tab.getAttribute("aria-controls"));

  if (focus) tab.focus();
}

document.addEventListener("DOMContentLoaded", () => {
  document.querySelectorAll('[role="tablist"]').forEach((tablist) => {
    const tabs = Array.from(tablist.querySelectorAll('[role="tab"]'));
    const panels = tabs
      .map(t => document.getElementById(t.getAttribute("aria-controls")))
      .filter(Boolean);

    tablist.addEventListener("click", (e) => {
      const tab = e.target.closest('[role="tab"]');
      if (!tab) return;
      activateTab(tab, tabs, panels);
      history.replaceState(null, "", "#" + tab.id);
    });

    tablist.addEventListener("keydown", (e) => {
      const current = document.activeElement.closest?.('[role="tab"]');
      if (!current) return;

      const i = tabs.indexOf(current);
      if (e.key === "ArrowRight" || e.key === "ArrowDown") {
        e.preventDefault();
        activateTab(tabs[(i + 1) % tabs.length], tabs, panels);
      } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
        e.preventDefault();
        activateTab(tabs[(i - 1 + tabs.length) % tabs.length], tabs, panels);
      } else if (e.key === "Home") {
        e.preventDefault();
        activateTab(tabs[0], tabs, panels);
      } else if (e.key === "End") {
        e.preventDefault();
        activateTab(tabs[tabs.length - 1], tabs, panels);
      }
    });

    // Deep-link: open tab by #tab-id
    const hash = decodeURIComponent(location.hash.replace(/^#/, ""));
    if (hash) {
      const t = document.getElementById(hash);
      if (t && tabs.includes(t)) activateTab(t, tabs, panels, { focus: false });
    }
  });
});

That’s the core: selected state, hidden panels, keyboard navigation, and deep-linking. No framework required. No reactivity system. No “tab store.”

Joke #2: If you build tabs with a global event bus, congratulations—you invented a toaster that needs Kubernetes.

Why not CSS-only tabs?

CSS-only tabs using radio buttons can work, but they’re often fragile and awkward for deep-linking, history integration, and assistive tech consistency. Use them for tiny marketing widgets if you must. For product UI, a 40-line script is typically more robust and far easier to debug.


Accessibility: what to guarantee, what to test

Accessibility is not “add ARIA and call it a day.” It’s ensuring users can operate the UI with keyboard and assistive tech, and that announcements match what’s happening.

For details/summary

  • Keyboard support: summary should be focusable and togglable via keyboard. Modern browsers handle this, unless you break it with styling or event handlers.
  • Visible focus: never remove focus outlines without providing a clear replacement.
  • Clickable area: keep the summary as the main hit target.
  • Nested interactive controls: avoid putting buttons/links inside summary; if unavoidable, test thoroughly because click and toggle interactions can conflict.

For tabs

  • Roles and relationships: tablist contains tab elements; each tab aria-controls a tabpanel; each panel aria-labelledby the tab.
  • Selected state: only one tab should have aria-selected="true".
  • Keyboard conventions: arrow keys move between tabs; Home/End jump; Enter/Space either activates (manual) or not (automatic) depending on your choice. Pick one and be consistent.
  • Hidden panels: use hidden attribute so hidden panels aren’t in the accessibility tree.

Opinion: Don’t “role=tab” a link unless you are intentionally overriding link behavior. A tab is not a navigation link; treat it as a control.

What to test in practice

Testing accessibility is less mystical than people pretend. You’re verifying predictable mechanics:

  • Tab key moves focus to the summary/tab controls in a sane order.
  • Enter/Space toggles <details> open/closed.
  • Arrow keys move between tabs; focus doesn’t get lost in hidden content.
  • Screen reader announces the control type and state (expanded/collapsed; selected tab).

Practical tasks: commands, outputs, and decisions

These are the tasks you run when a “simple accordion” becomes a production problem. Each one includes a realistic command, what the output means, and what decision you make next. This is where frontend meets SRE: you don’t guess, you measure.

Task 1: Verify HTML actually contains details/summary (no client-only rendering)

cr0x@server:~$ curl -sS https://app.example.internal/faq | grep -n "<details" | head
142:<details id="returns">
163:<details id="shipping">

Meaning: The server response already includes the disclosure widgets. Good baseline. Decision: You can rely on progressive enhancement; a JS outage won’t blank the FAQ.

Task 2: Detect if a UI library is being pulled just for accordion/tabs

cr0x@server:~$ curl -sS https://app.example.internal/faq | grep -Eo 'src="[^"]+\.js"' | head
src="/assets/runtime-8a1c.js"
src="/assets/vendor-2b19.js"
src="/assets/faq-41d0.js"

Meaning: There’s a vendor bundle on the FAQ page. Decision: Audit what’s inside; if it’s mostly UI framework runtime, consider shipping plain HTML for this page.

Task 3: Quantify JS payload and caching headers

cr0x@server:~$ curl -sSI https://app.example.internal/assets/vendor-2b19.js | sed -n '1,12p'
HTTP/2 200
content-type: application/javascript
cache-control: public, max-age=31536000, immutable
content-length: 286401
etag: "vendor-2b19"

Meaning: ~280KB vendor JS, cacheable long-term. Not catastrophic, but still a lot for “FAQ accordion.” Decision: If this page is high-traffic or used during incident response, remove unnecessary JS and keep it static.

Task 4: Identify whether the accordion behavior is custom JS overriding native

cr0x@server:~$ rg -n "preventDefault\\(\\)" public/assets/faq-41d0.js | head
1187:e.preventDefault();

Meaning: The script prevents default on something—likely summary click. Decision: Inspect the handler; remove default prevention unless you absolutely need it.

Task 5: Confirm the page still works with JavaScript disabled (headless check)

cr0x@server:~$ chromium --headless --disable-gpu --dump-dom https://app.example.internal/faq | grep -n "<summary" | head
143:<summary>How do returns work?</summary>
164:<summary>Do you ship internationally?</summary>

Meaning: Summaries are present in the DOM dump. Decision: If interaction fails only with JS on, the bug is in enhancement code, not baseline.

Task 6: Check for duplicate IDs breaking deep-linking

cr0x@server:~$ chromium --headless --disable-gpu --dump-dom https://app.example.internal/faq | grep -o 'id="[^"]*"' | sort | uniq -d | head
id="shipping"

Meaning: Duplicate id="shipping" exists. Decision: Fix templates to guarantee unique IDs; deep-linking and label relationships depend on it.

Task 7: Detect layout thrash in the enhancement script (profiling hint)

cr0x@server:~$ rg -n "getBoundingClientRect\\(|offsetHeight|scrollHeight" tabs.js accordion-one-open.js

Meaning: No direct layout reads in these small scripts. Decision: If you see these calls inside loops or toggle handlers, expect jank; rewrite animation logic.

Task 8: Confirm CSP isn’t blocking your tiny enhancement scripts

cr0x@server:~$ curl -sSI https://app.example.internal/faq | grep -i content-security-policy
content-security-policy: default-src 'self'; script-src 'self'; object-src 'none'

Meaning: Inline scripts are likely blocked; external scripts from self are allowed. Decision: Ship enhancements as static files, not inline <script> blobs.

Task 9: Verify tab panels are actually hidden from accessibility tree

cr0x@server:~$ chromium --headless --disable-gpu --dump-dom https://app.example.internal/account | grep -n 'role="tabpanel"' | head
88:<div role="tabpanel" id="panel-profile" aria-labelledby="tab-profile">
92:<div role="tabpanel" id="panel-security" aria-labelledby="tab-security" hidden>

Meaning: Non-active panels are hidden. Decision: Keep using hidden instead of CSS-only hiding for tab panels.

Task 10: Find event listeners that might multiply (double-binding on rerender)

cr0x@server:~$ rg -n "addEventListener\\(\"click\"|addEventListener\\(\"toggle\"" public/assets/*.js | head
public/assets/faq-41d0.js:221:addEventListener("click", function(e){
public/assets/faq-41d0.js:489:addEventListener("click", function(e){

Meaning: Multiple click listeners in the same bundle. Not automatically bad, but suspicious on a simple page. Decision: Ensure listeners are delegated once per container, not re-attached per item on each render.

Task 11: Measure TTFB and content download time to separate network vs JS issues

cr0x@server:~$ curl -o /dev/null -sS -w 'ttfb=%{time_starttransfer} total=%{time_total} size=%{size_download}\n' https://app.example.internal/faq
ttfb=0.084531 total=0.129774 size=40218

Meaning: Server is fast; network isn’t the problem. Decision: If UX is slow, focus on render-blocking assets and main-thread JS.

Task 12: Check if the enhancement script is render-blocking

cr0x@server:~$ curl -sS https://app.example.internal/faq | grep -n "<script" | head
35:<script src="/assets/vendor-2b19.js"></script>
36:<script src="/assets/faq-41d0.js"></script>

Meaning: Scripts are loaded without defer or type="module". They block parsing. Decision: Add defer to non-critical scripts, especially enhancement-only code.

Task 13: Validate gzip/brotli compression for JS payload

cr0x@server:~$ curl -sSI -H 'Accept-Encoding: br' https://app.example.internal/assets/vendor-2b19.js | grep -iE 'content-encoding|content-length'
content-encoding: br
content-length: 68421

Meaning: Brotli reduces transfer size significantly. Decision: If compression is missing, fix CDN/server config before arguing about micro-optimizations.

Task 14: Confirm “one open” behavior isn’t enforced by CSS hacks

cr0x@server:~$ rg -n "details\\[open\\].*~.*details\\[open\\]" public/assets/*.css | head

Meaning: No CSS sibling hack found. Decision: Use the small JS approach; CSS can’t reliably enforce “only one open” across arbitrary DOM layouts.


Fast diagnosis playbook

When tabs or accordions “sometimes don’t work,” the worst move is to stare at code first. Diagnose like an operator: narrow the class of failure in minutes.

First: is the baseline HTML correct?

  • Check: View source (not inspector) and confirm <details>/<summary> exist, or that tab content exists as normal sections.
  • If missing: You’re doing client-only rendering. Decide whether that’s acceptable for this component. For FAQs and help content, it usually isn’t.

Second: is JavaScript blocking interaction by accident?

  • Check: Look for preventDefault() on summary clicks, global click handlers, or overlay components that intercept clicks.
  • If present: Remove/limit the handler. Native <details> should not require click plumbing.

Third: is the problem state, hydration, or duplication?

  • Check: Duplicate IDs, multiple event binding, and server/client mismatch on initial open/selected state.
  • If mismatch: Make server output the canonical initial state (e.g., set open attribute; select first tab) and let client enhance without rewriting history.

Fourth: is the bottleneck performance or correctness?

  • Check: Are interactions delayed (main-thread busy) or broken (no toggle)?
  • If delayed: Profile for long tasks; remove heavy library code from disclosure pages; defer scripts.
  • If broken: Focus on event handling and DOM structure first; performance fixes won’t repair incorrect semantics.

Common mistakes: symptoms → root cause → fix

This section exists because these bugs recur across teams like a seasonal flu, except the cure is discipline.

1) “Accordion doesn’t open on some devices”

Symptom: Clicking the summary does nothing, or opens then immediately closes.

Root cause: A click handler on summary calls preventDefault() or toggles open twice (once by browser, once by script). Often introduced by analytics or “custom animation.”

Fix: Remove default prevention; listen to toggle on details instead. If you need manual control, stop relying on native toggle entirely and accept you’re building a custom component (and then test like one).

2) “Keyboard users can’t operate the accordion”

Symptom: Tab key skips summaries, or focus is invisible.

Root cause: CSS removed outlines; summary display changed in a way that breaks focus; summary replaced with a div.

Fix: Keep real <summary>. Restore focus styles. Test with keyboard before merging.

3) “Deep links open the wrong panel”

Symptom: URL fragment points to a panel, but a different one opens, or scroll jumps weirdly.

Root cause: Duplicate IDs or scripts that rewrite location.hash on load without verifying the target exists.

Fix: Enforce unique IDs; only call history.replaceState on explicit user action; validate hash targets.

4) “Tabs flicker on load”

Symptom: All panels briefly show, then one hides; or the active tab changes after a moment.

Root cause: Rendering baseline shows all sections; JS enhancement applies hiding after layout; hydration mismatch or late-loading script.

Fix: Start with server-rendered “active only” if possible (e.g., add hidden attributes) or apply a tiny inline class toggle before paint—if CSP allows—or use defer plus CSS that hides panels only when “JS-enabled” class is present.

5) “Only one open” accordion closes itself unpredictably

Symptom: Opening a panel closes it immediately, or toggling one closes a different accordion elsewhere.

Root cause: Event delegation is attached to document and not scoped; handler collects details from the whole page.

Fix: Scope to a container with data-accordion; only close siblings within that container.

6) “Screen reader announces weird roles or repeats headings”

Symptom: Tab panels are announced even when hidden; tabs announced as links; repeated labels.

Root cause: Hiding via CSS only; incorrect ARIA relationships; using display:none inconsistently; reusing IDs across multiple tablists.

Fix: Use hidden for inactive tabpanels; enforce unique IDs per tab instance; validate aria-controls and aria-labelledby pairs.


Three corporate mini-stories (anonymized)

Incident: a wrong assumption about “JS always loads”

A mid-sized B2B SaaS had an “Account Recovery” page with tabs: “Email,” “Authenticator,” “SSO fallback.” The product team wanted it sleek, so they used a component from the main app bundle. It rendered fine in staging, fine on office Wi‑Fi, fine in local dev where everything is instant and nobody’s laptop is running five VPNs.

Then a customer network started blocking a third-party domain used by an unrelated analytics script. The browser waited, retried, and delayed the execution of the main bundle in a way that varied by device. For a chunk of users, the “tab” UI loaded late enough that the baseline HTML—basically empty placeholders—was all they had. So the recovery options were invisible. People were locked out and furious, which is a special kind of ticket to receive.

The wrong assumption wasn’t “analytics might be blocked.” Everyone knows that. The wrong assumption was that critical interaction can be client-only. The fix wasn’t heroic: server-render the recovery content as normal sections with anchor navigation, then enhance to tabs only if JS loads. The next time a corporate proxy threw a tantrum, recovery still worked. Nobody threw a tantrum at the on-call.

Optimization that backfired: CSS-only tabs with radio buttons

A commerce team decided to remove JavaScript from a product detail tabs widget (“Description,” “Specs,” “Warranty”). Good instinct. Unfortunately, they chose the radio-button CSS trick, then wrapped it in a template partial used across the site—including in a comparison view where 20 products render on the same page.

Radio inputs require unique name groups and unique IDs to keep labels tied to the correct input. The partial used static IDs because “it’s just a component.” In single-product pages it looked fine. In the comparison view, clicking “Specs” on product A toggled the “Warranty” panel on product B. Users thought the page was haunted, which is technically accurate.

The fix was to stop pretending CSS-only means complexity-free. They moved to an anchor-based baseline (each section had an ID), and a small scoped enhancement that upgraded a single container to tabs. IDs were namespaced by product identifier. The “optimization” became an actual improvement: less JS than the framework widget, more correctness than the CSS hack.

Boring but correct practice that saved the day: scoping and defaults

At a financial services firm, a security review forced a stricter CSP. Inline scripts were out. Some teams panicked because their UI components relied on sprinkling inline scripts per page to initialize widgets. One team didn’t panic, because their interaction patterns were built on progressive enhancement with external scripts and strict scoping.

Their disclosure-heavy pages used native <details>. Enhancements (one-open behavior, deep-link opening, analytics) were shipped as versioned static files with defer. Initialization was container-based: each accordion had data-accordion, each tablist was local, and event listeners were delegated within the component.

When the CSP change landed, those pages kept functioning. No emergency exception requests. No “temporary” header relaxations that become permanent. Just a boring green deploy. In a corporate environment, boring is a feature you should fight to keep.


Checklists / step-by-step plan

Step-by-step: shipping an accordion the resilient way

  1. Start with HTML: Use <details>/<summary>. Put real content inside. No placeholders as the only source of truth.
  2. Decide default state: Add open on the one panel you want expanded by default (or none).
  3. Style carefully: Keep focus visible; don’t remove semantics; avoid interactive controls inside summary.
  4. Add enhancements only when asked: one-open behavior, deep-link opening, analytics. Keep each enhancement independent.
  5. Defer scripts: Add defer and keep enhancement scripts small and local.
  6. Test failure modes: JS disabled, CSS disabled (or blocked), slow network. Confirm content remains reachable.
  7. Lock in IDs: Ensure unique, stable IDs if you need deep links or analytics.

Step-by-step: upgrading anchor navigation into tabs

  1. Baseline sections: Use <section id="..."><h2>...</h2> for each panel.
  2. Baseline navigation: Provide a <nav> list of links to those IDs.
  3. Enhancement layer: Replace/augment nav with a role="tablist" of buttons when JS is available.
  4. Hide panels correctly: Use hidden on inactive panels, not just CSS.
  5. Keyboard support: Arrow keys, Home/End. Don’t skip it; it’s part of the widget.
  6. Deep-linking: Use hash to select a tab. Don’t push history on load; replace state on user action.
  7. Scope everything: One tab widget must not affect another. Avoid global selectors that catch every panel on the page.

Release checklist for production systems

  • Can a user reach the content with JS blocked?
  • Can a user operate the control with keyboard only?
  • Do hash links open the right panel/tab?
  • Are IDs unique across the page?
  • Are scripts deferred and cacheable?
  • Does the component still work if analytics fails?
  • Have you tested at least one “slow 3G / high-latency” profile?
  • Is the enhancement code scoped to a container (no document-wide accidental coupling)?

FAQ

1) Should I always use details/summary for accordions?

Yes for typical disclosure content. If you need complex state coordination, nested interactive headers, or custom semantics, you might build a custom component—but you’re choosing more testing and more risk.

2) Can I style the summary marker?

You can, but be cautious. If you remove the default marker, you must replace the affordance (a clear indicator) and preserve keyboard focus visibility. Don’t hide the only clue that something is expandable.

3) Why not use a UI library’s accordion component?

Sometimes you should—if you already depend on the library and the component is heavily customized. But if you’re pulling the library primarily for disclosures, you’re paying a recurring tax: bundle size, hydration bugs, and dependency churn.

4) Are tabs possible without JavaScript?

Not as “real tabs” with tab semantics and keyboard conventions. The baseline should be anchor navigation or separate pages. Then enhance with JS into tabs if needed.

5) Should tab buttons be links?

Usually no. Tabs are controls; use <button> with role="tab". If you want navigation behavior, use links and don’t call it tabs. Mixing the two creates confusing behavior for users and assistive tech.

6) How do I handle deep links for tabs?

Use the hash to represent tab selection (e.g., #tab-security). On load, read the hash and activate the matching tab. On user click, update the hash using history.replaceState to avoid spamming browser history.

7) What’s the safest way to enforce “only one open” in an accordion?

Listen to the toggle event on a container and close sibling details elements when one opens. Scope it to one accordion container so you don’t close unrelated disclosures elsewhere.

8) Can I nest details elements?

You can, but it’s easy to create confusing interaction and focus paths. If you nest, keep summaries simple, avoid nested controls in headers, and test keyboard navigation thoroughly.

9) How do I avoid flicker when enhancing to tabs?

Either render the initial “active-only” state server-side using hidden, or apply a “js-enabled” class early and only hide panels when that class exists. Don’t rely on late JS to hide content after layout.

10) What’s the minimum test plan before shipping?

Keyboard-only operation, JS-disabled reachability, unique ID validation, and at least one slow network profile. If you can’t do those, you’re shipping wishful thinking.


Conclusion: next steps that won’t haunt you

If you remember one thing: start from HTML that already works. <details>/<summary> is your friend for disclosures. Tabs require JavaScript, but they don’t require a framework—and they absolutely don’t require client-only content.

Practical next steps:

  • Pick one high-traffic accordion page (FAQ, pricing, settings help). Replace the library accordion with native <details> and measure bundle reduction.
  • For existing tab widgets, ensure there’s a baseline anchor navigation and sections. Then re-add tabs as an enhancement with scoped JS.
  • Run the diagnosis tasks above in CI: check for duplicate IDs and render-blocking scripts on content-heavy pages.
  • Write down your team’s “component contract”: baseline behavior without JS, enhancement behavior with JS, and acceptable failure modes.

Do that, and your UI won’t just look good. It’ll keep working when the real world shows up, which is what production systems do.

← Previous
Email Catch‑all Mailbox: Why It Ruins Deliverability (and Safer Alternatives)
Next →
Email: Multiple MX records — how priority really works (and common mistakes)

Leave a comment