VibeUI
ComponentsDocs
VibeUI

Components with taste, that you own. Copy the source, retint the tokens, keep every line.

Library

  • All components
  • Signature pieces
  • Text effects
  • Footers

Docs

  • Introduction
  • Installation
  • Theming
  • CLI

Project

  • GitHub
  • Agents and MCP
  • Issues
  • MIT license
© 2026 VibeUI · MIT licensed
  1. Components
  2. Navigation
  3. Accordion

Accordion

Expandable sections with smooth height animation. Single or multiple open.

NavigationClient component

A .dark class on <html> swaps the CSS variables. A tiny inline script sets it before paint, so there is no flash.

No. Copy the component file, install the four peer dependencies, and import it.

Change --accent and the canvas tokens. Everything follows.

Install

bash
npx @agents-npm-packages/vibeui add accordion --dir ./src/components/ui

Import

tsx
import { Accordion } from "@/components/ui/accordion"

API

Generated from the component's TypeScript types, so it always matches the source. * marks a required prop.

<Accordion>

PropTypeDefaultDescription
items*{ id: string; title: string; content: ReactNode; }[]—The items to render.
type"single" | "multiple""single"Whether one item or several can be open at once.
defaultOpenstring[]—Initial open state when uncontrolled.
classNamestring—Extra classes, merged with the component's own.

Source

src/components/ui/accordion.tsx
"use client";

import * as React from "react";
import { Plus } from "@phosphor-icons/react";
import { cn } from "@/lib/utils";

/** Expandable sections with smooth height animation. Single or multiple open. */
export function Accordion({
  items,
  type = "single",
  defaultOpen,
  className,
}: {
  items: { id: string; title: string; content: React.ReactNode }[];
  type?: "single" | "multiple";
  defaultOpen?: string[];
  className?: string;
}) {
  const baseId = React.useId();
  const [open, setOpen] = React.useState<string[]>(
    defaultOpen ?? (items[0] ? [items[0].id] : []),
  );

  const toggle = (id: string) =>
    setOpen((prev) =>
      prev.includes(id)
        ? prev.filter((x) => x !== id)
        : type === "single"
          ? [id]
          : [...prev, id],
    );

  return (
    <div className={cn("divide-y divide-line border-y border-line", className)}>
      {items.map((item) => {
        const isOpen = open.includes(item.id);
        return (
          <div key={item.id}>
            <h3>
              <button
                type="button"
                id={`${baseId}-${item.id}-btn`}
                aria-expanded={isOpen}
                aria-controls={`${baseId}-${item.id}-panel`}
                onClick={() => toggle(item.id)}
                className="group flex w-full items-center justify-between gap-4 py-4 text-left text-[15px] font-medium tracking-[-0.01em] text-ink"
              >
                <span className="transition-colors group-hover:text-ink-soft">
                  {item.title}
                </span>
                <Plus
                  aria-hidden
                  size={14}
                  weight="bold"
                  className={cn(
                    "shrink-0 text-muted transition-transform duration-300 ease-out",
                    isOpen && "rotate-45 text-accent",
                  )}
                />
              </button>
            </h3>
            <div
              id={`${baseId}-${item.id}-panel`}
              role="region"
              aria-labelledby={`${baseId}-${item.id}-btn`}
              className={cn(
                "grid transition-[grid-template-rows,opacity] duration-300 ease-out",
                isOpen ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0",
              )}
            >
              <div className="overflow-hidden">
                <div className="pb-4 pr-8 text-sm leading-relaxed text-muted">
                  {item.content}
                </div>
              </div>
            </div>
          </div>
        );
      })}
    </div>
  );
}

Exports Accordion. Peer dependencies: clsx, tailwind-merge, @phosphor-icons/react.

PreviousTourNextBreadcrumb