Accordion
A vertically stacked set of collapsible sections. Supports single and multiple expansion, controlled mode, and smooth CSS animation.
AccessibleDark ModeAnimatedSingle / MultipleControlledCompound
01
Theme Preview
First item open, remaining items collapsed — forced light and dark styling.
Light
Is it accessible?
Yes. Uses aria-expanded and keyboard-navigable buttons.
Is it animated?
Controlled mode?
Dark
Is it accessible?
Yes. Uses aria-expanded and keyboard-navigable buttons.
Is it animated?
Controlled mode?
02
Live Demo
Type
Only one item open at a time.
collapsible={false} — active item cannot be collapsed.
03
Code Snippet
src/ui/Accordion.tsx
import { Accordion } from "@/ui/Accordion"; // Single — only one item open at a time <Accordion.Root type="single" defaultValue="item-1"> <Accordion.Item value="item-1"> <Accordion.Trigger>Is it accessible?</Accordion.Trigger> <Accordion.Content> Yes. It uses aria-expanded and keyboard-navigable buttons. </Accordion.Content> </Accordion.Item> <Accordion.Item value="item-2"> <Accordion.Trigger>Is it animated?</Accordion.Trigger> <Accordion.Content> Yes. Content expands with a CSS grid-template-rows transition. </Accordion.Content> </Accordion.Item> </Accordion.Root> // Multiple — any number of items open simultaneously <Accordion.Root type="multiple" defaultValue={["item-1", "item-3"]}> ... </Accordion.Root> // Controlled <Accordion.Root type="single" value={open} onChange={(v) => setOpen(v as string)}> ... </Accordion.Root> // Prevent collapse (collapsible=false) <Accordion.Root type="single" collapsible={false} defaultValue="item-1"> ... </Accordion.Root>
Flat exports seperti AccordionItem, AccordionTrigger, danAccordionContent tetap didukung untuk migrasi bertahap.
04
Copy-Paste (Single File)
Snippet ini self-contained dengan context + animation logic di satu file agar minim setup.
Accordion.tsx
"use client"; import { createContext, useContext, useState, type ButtonHTMLAttributes, type HTMLAttributes, type ReactNode, } from "react"; type ClassValue = string | false | null | undefined; const cn = (...classes: ClassValue[]) => classes.filter(Boolean).join(" "); type AccordionType = "single" | "multiple"; interface AccordionProps { type?: AccordionType; defaultValue?: string | string[]; value?: string | string[]; onChange?: (value: string | string[]) => void; collapsible?: boolean; children: ReactNode; className?: string; } interface AccordionItemProps extends HTMLAttributes<HTMLDivElement> { value: string; children: ReactNode; } interface AccordionContextValue { isOpen: (value: string) => boolean; toggle: (value: string) => void; } interface ItemContextValue { value: string; open: boolean; } const AccordionContext = createContext<AccordionContextValue | null>(null); const ItemContext = createContext<ItemContextValue | null>(null); function useAccordionContext() { const context = useContext(AccordionContext); if (!context) throw new Error("Accordion sub-components must be used inside <Accordion.Root>"); return context; } function useAccordionItem() { const context = useContext(ItemContext); if (!context) throw new Error("Accordion sub-components must be used inside <Accordion.Item>"); return context; } function AccordionRoot({ type = "single", defaultValue, value: controlledValue, onChange, collapsible = true, children, className, }: AccordionProps) { const toSet = (next?: string | string[]) => { if (!next) return new Set<string>(); return new Set(Array.isArray(next) ? next : [next]); }; const [internal, setInternal] = useState<Set<string>>(() => toSet(defaultValue)); const isControlled = controlledValue !== undefined; const active = isControlled ? toSet(controlledValue) : internal; const toggle = (nextValue: string) => { let next: Set<string>; if (type === "single") { if (active.has(nextValue)) { next = collapsible ? new Set() : new Set([nextValue]); } else { next = new Set([nextValue]); } } else { next = new Set(active); if (next.has(nextValue)) next.delete(nextValue); else next.add(nextValue); } if (!isControlled) setInternal(next); if (onChange) { const values = [...next]; onChange(type === "single" ? (values[0] ?? "") : values); } }; return ( <AccordionContext.Provider value={{ isOpen: (v) => active.has(v), toggle }}> <div className={cn("w-full overflow-hidden rounded-xl border border-slate-200", className)}>{children}</div> </AccordionContext.Provider> ); } function AccordionItem({ value, className, children, ...props }: AccordionItemProps) { const { isOpen } = useAccordionContext(); const open = isOpen(value); return ( <ItemContext.Provider value={{ value, open }}> <div className={cn("border-b border-slate-200 bg-white last:border-b-0", className)} {...props}> {children} </div> </ItemContext.Provider> ); } function AccordionTrigger({ children, className, ...props }: ButtonHTMLAttributes<HTMLButtonElement>) { const { toggle } = useAccordionContext(); const { value, open } = useAccordionItem(); return ( <button type="button" aria-expanded={open} onClick={() => toggle(value)} className={cn("flex w-full items-center justify-between px-5 py-4 text-left text-sm font-medium", className)} {...props} > <span>{children}</span> <span className={cn("transition-transform", open && "rotate-180")}>⌄</span> </button> ); } function AccordionContent({ children, className, ...props }: HTMLAttributes<HTMLDivElement>) { const { open } = useAccordionItem(); return ( <div style={{ display: "grid", gridTemplateRows: open ? "1fr" : "0fr", transition: "grid-template-rows .2s ease" }}> <div style={{ overflow: "hidden" }}> <div className={cn("px-5 pb-4 text-sm text-slate-600", className)} {...props}> {children} </div> </div> </div> ); } type AccordionCompound = typeof AccordionRoot & { Root: typeof AccordionRoot; Item: typeof AccordionItem; Trigger: typeof AccordionTrigger; Content: typeof AccordionContent; }; export const Accordion = Object.assign(AccordionRoot, { Root: AccordionRoot, Item: AccordionItem, Trigger: AccordionTrigger, Content: AccordionContent, }) as AccordionCompound;
05
Props
| Component | Prop | Type | Default | Description |
|---|---|---|---|---|
Accordion.Root | type | "single" | "multiple" | "single" | Whether one or multiple items can be open at a time. |
Accordion.Root | defaultValue | string | string[] | — | Initially open item(s) — uncontrolled. |
Accordion.Root | value | string | string[] | — | Controlled open item(s). |
Accordion.Root | onChange | (value: string | string[]) => void | — | Callback when open items change. |
Accordion.Root | collapsible | boolean | true | When type=single, allow closing the open item by clicking it. |
Accordion.Item | value | string | — | Unique identifier for this item. |
Accordion.Trigger | — | ButtonHTMLAttributes | — | Extends all native button attributes. |
Accordion.Content | — | HTMLAttributes<div> | — | Animated content panel. |