GitHub

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 accordion
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?
Type

Only one item open at a time.

Yes. Each trigger is a native <button> with aria-expanded. Keyboard navigation works out of the box — Tab moves focus and Enter/Space toggles.
Yes. Content panels expand and collapse using a CSS grid-template-rows transition from 0fr to 1fr, giving a smooth height animation without JavaScript.
Yes. Pass value and onChange to control open items externally. Omit them and use defaultValue for fully uncontrolled behaviour.
Yes. Set type="multiple". You can also set defaultValue to an array of item values to open multiple items initially.
Set collapsible={false} on the Accordion. This only applies to type="single" — the open item won't collapse when clicked again.

collapsible={false} — active item cannot be collapsed.

This component requires React 19+ and uses createContext, useState, and useContext from the standard React package.
Styling is fully Tailwind-based with Tailwind v4. Theme tokens and variants are configured in src/app/globals.css.
Uses cn() from @/lib/utils (clsx + tailwind-merge) for conditional class merging.
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

ComponentPropTypeDefaultDescription
Accordiontype"single" | "multiple""single"Whether one or multiple items can be open at a time.
AccordiondefaultValuestring | string[]Initially open item(s) — uncontrolled.
Accordionvaluestring | string[]Controlled open item(s).
AccordiononChange(value: string | string[]) => voidCallback when open items change.
AccordioncollapsiblebooleantrueWhen type=single, allow closing the open item by clicking it.
Accordion.ItemvaluestringUnique identifier for this item.
Accordion.TriggerButtonHTMLAttributesExtends all native button attributes.
Accordion.ContentHTMLAttributes<div>Animated content panel.
react-principles