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.json

Live 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

PropTypeDefaultDescription
items*AccordionItem[]Sections to render.
allowMultiplebooleanfalseWhen true, multiple sections can be open at once.
activeKeysstring[]Controlled open section keys.
onActiveKeysChange(keys: string[]) => voidFires when open section keys change.
defaultOpenKeysstring[]Initial open keys (uncontrolled).
forcedOpenKeysstring[]Keys that stay open and cannot be closed.
disabled (item)booleanDisables 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

Browse the full examples gallery