stream/ui
Browse the library

Accordion

Expandable sections built on native details and summary, with an exclusive mode that keeps one open at a time.

stableAccordion.tsx

Examples

Basic

How do I install it?
Run bun add @streamuilib/react, then import the theme in your CSS.
Do I need Tailwind?
Yes, Tailwind CSS v4. The components are styled with its classes.
Can I use it commercially?
Yes. stream/ui is MIT licensed.
accordion/basic.tsx
import { Accordion } from "@streamuilib/react";

const items = [
  { id: "install", title: "How do I install it?", content: "Run bun add @streamuilib/react, then import the theme in your CSS." },
  { id: "tailwind", title: "Do I need Tailwind?", content: "Yes, Tailwind CSS v4. The components are styled with its classes." },
  { id: "license", title: "Can I use it commercially?", content: "Yes. stream/ui is MIT licensed." },
];

export default function AccordionBasic() {
  return <Accordion items={items} defaultOpen={["install"]} className="w-full max-w-md" />;
}

One open at a time

exclusive uses the native details name attribute, so opening one closes the others.

1. Create an account
Sign up with GitHub or email.
2. Create a project
Name it and pick a region close to your users.
3. Deploy
Push to main and your preview URL is ready in a minute.
accordion/exclusive.tsx
import { Accordion } from "@streamuilib/react";

const steps = [
  { id: "account", title: "1. Create an account", content: "Sign up with GitHub or email." },
  { id: "project", title: "2. Create a project", content: "Name it and pick a region close to your users." },
  { id: "deploy", title: "3. Deploy", content: "Push to main and your preview URL is ready in a minute." },
];

export default function AccordionExclusive() {
  return <Accordion items={steps} exclusive defaultOpen={["account"]} className="w-full max-w-md" />;
}

Installation

bun add @streamuilib/react
usage.tsx
import { Accordion } from "@streamuilib/react";

<Accordion
  exclusive
  items={[
    { id: "install", title: "How do I install it?", content: "…" },
    { id: "license", title: "Is it free?", content: "…" },
  ]}
/>

API

PropTypeDefaultDescription
items{ id: string; title: ReactNode; content: ReactNode }[]—Sections in order. id must be unique.
exclusivebooleanfalseOnly one section open at a time.
defaultOpenstring[][]Ids open on first render.
classNamestring—Classes for the wrapper.

Accessibility

  • Native <details>/<summary>: Enter and Space toggle, and screen readers announce expanded or collapsed.
  • Works without JavaScript, and the browser's find-in-page can open a section to show a match.
  • Closed content stays in the page, so it isn't lost to search engines.

Source

packages/react/src/Accordion.tsx
import { useId, type ReactNode } from "react";
import { Plus } from "lucide-react";
import { cn } from "./cn";

export type AccordionItem = { id: string; title: ReactNode; content: ReactNode };

export type AccordionProps = {
  items: AccordionItem[];
  /** Only one item open at a time (native exclusive <details>). */
  exclusive?: boolean;
  /** Ids of items open on first render. */
  defaultOpen?: string[];
  className?: string;
};

/**
 * Built on native <details>/<summary>: keyboard support, screen reader
 * state, find-in-page, and no JavaScript needed. `exclusive` uses the
 * details `name` attribute, which closes siblings in the same group.
 */
export function Accordion({ items, exclusive = false, defaultOpen = [], className }: AccordionProps) {
  const group = useId();

  return (
    <div className={cn("flex flex-col gap-3", className)}>
      {items.map((item) => (
        <details
          key={item.id}
          name={exclusive ? group : undefined}
          open={defaultOpen.includes(item.id)}
          className="group border-2 border-ink bg-paper-raised shadow-brutal-sm open:bg-lime-soft"
        >
          <summary className="flex cursor-pointer list-none items-center justify-between gap-4 px-5 py-4 font-bold [&::-webkit-details-marker]:hidden">
            {item.title}
            <Plus
              aria-hidden
              className="size-5 shrink-0 transition-transform duration-200 group-open:rotate-45 motion-reduce:transition-none"
              strokeWidth={2.5}
            />
          </summary>
          <div className="border-t-2 border-ink px-5 py-4 leading-relaxed">{item.content}</div>
        </details>
      ))}
    </div>
  );
}