Accordion

Expandable sections; single-open by default or multiple.

import { Accordion, AccordionItem }from "@/components/ui"

Specification

Shows a list of expandable sections, single-open by default or multiple-open, with keyboard-accessible triggers.

Props (3)
Every prop this component accepts, its type, default, and how to use it.
PropTypeDefaultDescription
items*AccordionItem[]—Sections to render; each has an id, title and content.
multiplebooleanfalseAllows more than one section to be open at once.
defaultOpenstring[][]Ids of the sections open on first render.

FAQ

Everything in the free tier plus priority support.
Full source
The complete component, read from src/components/ui/accordion.tsx.
accordion.tsx
"use client";

import { useState, type ReactNode } from "react";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";

export interface AccordionItem {
  id: string;
  title: ReactNode;
  content: ReactNode;
}

interface AccordionProps {
  items: AccordionItem[];

  multiple?: boolean;

  defaultOpen?: string[];
}

export function Accordion({ items, multiple = false, defaultOpen = [] }: AccordionProps) {
  const [open, setOpen] = useState<Set<string>>(new Set(defaultOpen));

  const toggle = (id: string) => {
    setOpen((prev) => {
      const next = new Set(prev);
      if (next.has(id)) {
        next.delete(id);
      } else {
        if (!multiple) next.clear();
        next.add(id);
      }
      return next;
    });
  };

  return (
    <div className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface">
      {items.map((item) => {
        const isOpen = open.has(item.id);
        const regionId = `accordion-${item.id}`;
        return (
          <div key={item.id}>
            <button
              type="button"
              aria-expanded={isOpen}
              aria-controls={regionId}
              onClick={() => toggle(item.id)}
              className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left text-sm font-medium text-text transition-colors hover:bg-bg-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent"
            >
              <span className="min-w-0">{item.title}</span>
              <ChevronDown
                size={16}
                className={cn("shrink-0 text-text-tertiary transition-transform duration-200", isOpen && "rotate-180")}
                aria-hidden
              />
            </button>
            {isOpen && (
              <div id={regionId} role="region" className="px-4 pb-4 text-sm leading-relaxed text-text-secondary">
                {item.content}
              </div>
            )}
          </div>
        );
      })}
    </div>
  );
}

Dependencies

To use Accordion you also need the following. Copy these into your project alongside it.

Recommended folder structure

project structure
src/components/ui/
  └─ accordion.tsx
src/lib/
  └─ utils.ts

Dependency files

These supporting files aren’t components — copy each one into the matching path above.

src/lib/utils.ts
utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

/**
 * Merge class names with clsx semantics + Tailwind conflict resolution.
 * Prefer this in all components.
 */
export function cn(...inputs: ClassValue[]): string {
  return twMerge(clsx(inputs));
}

/**
 * Concatenate class names, filtering out falsy values.
 * @deprecated Prefer {@link cn} — it also resolves Tailwind class conflicts.
 */
export function cx(...args: (string | false | null | undefined)[]): string {
  return args.filter(Boolean).join(" ");
}

/**
 * Focus ring style helper.
 * @deprecated Focus rings are now handled globally via the
 * `:focus-visible` rule in `globals.css` and the `focus-visible:*`
 * utilities. This remains only for backwards compatibility.
 */
export function ring(color: string = "var(--color-accent)") {
  return { outlineColor: color };
}