44 / Controls
Accordion
A list of disclosures built on native details elements, so find-in-page and the browser do the work.
Is it free to use?
Will it match my theme?
Registry or npm?
"use client"
import { Accordion } from "mischief-ui/accordion"
const items = [
{
id: "licence",
title: "Is it free to use?",
content:
"Yes. Everything here is MIT licensed, for personal and commercial work alike. Keep the notice and you are done.",
},
{
id: "theme",
title: "Will it match my theme?",
content:
"Components use semantic shadcn tokens, so they inherit whatever background, foreground, and border you already have.",
},
{
id: "registry",
title: "Registry or npm?",
content:
"Take the source through the registry when you plan to change it. Install from npm when you would rather have versioned updates.",
},
]
export function AccordionDemo() {
return (
<div className="w-full max-w-lg">
<Accordion items={items} defaultOpen={["licence"]} />
</div>
)
}Installation
Copy the source into your project, or keep it behind a package.
npx shadcn@latest add Tinkerers-Labs/mischief-ui/accordionimport { Accordion } from "mischief-ui/accordion"Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.
"use client" import * as React from "react"import { Plus } from "lucide-react" import { cn } from "@/lib/utils" export type AccordionItem = { id: string title: React.ReactNode content: React.ReactNode} export type AccordionProps = Omit<Usage
export function Faq() {
return (
<Accordion
items={questions}
defaultOpen={["licence"]}
/>
)
}Why native disclosures
Each row is a details element with a summary, rather than a button and a div wired together with state. That is not nostalgia: it means the open and closed state, the keyboard handling, and the accessible relationship between the two halves come from the browser, and none of it can drift as the component changes.
It also means collapsed answers are still in the page and still findable. Pressing find-in-page on a word inside a closed panel scrolls to it and opens the panel, which no scripted accordion does for free.
One at a time
Exclusivity comes from giving every details element the same name attribute, which the browser then enforces -- opening one closes the others, with no state of ours involved. Pass exclusive={false} and the name is dropped, so any number can be open at once.
In a browser too old to know the name attribute, nothing breaks: the panels simply all stay open, which is the right thing to degrade to. Set defaultOpen to the ids that should start open, and leave it out for a set that starts closed.
<Accordion
items={questions}
defaultOpen={["licence"]}
onToggle={(id, open) => open && track("faq_opened", { id })}
/>API
itemsAccordionItem[]Each with an id, a title, and content.exclusivebooleanKeeps one panel open at a time. Defaults to true.defaultOpenstring[]Ids open on first render.iconReactNodeReplaces the plus marker, which rotates when open.onToggle(id: string, open: boolean) => voidCalled whenever a panel opens or closes.AccordionItem
idstringUnique within the set. What defaultOpen and onToggle name.titleReactNodeThe summary line.contentReactNodeThe panel, which stays in the page while closed.Accessibility
Open and closed state, keyboard handling, and expansion during find-in-page all come from the native disclosure element rather than scripted state, so the panel content stays searchable while collapsed. Exclusivity uses the shared name attribute for the same reason. Summaries are 44px targets with a visible focus ring, and the marker is decoration the screen reader skips.