Implementing Accordion and Tabs on Website
Accordions and tabs are basic content layout patterns. Making them "just work" is easy. Making them accessible, SEO-friendly, and without flashing on load — requires care.
Accordion: Native HTML
Native <details>/<summary> works without JS, has built-in semantics, and can be animated via CSS:
<div class="accordion" role="list">
<details class="accordion__item" role="listitem">
<summary class="accordion__trigger">
How to place an order?
<span class="accordion__icon" aria-hidden="true"></span>
</summary>
<div class="accordion__content">
<p>Select products, go to cart and click "Place Order".</p>
</div>
</details>
<details class="accordion__item" role="listitem">
<summary class="accordion__trigger">
What payment methods available?
</summary>
<div class="accordion__content">
<p>Card, cash on delivery, wire transfer.</p>
</div>
</details>
</div>
.accordion__item {
border-bottom: 1px solid #e2e8f0;
}
.accordion__trigger {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 0;
cursor: pointer;
list-style: none; /* hide native marker */
font-weight: 500;
user-select: none;
}
.accordion__trigger::-webkit-details-marker {
display: none; /* Safari */
}
.accordion__icon::before {
content: '+';
font-size: 20px;
transition: transform 0.2s;
}
details[open] .accordion__icon::before {
content: '−';
}
/* Animation via @starting-style (Chrome 117+, Firefox 129+) */
.accordion__content {
overflow: hidden;
}
@supports (interpolate-size: allow-keywords) {
.accordion__content {
interpolate-size: allow-keywords;
height: 0;
transition: height 0.3s ease;
}
details[open] .accordion__content {
height: auto;
}
}
For browsers without interpolate-size — JavaScript animation:
document.querySelectorAll('details.accordion__item').forEach(details => {
const content = details.querySelector('.accordion__content') as HTMLElement
details.addEventListener('toggle', () => {
if (details.open) {
const height = content.scrollHeight
content.style.height = '0'
requestAnimationFrame(() => {
content.style.transition = 'height 0.3s ease'
content.style.height = `${height}px`
content.addEventListener('transitionend', () => {
content.style.height = 'auto'
}, { once: true })
})
} else {
content.style.height = `${content.scrollHeight}px`
requestAnimationFrame(() => {
content.style.transition = 'height 0.3s ease'
content.style.height = '0'
})
}
})
})
Accordion: React Component
import { useState, useRef, useEffect } from 'react'
interface AccordionItem {
id: string
question: string
answer: React.ReactNode
}
function AccordionItem({ item, isOpen, onToggle }: {
item: AccordionItem
isOpen: boolean
onToggle: () => void
}) {
const contentRef = useRef<HTMLDivElement>(null)
const [height, setHeight] = useState(0)
useEffect(() => {
if (contentRef.current) {
setHeight(isOpen ? contentRef.current.scrollHeight : 0)
}
}, [isOpen])
return (
<div className="accordion-item">
<button
className="accordion-item__trigger"
onClick={onToggle}
aria-expanded={isOpen}
aria-controls={`accordion-content-${item.id}`}
id={`accordion-btn-${item.id}`}
>
{item.question}
<svg className={`accordion-item__chevron ${isOpen ? 'rotated' : ''}`} viewBox="0 0 24 24">
<path d="M6 9l6 6 6-6" stroke="currentColor" fill="none" strokeWidth="2"/>
</svg>
</button>
<div
id={`accordion-content-${item.id}`}
role="region"
aria-labelledby={`accordion-btn-${item.id}`}
style={{ height, overflow: 'hidden', transition: 'height 0.3s ease' }}
>
<div ref={contentRef} className="accordion-item__body">
{item.answer}
</div>
</div>
</div>
)
}
export function Accordion({ items, allowMultiple = false }: {
items: AccordionItem[]
allowMultiple?: boolean
}) {
const [openIds, setOpenIds] = useState<Set<string>>(new Set())
function toggle(id: string) {
setOpenIds(prev => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
if (!allowMultiple) next.clear()
next.add(id)
}
return next
})
}
return (
<div className="accordion">
{items.map(item => (
<AccordionItem
key={item.id}
item={item}
isOpen={openIds.has(item.id)}
onToggle={() => toggle(item.id)}
/>
))}
</div>
)
}
Tabs: ARIA Pattern
interface Tab {
id: string
label: string
content: React.ReactNode
}
export function Tabs({ tabs, defaultTab }: { tabs: Tab[]; defaultTab?: string }) {
const [activeId, setActiveId] = useState(defaultTab ?? tabs[0]?.id)
const tablistRef = useRef<HTMLDivElement>(null)
// Keyboard navigation with arrows
function handleKeyDown(e: React.KeyboardEvent, currentIndex: number) {
let newIndex = currentIndex
if (e.key === 'ArrowRight') newIndex = (currentIndex + 1) % tabs.length
else if (e.key === 'ArrowLeft') newIndex = (currentIndex - 1 + tabs.length) % tabs.length
else if (e.key === 'Home') newIndex = 0
else if (e.key === 'End') newIndex = tabs.length - 1
else return
e.preventDefault()
setActiveId(tabs[newIndex].id)
// Move focus
const tabEls = tablistRef.current?.querySelectorAll('[role="tab"]')
;(tabEls?.[newIndex] as HTMLElement)?.focus()
}
return (
<div className="tabs">
<div role="tablist" ref={tablistRef} className="tabs__list">
{tabs.map((tab, i) => (
<button
key={tab.id}
role="tab"
id={`tab-${tab.id}`}
aria-selected={activeId === tab.id}
aria-controls={`tabpanel-${tab.id}`}
tabIndex={activeId === tab.id ? 0 : -1}
onClick={() => setActiveId(tab.id)}
onKeyDown={e => handleKeyDown(e, i)}
className={`tabs__tab ${activeId === tab.id ? 'tabs__tab--active' : ''}`}
>
{tab.label}
</button>
))}
</div>
{tabs.map(tab => (
<div
key={tab.id}
role="tabpanel"
id={`tabpanel-${tab.id}`}
aria-labelledby={`tab-${tab.id}`}
hidden={activeId !== tab.id}
tabIndex={0}
className="tabs__panel"
>
{tab.content}
</div>
))}
</div>
)
}
URL Synchronization for Tabs
Tabs not reflected in URL break the back button and sharing:
import { useSearchParams } from 'react-router-dom'
function UrlTabs({ tabs }: { tabs: Tab[] }) {
const [params, setParams] = useSearchParams()
const activeId = params.get('tab') ?? tabs[0].id
function setTab(id: string) {
setParams(prev => { prev.set('tab', id); return prev }, { replace: true })
}
return <Tabs tabs={tabs} defaultTab={activeId} onTabChange={setTab} />
}
SEO: Tab Content for Search Engines
Hidden tab content with hidden or display: none is indexed by Google but with less weight. For SEO-important content use visibility: hidden + height: 0 instead of display: none, or server-render all panels:
// SSR: render all panels, hide via CSS
<div
role="tabpanel"
hidden={activeId !== tab.id}
// hidden = display:none in browser, but in SSR HTML shows content
// Google sees content in HTML
>
Schema.org for FAQ Accordion
function FaqAccordion({ items }: { items: AccordionItem[] }) {
const schema = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: items.map(item => ({
'@type': 'Question',
name: item.question,
acceptedAnswer: {
'@type': 'Answer',
text: typeof item.answer === 'string' ? item.answer : '',
},
})),
}
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
/>
<Accordion items={items} />
</>
)
}
Timeline
Simple accordion or tabs on native HTML — 2–3 hours. React components with animation, ARIA, keyboard navigation — 1 day. System with URL sync, Schema.org markup, tests — 1.5 days.







