Display

Accordion

A zero-dependency accessible disclosure list — single or multiple expansion, grid-rows animation, and full APG keyboard support.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"

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

export interface AccordionProps {
  items: AccordionItem[]

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/accordion.json

Prompt

Build a React + TypeScript + Tailwind "Accordion" component (lucide-react
ChevronDown), zero other runtime dependencies.

Contract
- Export items: { id: string; title: ReactNode; content: ReactNode }[].
- type?: "single" | "multiple" (default "single") — single closes the
  previously open item when a new one opens; multiple toggles items
  independently.
- defaultOpen?: string[] — item ids expanded on mount.
- className?: string, merged onto the root via cn().

Behavior
- Each item renders an <h3> wrapping a <button> trigger row (title + a
  ChevronDown icon that rotates 180deg when open) and a content panel below
  it. Clicking a trigger toggles that item's membership in an internal
  `open: string[]` state array — in "single" mode setting it replaces the
  array with just that id (or empties it if it was already the only open
  item); in "multiple" mode it pushes/removes freely.
- aria: trigger has aria-expanded + aria-controls pointing at the panel id;
  panel has role="region" + aria-labelledby pointing at the trigger id.
  Ids are namespaced with useId() so multiple Accordion instances on one
  page never collide.
- Keyboard: buttons are native, so Tab/Shift+Tab, Enter and Space already
  work. Add ArrowDown/ArrowUp to move focus to the next/previous trigger
  (wrapping at the ends) and Home/End to jump to the first/last trigger —
  the WAI-ARIA APG accordion pattern. Arrow keys only move focus, they
  never change what's open.
- Expand animation: the panel wrapper animates grid-template-rows from
  0fr to 1fr with an inner overflow-hidden div, so height animates without
  measuring pixels; instant under prefers-reduced-motion instead of
  animating.

Rendering & styling
- Semantic tokens only: text-muted-foreground for the chevron and content
  text, focus-visible:ring-ring for the focus ring, border-b between items.
  cn() merges the consumer className into the root.
- Trigger row: py-4 text-sm font-medium, hover:underline (shadcn's
  disclosure-row convention), chevron shrink-0 with a 200ms transform
  transition (motion-reduce:transition-none).
- Content: text-sm text-muted-foreground pb-4, so it sits flush under the
  trigger once expanded.

Customization levers
- Icon: swap ChevronDown for another lucide icon, or move it to the left
  of the title — it's one JSX node, not baked into the toggle logic.
- Visual style: drop the border-b divider list for individually rounded
  bg-card items (a "boxed" accordion) by moving the border from the row
  wrapper to each item's own container.
- Controlled usage: lift `open` out as value/onValueChange props instead of
  internal state when a parent needs to read or drive which items are
  expanded (e.g. syncing with a URL hash).
- Density: the py-4 row padding and pb-4 content padding are the only two
  spacing knobs — tighten both together for compact settings panels.

Concepts

  • Disclosure pattern — a header hides or reveals its own content block; the accordion is just a list of these paired with a rule for how many stay open at once.
  • Single vs multiple expansiontype swaps that rule: "single" replaces the open-id array on click (exclusive), "multiple" toggles membership freely (independent).
  • grid-template-rows animation — animating 0fr → 1fr on the panel wrapper (with an inner overflow-hidden) expands to the content's natural height without ever measuring pixels in JS, and collapses to an instant snap under prefers-reduced-motion.
  • APG keyboard navigation — Arrow keys move focus between trigger buttons (wrapping at the ends), Home/End jump to the first/last — they never toggle content, matching the WAI-ARIA Accordion pattern instead of a custom scheme.
  • Collision-safe idsuseId() prefixes every trigger/panel id pair, so dropping two <Accordion> instances on the same page never produces duplicate DOM ids for aria-controls/aria-labelledby.

On This Page