Accordion
Preview
Basic
Loading…
Preview
Code
ts
import Accordion from '@/components/layout/Accordion';src/components/layout/Accordion.tsx
AI prompt
text
Build an accessible accordion component in React + TypeScript + Tailwind CSS.
Stacked sections that expand in place, one at a time or several.
## Look
- Shell: `rounded-lg border border-slate-200 dark:border-slate-700 divide-y divide-slate-200 dark:divide-slate-700`. NO `overflow-hidden` on it — clipping the shell would also clip any dropdown opened inside a section. The rounded corners come from rounding the first header (`rounded-t-lg`) and the last header while closed (`rounded-b-lg`) instead.
- Header button: `flex w-full items-center gap-2 px-3 py-2.5 text-left text-xs font-semibold text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-800/60`; optional 14px leading icon (slate-400 / dark slate-500); title `min-w-0 flex-1`; a 14px `ChevronRight` at the end (slate-400) that rotates 90° when open (`duration-200`). Focus: an INSET ring (`ring-2 ring-inset ring-indigo-400`, `relative` so it sits above neighbours). Disabled: `opacity-50 cursor-not-allowed`, no hover tint.
- Panel content: `border-t border-slate-100 dark:border-slate-800 px-3 py-3 text-xs text-slate-600 dark:text-slate-300`.
## Behaviour
- Open/close animates height with a `grid-template-rows` 0fr ↔ 1fr transition (`duration-200 ease-out`, none under reduced motion), inner wrapper `min-h-0` — no measuring. It clips only while closed and for 250ms while expanding (a timer, since `transitionend` never fires under reduced motion), so an open section never cuts off a popover. Closed panels are `inert`, keeping hidden controls out of the Tab order.
- `multiple` off: opening one closes the other; `multiple` on: independent. Clicking an open header closes it.
- `value` is ALWAYS an array of open ids, in single mode too — flipping `multiple` never changes the type a caller stores, and "all closed" is just `[]`. Controlled when `value` is passed, else internal from `defaultValue`; `onChange(openIds)` fires in both.
- Keyboard: every header stays in the Tab order (they are independent controls, unlike tabs); ArrowDown/ArrowUp move focus to the next/previous header (wrapping), Home/End to first/last, all skipping disabled items.
## API
`items: { id: string; title: ReactNode; content: ReactNode; disabled?: boolean; icon?: ComponentType<{ className?: string }> }[]`, `multiple?` (false), `value?: string[]`, `defaultValue?` ([]), `onChange?`, `headingLevel?: 2–6` (3), `className`.
## Accessibility
WAI-ARIA accordion: each header is a `<button>` inside a real `<hN>` (level from `headingLevel`, `m-0`), with `aria-expanded` and `aria-controls`; each panel is `role="region"` with `aria-labelledby` the header.
## Demo
Account (User icon) "Name, email address and sign-in preferences."; Security (Shield) "Password, two-factor authentication and active sessions."; Notifications (Bell) "Choose which events send an email or a push message."; Billing (FileText), disabled. Side by side: single uncontrolled with Account open, and multiple controlled with Account and Notifications open.
## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.Source
tsx
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { ChevronRight } from 'lucide-react';
import { cn } from '@/lib/cn';
export type AccordionItem = {
id: string;
title: React.ReactNode;
content: React.ReactNode;
disabled?: boolean;
/** An icon component (a lucide icon, say), sized by the accordion. */
icon?: React.ComponentType<{ className?: string }>;
};
export interface AccordionProps {
items: AccordionItem[];
/** Allow several sections open at once. Off, opening one closes the other. */
multiple?: boolean;
/** Controlled: the ids of the open sections. */
value?: string[];
/** Uncontrolled initial open ids. */
defaultValue?: string[];
onChange?: (openIds: string[]) => void;
/** Heading level wrapping each header button, to fit the page outline. */
headingLevel?: 2 | 3 | 4 | 5 | 6;
className?: string;
}
/**
* Stacked sections that expand in place.
*
* `value` is always an array of open ids, in single mode too. One shape for
* both modes means flipping `multiple` never changes the type a caller stores,
* and "all closed" is simply `[]` rather than a nullable string.
*
* Built to the WAI-ARIA accordion pattern: each header is a `<button>` inside a
* real heading, with `aria-expanded` and `aria-controls` pointing at a
* `role="region"` labelled by it. Every header stays in the Tab order (unlike
* Tabs' roving tabindex — an accordion's headers are independent controls),
* and Up/Down/Home/End move between them as a shortcut, skipping disabled
* ones.
*
* No `overflow-hidden` on the outer border box. Its rounded corners come from
* rounding the first and last header instead, because clipping the shell would
* also clip any dropdown opened from inside a section's content.
*/
export default function Accordion({
items,
multiple = false,
value,
defaultValue = [],
onChange,
headingLevel = 3,
className,
}: AccordionProps) {
const base = useId();
const [internal, setInternal] = useState<string[]>(defaultValue);
const open = value ?? internal;
const headers = useRef<Record<string, HTMLButtonElement | null>>({});
const Heading = `h${headingLevel}` as const;
const toggle = (id: string) => {
const isOpen = open.includes(id);
const next = isOpen ? open.filter((x) => x !== id) : multiple ? [...open, id] : [id];
if (value === undefined) setInternal(next);
onChange?.(next);
};
const onKey = (e: React.KeyboardEvent, id: string) => {
const usable = items.filter((it) => !it.disabled);
const i = usable.findIndex((it) => it.id === id);
let target: AccordionItem | undefined;
if (e.key === 'ArrowDown') target = usable[(i + 1) % usable.length];
else if (e.key === 'ArrowUp') target = usable[(i - 1 + usable.length) % usable.length];
else if (e.key === 'Home') target = usable[0];
else if (e.key === 'End') target = usable[usable.length - 1];
if (!target) return;
e.preventDefault();
headers.current[target.id]?.focus();
};
return (
<div className={cn('divide-y divide-slate-200 rounded-lg border border-slate-200 dark:divide-slate-700 dark:border-slate-700', className)}>
{items.map((it, idx) => {
const isOpen = open.includes(it.id);
const headerId = `${base}-h-${it.id}`;
const panelId = `${base}-p-${it.id}`;
const Icon = it.icon;
const last = idx === items.length - 1;
return (
<div key={it.id}>
<Heading className="m-0">
<button
ref={(el) => {
headers.current[it.id] = el;
}}
type="button"
id={headerId}
aria-expanded={isOpen}
aria-controls={panelId}
disabled={it.disabled}
onClick={() => toggle(it.id)}
onKeyDown={(e) => onKey(e, it.id)}
className={cn(
'flex w-full items-center gap-2 px-3 py-2.5 text-left text-xs font-semibold transition-colors',
'text-slate-700 hover:bg-slate-50 dark:text-slate-200 dark:hover:bg-slate-800/60',
'focus-visible:relative focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-400',
'disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent',
idx === 0 && 'rounded-t-lg',
last && !isOpen && 'rounded-b-lg',
)}
>
{Icon && <Icon className="h-3.5 w-3.5 shrink-0 text-slate-400 dark:text-slate-500" aria-hidden />}
<span className="min-w-0 flex-1">{it.title}</span>
<ChevronRight
className={cn(
'h-3.5 w-3.5 shrink-0 text-slate-400 transition-transform duration-200 dark:text-slate-500',
isOpen && 'rotate-90',
)}
aria-hidden
/>
</button>
</Heading>
<Panel id={panelId} labelledBy={headerId} open={isOpen}>
{it.content}
</Panel>
</div>
);
})}
</div>
);
}
/**
* One collapsible region. Same technique as Fieldset: a `grid-template-rows`
* 0fr↔1fr transition needs no height measuring, `inert` keeps a closed
* section's controls out of the Tab order, and the clip is on only while
* closed or mid-expand so an open section never cuts off a popover.
*/
function Panel({ id, labelledBy, open, children }: { id: string; labelledBy: string; open: boolean; children: React.ReactNode }) {
const [opening, setOpening] = useState(false);
const [prev, setPrev] = useState(open);
if (prev !== open) {
setPrev(open);
setOpening(open);
}
useEffect(() => {
if (!opening) return;
const t = setTimeout(() => setOpening(false), 250);
return () => clearTimeout(t);
}, [opening]);
return (
<div
id={id}
role="region"
aria-labelledby={labelledBy}
inert={!open}
className={cn(
'grid transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none',
open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
)}
>
<div className={cn('min-h-0', (!open || opening) && 'overflow-hidden')}>
<div className="border-t border-slate-100 px-3 py-3 text-xs text-slate-600 dark:border-slate-800 dark:text-slate-300">
{children}
</div>
</div>
</div>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
items* | AccordionItem[] | — | |
multiple | boolean | false | Allow several sections open at once. Off, opening one closes the other. |
value | string[] | — | Controlled: the ids of the open sections. |
defaultValue | string[] | [] | Uncontrolled initial open ids. |
onChange | (openIds: string[]) => void | — | |
headingLevel | 2 | 3 | 4 | 5 | 6 | 3 | Heading level wrapping each header button, to fit the page outline. |
className | string | — |