Composite
Accordion
Accordion renders a bordered card list from an `items` array. Supports single or multiple open panels, controlled `activeKeys`, chevron triggers, and animated expand/collapse.
Installation
npx shadcn add https://rck.vibeboxph.com//r/accordion.jsonLive preview & controls
Controls
<Accordion items={[{ id: "item-1", title: "Question", content: "Answer" }]} />Anatomy
AccordionThe root — pass an `items` array of `{ id, title, content }` sections.AccordionItem (data)One section definition: `title`, `content`, optional `id`, `disabled`, `defaultOpen`.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| items* | AccordionItem[] | — | Sections to render. |
| allowMultiple | boolean | false | When true, multiple sections can be open at once. |
| activeKeys | string[] | — | Controlled open section keys. |
| onActiveKeysChange | (keys: string[]) => void | — | Fires when open section keys change. |
| defaultOpenKeys | string[] | — | Initial open keys (uncontrolled). |
| forcedOpenKeys | string[] | — | Keys that stay open and cannot be closed. |
| disabled (item) | boolean | — | Disables a single item. |
Accessibility
- Triggers are real buttons inside an `<h3>`, with `aria-expanded` and `aria-controls`.
- Panels use `role="region"` with `aria-labelledby` pointing back at their trigger.
- Arrow Up/Down moves focus between triggers; Home/End jump to the first/last.
Source
registry/rck/ui/accordion.tsx
"use client";
import * as React from "react";
import * as AccordionPrimitive from "@/registry/rck/primitives/accordion";
import { ChevronRight } from "@/lib/icons";
import { cn } from "@/lib/utils";
export interface AccordionItem {
id?: string;
title: React.ReactNode;
content: React.ReactNode;
disabled?: boolean;
defaultOpen?: boolean;
}
export interface AccordionProps {
items: AccordionItem[];
/** When false (default), only one section can be open at a time. */
allowMultiple?: boolean;
/** Controlled open keys. When provided, the accordion is fully controlled. */
activeKeys?: string[];
onActiveKeysChange?: (keys: string[]) => void;
/** Uncontrolled initial open keys. Ignored when `activeKeys` is provided. */
defaultOpenKeys?: string[];
/** Keys that stay open and cannot be closed by the user. */
forcedOpenKeys?: string[];
className?: string;
}
function getItemKey(item: AccordionItem, index: number) {
return item.id ?? String(index);
}
function keysToPrimitiveValue(keys: string[], allowMultiple: boolean): string | string[] {
if (allowMultiple) return keys;
return keys[0] ?? "";
}
function primitiveValueToKeys(
value: string | string[],
allowMultiple: boolean,
): string[] {
if (allowMultiple) return Array.isArray(value) ? value : value ? [value] : [];
return typeof value === "string" && value ? [value] : [];
}
function AccordionItems({ items }: { items: AccordionItem[] }) {
return (
<>
{items.map((item, index) => {
const key = getItemKey(item, index);
return (
<AccordionPrimitive.Item
key={key}
value={key}
disabled={item.disabled}
data-slot="accordion-item"
className="data-[state=open]:bg-surface-2/50 transition-colors"
>
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
disabled={item.disabled}
className={cn(
"group/trigger flex w-full items-center gap-3 px-4 py-3 text-left text-sm font-semibold transition-colors outline-none",
"hover:bg-surface-2/80 focus-visible:ring-ring/50 focus-visible:ring-offset-card focus-visible:ring-2 focus-visible:ring-offset-2",
"disabled:pointer-events-none disabled:opacity-50",
)}
>
<ChevronRight
className="text-muted-foreground size-4 shrink-0 transition-transform duration-200 group-data-[state=open]/trigger:rotate-90"
aria-hidden="true"
/>
<span className="flex-1">{item.title}</span>
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
<AccordionPrimitive.Content
data-slot="accordion-content"
className={cn(
"overflow-hidden text-sm leading-relaxed data-[state=closed]:h-0",
"data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down",
)}
>
<div className="text-muted-foreground px-4 pt-0 pb-4">{item.content}</div>
</AccordionPrimitive.Content>
</AccordionPrimitive.Item>
);
})}
</>
);
}
export function Accordion({
items,
allowMultiple = false,
activeKeys,
onActiveKeysChange,
defaultOpenKeys,
forcedOpenKeys = [],
className,
}: AccordionProps) {
const isControlled = activeKeys !== undefined;
const hasForcedOpen = forcedOpenKeys.length > 0;
const useWrapperState = isControlled || hasForcedOpen;
const initialKeys = React.useMemo(() => {
const defaults =
defaultOpenKeys ??
items
.map((item, index) => (item.defaultOpen ? getItemKey(item, index) : null))
.filter((key): key is string => key != null);
return [...new Set([...defaults, ...forcedOpenKeys])];
}, [defaultOpenKeys, forcedOpenKeys, items]);
const [internalKeys, setInternalKeys] = React.useState<string[]>(() =>
initialKeys.filter((key) => !forcedOpenKeys.includes(key)),
);
const userKeys = isControlled ? activeKeys : internalKeys;
const openKeys = React.useMemo(
() => [...new Set([...(userKeys ?? []), ...forcedOpenKeys])],
[userKeys, forcedOpenKeys],
);
function handleValueChange(nextValue: string | string[]) {
const nextUserKeys = primitiveValueToKeys(nextValue, allowMultiple).filter(
(key) => !forcedOpenKeys.includes(key),
);
if (!isControlled) setInternalKeys(nextUserKeys);
onActiveKeysChange?.([...new Set([...nextUserKeys, ...forcedOpenKeys])]);
}
const notifyChange = React.useCallback(
(nextValue: string | string[]) => {
onActiveKeysChange?.(primitiveValueToKeys(nextValue, allowMultiple));
},
[allowMultiple, onActiveKeysChange],
);
const rootProps = useWrapperState
? {
value: keysToPrimitiveValue(openKeys, allowMultiple),
onValueChange: handleValueChange,
}
: {
defaultValue: keysToPrimitiveValue(initialKeys, allowMultiple),
onValueChange: notifyChange,
};
return (
<AccordionPrimitive.Root
type={allowMultiple ? "multiple" : "single"}
collapsible={!allowMultiple}
{...rootProps}
className={cn(
"border-border bg-card divide-border w-full divide-y overflow-hidden rounded-lg border",
className,
)}
>
<AccordionItems items={items} />
</AccordionPrimitive.Root>
);
}
Related examples
Default stylingFAQ-style single mode and multi-open layout.
ControlledOpen state driven by external buttons.
Keyboard behaviorMultiple-open accordion for exploring keyboard navigation.