Skip to content
Navigation & Steps primitive

Accordion

Buttons inside real headings, with panels that stay mounted so aria-controls resolves and go inert when shut so Tab cannot walk into invisible content — animated by a grid-row transition, off under reduced motion.

138 lineslucide-react
  • accordion
  • disclosure
  • faq
  • collapse
  • expand

What's included

  • components/ui/accordion.tsx
  • Needs lucide-react

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add accordion

Or over MCP, from your editor's agent — no account needed.

License

Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.

Was this useful?

Live

Interactive, in your current theme. Every state below is the real component — type in it, tab through it, switch the theme.

Most orders leave the warehouse within a day and arrive in three to five working days.

Yes, within 30 days of delivery, as long as it is unused and in its original packaging.

Manufacturing defects for two years. It does not cover accidental damage.

Source

One file. Create components/ui/accordion.tsx and paste.

components/ui/accordion.tsx
'use client'

/**
 * <Accordion> — stacked headings that each open to reveal a panel.
 *
 *  - The header is a `<button>` INSIDE a heading, not a heading made
 *    clickable. The heading gives a screen reader a place in the document
 *    outline ("heading level 3, Shipping"); the button gives the control. A
 *    clickable `<div>` gives neither. `headingLevel` is a prop because the
 *    right level depends on the page it is dropped into, and a hard-coded
 *    `<h3>` under an `<h1>` skips a level.
 *  - `aria-expanded` and an `aria-controls` that always resolves: every panel
 *    stays mounted and is closed by height, so the reference never dangles.
 *  - A closed panel is `inert`, not just clipped to zero height. Clipping
 *    leaves its links and buttons focusable, so Tab would walk into invisible
 *    content; `inert` removes it from focus and the accessibility tree at once.
 *  - The height animation is a grid-row transition (0fr to 1fr), which
 *    animates to a height nobody had to measure, and it is off under
 *    `prefers-reduced-motion`.
 *  - Up/Down move between headers, wrapping; Home and End jump. Space and
 *    Enter are the button's own.
 *  - `single` mode keeps one open (`collapsible` decides whether it can close);
 *    `multiple` lets any number be open.
 */

import * as React from 'react'
import { ChevronDown } from 'lucide-react'

export interface AccordionItem {
  id: string
  title: string
  content: React.ReactNode
  disabled?: boolean
}

export interface AccordionProps {
  items: AccordionItem[]
  type?: 'single' | 'multiple'
  /** In `single` mode, whether the open item may be closed again. */
  collapsible?: boolean
  /** Controlled open ids. */
  value?: string[]
  defaultValue?: string[]
  onValueChange?: (open: string[]) => void
  /** Heading level for each header, 2 to 6. */
  headingLevel?: 2 | 3 | 4 | 5 | 6
  className?: string
}

export function Accordion({
  items,
  type = 'single',
  collapsible = true,
  value,
  defaultValue = [],
  onValueChange,
  headingLevel = 3,
  className = '',
}: AccordionProps) {
  const uid = React.useId()
  const [inner, setInner] = React.useState<string[]>(defaultValue)
  const open = value ?? inner
  const buttons = React.useRef<(HTMLButtonElement | null)[]>([])
  const Heading = `h${headingLevel}` as 'h3'

  const commit = (next: string[]) => {
    if (value === undefined) setInner(next)
    onValueChange?.(next)
  }

  const toggle = (id: string) => {
    const isOpen = open.includes(id)
    if (type === 'multiple') return commit(isOpen ? open.filter((o) => o !== id) : [...open, id])
    if (isOpen) return collapsible ? commit([]) : undefined
    commit([id])
  }

  const onKeyDown = (e: React.KeyboardEvent, index: number) => {
    const live = items.map((it, i) => (it.disabled ? -1 : i)).filter((i) => i >= 0)
    const at = live.indexOf(index)
    let target: number | undefined
    if (e.key === 'ArrowDown') target = live[(at + 1) % live.length]
    else if (e.key === 'ArrowUp') target = live[(at - 1 + live.length) % live.length]
    else if (e.key === 'Home') target = live[0]
    else if (e.key === 'End') target = live[live.length - 1]
    if (target === undefined) return
    e.preventDefault()
    buttons.current[target]?.focus()
  }

  return (
    <div className={`divide-y divide-border rounded-xl border border-border ${className}`}>
      {items.map((item, i) => {
        const isOpen = open.includes(item.id)
        return (
          <div key={item.id}>
            <Heading className="m-0 text-base">
              <button
                ref={(el) => {
                  buttons.current[i] = el
                }}
                type="button"
                id={`${uid}-h-${item.id}`}
                aria-expanded={isOpen}
                aria-controls={`${uid}-p-${item.id}`}
                disabled={item.disabled}
                onClick={() => toggle(item.id)}
                onKeyDown={(e) => onKeyDown(e, i)}
                className="flex w-full items-center justify-between gap-4 px-4 py-3.5 text-start text-sm font-medium transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50"
              >
                {item.title}
                <ChevronDown
                  aria-hidden
                  className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200 motion-reduce:transition-none ${
                    isOpen ? 'rotate-180' : ''
                  }`}
                />
              </button>
            </Heading>
            <div
              id={`${uid}-p-${item.id}`}
              role="region"
              aria-labelledby={`${uid}-h-${item.id}`}
              className={`grid transition-[grid-template-rows] duration-200 motion-reduce:transition-none ${
                isOpen ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'
              }`}
            >
              <div inert={!isOpen} className="overflow-hidden">
                <div className="px-4 pb-4 text-sm text-muted-foreground">{item.content}</div>
              </div>
            </div>
          </div>
        )
      })}
    </div>
  )
}

Props

Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.

PropTypeDefault
itemsrequiredAccordionItem[]—
type'single' | 'multiple''single'
collapsibleIn `single` mode, whether the open item may be closed again.booleantrue
valueControlled open ids.string[]—
defaultValuestring[][]
onValueChange(open: string[]) => void—
headingLevelHeading level for each header, 2 to 6.2 | 3 | 4 | 5 | 63
classNamestring''

For AI

The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.

See the prompt

More navigation & steps

View all
Open the full page for this primitive

Progress Steps

An ordered list in a nav, not a row of divs — so the count and the position come free, and aria-current names the step you are on. Horizontal and vertical, because four labels do not fit on a phone.

Navigation & Steps145 lines1 dep
Open the full page for this primitive

Tree View

One tab stop for the whole tree, with the ARIA tree keyboard contract in full — including typeahead and the arrow keys mapped through the reading direction.

Navigation & Steps235 lines1 dep
Open the full page for this primitive

Tabs

One tab stop for the whole list, arrow keys mapped through the reading direction, and a manual-activation mode so arrowing past five expensive panels does not fire four requests nobody asked for.

Navigation & Steps181 linesNo deps