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/reactusage.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
| Prop | Type | Default | Description |
|---|---|---|---|
| items | { id: string; title: ReactNode; content: ReactNode }[] | — | Sections in order. id must be unique. |
| exclusive | boolean | false | Only one section open at a time. |
| defaultOpen | string[] | [] | Ids open on first render. |
| className | string | — | 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>
);
}