Accordion
A vertically stacked set of collapsible sections. Supports single and multiple expansion, controlled mode, and smooth CSS animation.
AccessibleDark ModeAnimatedSingle / MultipleControlledCompound
Install
$
npx react-principles add accordion01
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 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> // Multiple — any number of items open simultaneously <Accordion type="multiple" defaultValue={["item-1", "item-3"]}> ... </Accordion> // Controlled <Accordion type="single" value={open} onChange={(v) => setOpen(v as string)}> ... </Accordion> // Prevent collapse (collapsible=false) <Accordion type="single" collapsible={false} defaultValue="item-1"> ... </Accordion>
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
import { createContext, useContext, useState, HTMLAttributes, ButtonHTMLAttributes, ReactNode, } from "react"; import { cn } from "@/lib/utils"; // ─── Types ──────────────────────────────────────────────────────────────────── export type AccordionType = "single" | "multiple"; export interface AccordionProps { type?: AccordionType; defaultValue?: string | string[]; value?: string | string[]; onChange?: (value: string | string[]) => void; collapsible?: boolean; children: ReactNode; className?: string; } export interface AccordionItemProps extends HTMLAttributes<HTMLDivElement> { value: string; children: ReactNode; } export interface AccordionTriggerProps extends ButtonHTMLAttributes<HTMLButtonElement> { children: ReactNode; } export interface AccordionContentProps extends HTMLAttributes<HTMLDivElement> { children: ReactNode; } // ─── Context ────────────────────────────────────────────────────────────────── interface AccordionCtx { isOpen: (value: string) => boolean; toggle: (value: string) => void; } interface ItemCtx { value: string; open: boolean; } const AccordionContext = createContext<AccordionCtx | null>(null); const ItemContext = createContext<ItemCtx | null>(null); function useAccordion() { const ctx = useContext(AccordionContext); if (!ctx) throw new Error("AccordionTrigger/Content must be inside <AccordionItem>"); return ctx; } function useItem() { const ctx = useContext(ItemContext); if (!ctx) throw new Error("AccordionTrigger/Content must be inside <AccordionItem>"); return ctx; } // ─── Components ─────────────────────────────────────────────────────────────── export function Accordion({ type = "single", defaultValue, value: controlledValue, onChange, collapsible = true, children, className, }: AccordionProps) { const toSet = (v?: string | string[]): Set<string> => { if (!v) return new Set(); return new Set(Array.isArray(v) ? v : [v]); }; const [internal, setInternal] = useState<Set<string>>(() => toSet(defaultValue)); const isControlled = controlledValue !== undefined; const active = isControlled ? toSet(controlledValue) : internal; const toggle = (val: string) => { let next: Set<string>; if (type === "single") { if (active.has(val)) { next = collapsible ? new Set() : new Set([val]); } else { next = new Set([val]); } } else { next = new Set(active); if (next.has(val)) { next.delete(val); } else { next.add(val); } } if (!isControlled) setInternal(next); if (onChange) { const arr = [...next]; onChange(type === "single" ? (arr[0] ?? "") : arr); } }; const isOpen = (val: string) => active.has(val); return ( <AccordionContext.Provider value={{ isOpen, toggle }}> <div className={cn("w-full divide-y divide-slate-200 dark:divide-[#1f2937] rounded-xl border border-slate-200 dark:border-[#1f2937] overflow-hidden", className)}> {children} </div> </AccordionContext.Provider> ); } Accordion.Item = function AccordionItem({ value, children, className, ...props }: AccordionItemProps) { const { isOpen } = useAccordion(); const open = isOpen(value); return ( <ItemContext.Provider value={{ value, open }}> <div className={cn("bg-white dark:bg-[#161b22]", className)} {...props}> {children} </div> </ItemContext.Provider> ); } Accordion.Trigger = function AccordionTrigger({ children, className, ...props }: AccordionTriggerProps) { const { toggle } = useAccordion(); const { value, open } = useItem(); 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", "text-slate-900 dark:text-white", "hover:bg-slate-50 dark:hover:bg-[#1f2937] transition-colors", "focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary/40", className )} {...props} > <span>{children}</span> <svg className={cn("h-4 w-4 shrink-0 text-slate-400 transition-transform duration-200", open && "rotate-180")} viewBox="0 0 16 16" fill="none" > <path d="M4 6l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /> </svg> </button> ); } Accordion.Content = function AccordionContent({ children, className, ...props }: AccordionContentProps) { const { open } = useItem(); return ( <div style={{ display: "grid", gridTemplateRows: open ? "1fr" : "0fr", transition: "grid-template-rows 0.2s ease", }} > <div style={{ overflow: "hidden" }}> <div className={cn("px-5 pb-4 text-sm text-slate-600 dark:text-slate-400 leading-relaxed", className)} {...props} > {children} </div> </div> </div> ); }
05
Props
| Component | Prop | Type | Default | Description |
|---|---|---|---|---|
Accordion | type | "single" | "multiple" | "single" | Whether one or multiple items can be open at a time. |
Accordion | defaultValue | string | string[] | — | Initially open item(s) — uncontrolled. |
Accordion | value | string | string[] | — | Controlled open item(s). |
Accordion | onChange | (value: string | string[]) => void | — | Callback when open items change. |
Accordion | 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. |