Making an Accessible FAQ Section That Works for Everyone

By · Updated

Ship a keyboard-first, screen-reader-friendly FAQ that scales—from native <details> to a fully controllable ARIA accordion—with deep links, analytics, and JSON-LD.

Why Accessible FAQs Matter (SEO + UX + Legal)

  • Findability: Clear questions/answers improve information scent and reduce pogo-sticking.
  • Usability: Keyboard users, screen readers, and touch users get equal access to the same answers.
  • Maintainability: Semantic HTML scales without brittle JS.

Option A — Native <details>/<summary> (Fastest to Ship)

The native disclosure pattern is accessible by default, linkable, and requires almost no JavaScript. It’s perfect for most FAQs.

<section aria-labelledby="faq-title" class="space-y-3" id="faq">
  <h2 id="faq-title" class="text-2xl md:text-3xl font-bold">FAQs</h2>

  <details id="faq-shipping" class="group rounded-lg border p-4 open:bg-gray-50 dark:open:bg-gray-800/40">
    <summary class="flex w-full cursor-pointer items-center justify-between gap-3
                    font-semibold text-[#263f4e] dark:text-white
                    marker:content-none list-none">
      How long does shipping take?
      <span aria-hidden="true" class="transition-transform group-open:rotate-180">▾</span>
    </summary>
    <div class="mt-3 text-gray-700 dark:text-gray-300">
      Most orders ship within 2–3 business days. You’ll receive tracking once dispatched.
    </div>
  </details>

  <details id="faq-returns" class="group rounded-lg border p-4 open:bg-gray-50 dark:open:bg-gray-800/40">
    <summary class="flex w-full cursor-pointer items-center justify-between gap-3 font-semibold">
      What is your return policy?
      <span aria-hidden="true" class="transition-transform group-open:rotate-180">▾</span>
    </summary>
    <div class="mt-3">Returns accepted within 30 days in original condition.</div>
  </details>
</section>

Pro tip: Give each <details> a unique id so you can deep-link (e.g., #faq-returns).

Option B — ARIA Accordion (Controlled, Button-Driven)

If you need programmatic control (expand/collapse all, analytics, animations), use the WAI-ARIA accordion pattern: heading + button + region.

<section id="faq-accordion" aria-labelledby="faq-acc-title" class="space-y-3">
  <h2 id="faq-acc-title" class="text-2xl md:text-3xl font-bold">FAQs</h2>

  <div class="flex gap-2 mb-2">
    <button type="button" class="px-3 py-1.5 rounded border" data-acc="expand-all">Expand all</button>
    <button type="button" class="px-3 py-1.5 rounded border" data-acc="collapse-all">Collapse all</button>
  </div>

  <div class="border rounded-lg divide-y" data-accordion>

    <h3 class="m-0">
      <button class="w-full text-left p-4 font-semibold flex items-center justify-between"
              aria-expanded="false"
              aria-controls="panel-1" id="control-1">
        Do you ship internationally?
        <span aria-hidden="true">▾</span>
      </button>
    </h3>
    <div id="panel-1" role="region" aria-labelledby="control-1"
         hidden class="p-4 text-gray-700 dark:text-gray-300">
      Yes, to 40+ countries. Duties/taxes vary by destination.
    </div>

    <h3 class="m-0">
      <button class="w-full text-left p-4 font-semibold flex items-center justify-between"
              aria-expanded="false"
              aria-controls="panel-2" id="control-2">
        Can I change my order?
        <span aria-hidden="true">▾</span>
      </button>
    </h3>
    <div id="panel-2" role="region" aria-labelledby="control-2" hidden class="p-4">
      Changes are possible within 1 hour of purchase. Contact support.
    </div>

  </div>
</section>

<script>
// Accessible accordion controller
(() => {
  const root = document.querySelector('[data-accordion]');
  if (!root) return;

  const controls = [...root.querySelectorAll('button[aria-controls]')];
  const panels = controls.map(btn => document.getElementById(btn.getAttribute('aria-controls')));

  function setExpanded(btn, expanded){
    btn.setAttribute('aria-expanded', String(expanded));
    const panel = document.getElementById(btn.getAttribute('aria-controls'));
    if (!panel) return;
    panel.hidden = !expanded;
    btn.querySelector('span[aria-hidden="true"]')?.classList.toggle('rotate-180', expanded);
  }

  // Click to toggle
  root.addEventListener('click', (e) => {
    const btn = e.target.closest('button[aria-controls]');
    if (!btn) return;
    const isOpen = btn.getAttribute('aria-expanded') === 'true';
    setExpanded(btn, !isOpen);
  });

  // Keyboard support: Home/End to jump, Up/Down to move focus
  root.addEventListener('keydown', (e) => {
    const i = controls.indexOf(document.activeElement);
    if (i < 0) return;
    if (['ArrowDown','ArrowUp','Home','End'].includes(e.key)) e.preventDefault();
    if (e.key === 'ArrowDown') controls[Math.min(i+1, controls.length-1)].focus();
    if (e.key === 'ArrowUp')   controls[Math.max(i-1, 0)].focus();
    if (e.key === 'Home')      controls[0].focus();
    if (e.key === 'End')       controls[controls.length-1].focus();
  });

  // Expand/collapse all
  document.addEventListener('click', (e) => {
    const a = e.target.closest('[data-acc]');
    if (!a) return;
    const expand = a.dataset.acc === 'expand-all';
    controls.forEach(btn => setExpanded(btn, expand));
  });

  // Deep link support (e.g., #panel-2 or #control-2)
  function openFromHash(){
    const id = location.hash.slice(1);
    if (!id) return;
    const panel = document.getElementById(id);
    const btn = document.getElementById(id) || document.querySelector(`[aria-controls="${id}"]`);
    const control = panel ? root.querySelector(`[aria-controls="${id}"]`) : (btn?.matches('button[aria-controls]') ? btn : null);
    if (control) {
      setExpanded(control, true);
      document.getElementById(control.getAttribute('aria-controls'))?.focus({ preventScroll: true });
    }
  }
  openFromHash();
  window.addEventListener('hashchange', openFromHash);
})();
</script>

Each button controls its region via aria-controls and announces state with aria-expanded. Regions expose a label through aria-labelledby.

Design Guardrails: Contrast, Hit Areas, and Motion

  • Contrast: Buttons and text should meet WCAG 2.1 AA (4.5:1 for body, 3:1 for large text).
  • Hit areas: Make summary/buttons full-row, with at least 44×44 px touch targets.
  • Motion: Keep open/close transitions subtle; respect prefers-reduced-motion.
@media (prefers-reduced-motion: reduce) {
  [data-accordion] [role="region"] { transition: none !important; }
}

Optional: FAQ JSON-LD for Rich Results

Add structured data that mirrors the visible Q&A. Keep it accurate and in sync with the page copy.

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "FAQPage",
  "mainEntity": [{
    "@type": "Question",
    "name": "How long does shipping take?",
    "acceptedAnswer": { "@type": "Answer", "text": "Most orders ship within 2–3 business days. Tracking is provided on dispatch." }
  },{
    "@type": "Question",
    "name": "What is your return policy?",
    "acceptedAnswer": { "@type": "Answer", "text": "Returns are accepted within 30 days if items are unused and in original packaging." }
  }]
}
</script>

Instrumentation: Measure What People Actually Ask

Capture expansion events to refine content and reduce support tickets.

<script>
// Example: send a custom event when a question opens
document.addEventListener('click', (e) => {
  const btn = e.target.closest('[aria-controls]');
  if (!btn) return;
  const opened = btn.getAttribute('aria-expanded') === 'false';
  if (opened) {
    window.dispatchEvent(new CustomEvent('faq:open', {
      detail: { id: btn.id, question: btn.textContent.trim() }
    }));
  }
});
</script>

Common Pitfalls (and Fixes)

  • Focus lost after toggle: Keep focus on the summary/button; don’t auto-move focus to content.
  • Screen reader says “button, collapsed… but nothing opens”: Ensure aria-controls matches a real id, and use hidden for true visibility state.
  • Keyboard trap: Don’t remove the button from the DOM or disable it after click; use stateful attributes only.
  • Desync between JSON-LD and copy: Keep the structured data updated whenever the visible Q/A changes.

QA Checklist

  • ✅ Fully usable with keyboard (Tab/Shift+Tab, Enter/Space, Arrow navigation in accordion).
  • ✅ Announced states: aria-expanded updates; regions labeled via aria-labelledby.
  • ✅ Contrast and touch targets meet WCAG 2.1 AA.
  • ✅ Deep links open the correct item (#faq-returns).
  • ✅ JSON-LD (if used) mirrors visible Q&A and is kept in sync.

FAQs about building FAQs

Details vs accordion—which should I choose?

Start with <details> for simplicity. Use the ARIA accordion if you need global controls, analytics hooks, or custom motion.

Should I auto-expand items on search?

Yes—if users arrive with a hash or on-page search result, expand that item only to reduce friction.

Can I animate height?

Prefer opacity/transform or content-visibility tricks; if you animate height, ensure it’s short and respect prefers-reduced-motion.

Key Takeaways

  • Semantic first: <details>/<summary> gets you 80% of the way, fast.
  • For control, use the ARIA accordion pattern with proper labels and state.
  • Deep links, analytics, and JSON-LD make FAQs useful for users and teams.
Disclaimer: This tutorial is provided for educational and informational purposes only and does not constitute legal, financial, or professional advice. All content is offered “as-is” without warranties of any kind. Readers are solely responsible for implementation and must ensure compliance with applicable laws, accessibility standards, and platform terms. Always apply the information only within authorized, ethical, and legal contexts.

Spot an error or a better angle? Tell me and I’ll update the piece. I’ll credit you by name—or keep it anonymous if you prefer. Accuracy > ego.

Portrait of Mason Goulding

Mason Goulding · Founder, Maelstrom Web Services

Builder of fast, hand-coded static sites with SEO baked in. Stack: Eleventy · Vanilla JS · Netlify · Figma

With 10 years of writing expertise and currently pursuing advanced studies in computer science and mathematics, Mason blends human behavior insights with technical execution. His Master’s research at CSU–Sacramento examined how COVID-19 shaped social interactions in academic spaces — see his thesis on Relational Interactions in Digital Spaces During the COVID-19 Pandemic . He applies his unique background and skills to create successful builds for California SMBs.

Every build follows Google’s E-E-A-T standards: scalable, accessible, and future-proof.