Skip to main content
CodeRocket
Accessibilitymediumcomponents

Make accordions keyboard navigable

rule · accordion-accessibility

Accessible accordions allow users to efficiently navigate and reveal content sections using any input method.

Code Example

HTML
<div class="accordion">
  <h3>
    <button
      type="button"
      aria-expanded="false"
      aria-controls="panel-1"
      class="accordion__trigger"
      id="accordion-1"
    >
      Section 1
      <span class="accordion__icon" aria-hidden="true"></span>
    </button>
  </h3>
  <div
    id="panel-1"
    role="region"
    aria-labelledby="accordion-1"
    class="accordion__panel"
    hidden
  >
    <p>Panel 1 content goes here.</p>
  </div>
 
  <h3>
    <button
      type="button"
      aria-expanded="false"
      aria-controls="panel-2"
      class="accordion__trigger"
      id="accordion-2"
    >
      Section 2
      <span class="accordion__icon" aria-hidden="true"></span>
    </button>
  </h3>
  <div
    id="panel-2"
    role="region"
    aria-labelledby="accordion-2"
    class="accordion__panel"
    hidden
  >
    <p>Panel 2 content goes here.</p>
  </div>
</div>

Why It Matters

Poorly implemented accordions trap keyboard users and leave screen reader users unable to understand or navigate collapsed content sections.

ARIA Pattern Requirements

ElementAttributePurpose
Triggerbutton elementActivatable with Enter/Space
Triggeraria-expandedIndicates open/closed state
Triggeraria-controlsReferences panel ID
PanelidTarget of aria-controls
Panelrole="region"Optional, for important sections
HeaderHeading elementMaintains document structure

React Accordion Component

TSX
import { useState, useId, useRef, KeyboardEvent } from 'react'
 
interface AccordionItemProps {
  title: string
  children: React.ReactNode
  defaultOpen?: boolean
}
 
interface AccordionProps {
  children: React.ReactNode
  allowMultiple?: boolean
}
 
export function Accordion({ children, allowMultiple = false }: AccordionProps) {
  const [openItems, setOpenItems] = useState<Set<string>>(new Set())
  const itemRefs = useRef<HTMLButtonElement[]>([])
 
  const toggleItem = (id: string) => {
    setOpenItems(prev => {
      const next = new Set(prev)
      if (next.has(id)) {
        next.delete(id)
      } else {
        if (!allowMultiple) next.clear()
        next.add(id)
      }
      return next
    })
  }
 
  const handleKeyDown = (e: KeyboardEvent, index: number) => {
    const items = itemRefs.current.filter(Boolean)
    let nextIndex: number | null = null
 
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault()
        nextIndex = (index + 1) % items.length
        break
      case 'ArrowUp':
        e.preventDefault()
        nextIndex = (index - 1 + items.length) % items.length
        break
      case 'Home':
        e.preventDefault()
        nextIndex = 0
        break
      case 'End':
        e.preventDefault()
        nextIndex = items.length - 1
        break
    }
 
    if (nextIndex !== null) {
      items[nextIndex]?.focus()
    }
  }
 
  return (
    <div className="accordion">
      {React.Children.map(children, (child, index) => {
        if (React.isValidElement<AccordionItemProps>(child)) {
          return React.cloneElement(child, {
            ...child.props,
            _index: index,
            _isOpen: openItems.has(`item-${index}`),
            _onToggle: () => toggleItem(`item-${index}`),
            _onKeyDown: (e: KeyboardEvent) => handleKeyDown(e, index),
            _ref: (el: HTMLButtonElement) => { itemRefs.current[index] = el },
          } as any)
        }
        return child
      })}
    </div>
  )
}
 
export function AccordionItem({
  title,
  children,
  defaultOpen = false,
  _index,
  _isOpen,
  _onToggle,
  _onKeyDown,
  _ref,
}: AccordionItemProps & {
  _index?: number
  _isOpen?: boolean
  _onToggle?: () => void
  _onKeyDown?: (e: KeyboardEvent) => void
  _ref?: (el: HTMLButtonElement) => void
}) {
  const triggerId = useId()
  const panelId = useId()
  const isOpen = _isOpen ?? defaultOpen
 
  return (
    <div className="accordion__item">
      <h3 className="accordion__header">
        <button
          ref={_ref}
          type="button"
          id={triggerId}
          aria-expanded={isOpen}
          aria-controls={panelId}
          onClick={_onToggle}
          onKeyDown={_onKeyDown}
          className="accordion__trigger"
        >
          <span className="accordion__title">{title}</span>
          <span
            className={`accordion__icon ${isOpen ? 'accordion__icon--open' : ''}`}
            aria-hidden="true"
          />
        </button>
      </h3>
      <div
        id={panelId}
        role="region"
        aria-labelledby={triggerId}
        className="accordion__panel"
        hidden={!isOpen}
      >
        <div className="accordion__content">
          {children}
        </div>
      </div>
    </div>
  )
}

Usage

TSX
<Accordion allowMultiple={false}>
  <AccordionItem title="What is your return policy?">
    <p>You can return items within 30 days of purchase...</p>
  </AccordionItem>
  <AccordionItem title="How long does shipping take?">
    <p>Standard shipping takes 5-7 business days...</p>
  </AccordionItem>
  <AccordionItem title="Do you ship internationally?">
    <p>Yes, we ship to over 50 countries...</p>
  </AccordionItem>
</Accordion>

Keyboard Navigation

KeyAction
Enter / SpaceToggle panel open/closed
Arrow DownMove focus to next header
Arrow UpMove focus to previous header
HomeMove focus to first header
EndMove focus to last header

Native HTML Details/Summary

HTML
<!-- Simple native accordion (limited styling) -->
<details class="accordion-native">
  <summary>Section Title</summary>
  <div class="accordion-native__content">
    <p>Content here...</p>
  </div>
</details>
 
<details class="accordion-native">
  <summary>Another Section</summary>
  <div class="accordion-native__content">
    <p>More content...</p>
  </div>
</details>
TSX
// React wrapper for details/summary
function NativeAccordion({ title, children, defaultOpen = false }) {
  return (
    <details open={defaultOpen} className="accordion-native">
      <summary>{title}</summary>
      <div className="accordion-native__content">
        {children}
      </div>
    </details>
  )
}

Styling

CSS
.accordion {
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  overflow: hidden;
}
 
.accordion__item {
  border-bottom: 1px solid #e0e0e0;
}
 
.accordion__item:last-child {
  border-bottom: none;
}
 
.accordion__header {
  margin: 0;
}
 
.accordion__trigger {
  display: flex;
  align-items: center;
  justify-content: space-between;
  width: 100%;
  padding: 1rem 1.5rem;
  font-size: 1rem;
  font-weight: 600;
  text-align: left;
  background: #fff;
  border: none;
  cursor: pointer;
  transition: background-color 0.2s;
}
 
.accordion__trigger:hover {
  background: #f5f5f5;
}
 
.accordion__trigger:focus-visible {
  outline: 2px solid #0066cc;
  outline-offset: -2px;
}
 
.accordion__icon {
  width: 1.25rem;
  height: 1.25rem;
  transition: transform 0.2s;
}
 
.accordion__trigger[aria-expanded="true"] .accordion__icon {
  transform: rotate(180deg);
}
 
.accordion__panel[hidden] {
  display: none;
}
 
.accordion__content {
  padding: 0 1.5rem 1rem;
}
 
/* Animation */
.accordion__panel {
  animation: slideDown 0.2s ease-out;
}
 
@keyframes slideDown {
  from {
    opacity: 0;
    transform: translateY(-8px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}
 
@media (prefers-reduced-motion: reduce) {
  .accordion__panel,
  .accordion__icon {
    animation: none;
    transition: none;
  }
}

Verification

  1. Tab to first accordion header
  2. Press Enter/Space to toggle
  3. Use Arrow keys to navigate between headers
  4. Press Home/End to jump to first/last
  5. Test with screen reader (announces expanded state)
  6. Verify heading structure in accessibility tree
  7. Check focus is visible on all interactive elements