Accordion
Expandable sections; single-open by default or multiple.
import { Accordion, AccordionItem }from "@/components/ui"
Specification
Shows a list of expandable sections, single-open by default or multiple-open, with keyboard-accessible triggers.
Props (3)
Every prop this component accepts, its type, default, and how to use it.
| Prop | Type | Default | Description |
|---|---|---|---|
| items* | AccordionItem[] | — | Sections to render; each has an id, title and content. |
| multiple | boolean | false | Allows more than one section to be open at once. |
| defaultOpen | string[] | [] | Ids of the sections open on first render. |
FAQ
Everything in the free tier plus priority support.
Full source
The complete component, read from
src/components/ui/accordion.tsx.accordion.tsx
"use client";
import { useState, type ReactNode } from "react";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
export interface AccordionItem {
id: string;
title: ReactNode;
content: ReactNode;
}
interface AccordionProps {
items: AccordionItem[];
multiple?: boolean;
defaultOpen?: string[];
}
export function Accordion({ items, multiple = false, defaultOpen = [] }: AccordionProps) {
const [open, setOpen] = useState<Set<string>>(new Set(defaultOpen));
const toggle = (id: string) => {
setOpen((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
if (!multiple) next.clear();
next.add(id);
}
return next;
});
};
return (
<div className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface">
{items.map((item) => {
const isOpen = open.has(item.id);
const regionId = `accordion-${item.id}`;
return (
<div key={item.id}>
<button
type="button"
aria-expanded={isOpen}
aria-controls={regionId}
onClick={() => toggle(item.id)}
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left text-sm font-medium text-text transition-colors hover:bg-bg-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent"
>
<span className="min-w-0">{item.title}</span>
<ChevronDown
size={16}
className={cn("shrink-0 text-text-tertiary transition-transform duration-200", isOpen && "rotate-180")}
aria-hidden
/>
</button>
{isOpen && (
<div id={regionId} role="region" className="px-4 pb-4 text-sm leading-relaxed text-text-secondary">
{item.content}
</div>
)}
</div>
);
})}
</div>
);
}Dependencies
To use Accordion you also need the following. Copy these into your project alongside it.
Recommended folder structure
project structure
src/components/ui/ └─ accordion.tsx src/lib/ └─ utils.ts
Dependency files
These supporting files aren’t components — copy each one into the matching path above.
src/lib/utils.ts
utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/**
* Merge class names with clsx semantics + Tailwind conflict resolution.
* Prefer this in all components.
*/
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
/**
* Concatenate class names, filtering out falsy values.
* @deprecated Prefer {@link cn} — it also resolves Tailwind class conflicts.
*/
export function cx(...args: (string | false | null | undefined)[]): string {
return args.filter(Boolean).join(" ");
}
/**
* Focus ring style helper.
* @deprecated Focus rings are now handled globally via the
* `:focus-visible` rule in `globals.css` and the `focus-visible:*`
* utilities. This remains only for backwards compatibility.
*/
export function ring(color: string = "var(--color-accent)") {
return { outlineColor: color };
}