Making an Accessible FAQ Section That Works for Everyone
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 realid
, and usehidden
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 viaaria-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.