Disclosure
Accordion, Collapsible and Tabs. Show and hide content with the keyboard maps users expect. Built on Base UI.
Usage
import {
Accordion, AccordionItem, AccordionTrigger, AccordionContent,
Collapsible, CollapsibleTrigger, CollapsibleContent,
Tabs, TabsList, TabsTrigger, TabsContent,
} from "@azmr/ui/disclosure";These are interactive, so they are client components: import them from a file (or a tree) that is already "use client", or use them from a client page.
Collapsible
One region that shows and hides. Use it for a single "show more".
import { Button } from "@azmr/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@azmr/ui/disclosure";
export default function CollapsibleBasic() {
return (
<Collapsible className="max-w-md">
<CollapsibleTrigger render={<Button variant="outline">Show advanced options</Button>} />
<CollapsibleContent className="pt-3 text-sm text-muted-foreground">
Advanced options live here. The panel animates its height and is instant when the visitor
prefers reduced motion.
</CollapsibleContent>
</Collapsible>
);
}The trigger is a real <button> with aria-expanded and aria-controls. Pass render={<Button … />} to use your own element.
Accordion
A stack of collapsible sections. One section is open at a time; add multiple to allow several.
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@azmr/ui/disclosure";
export default function AccordionBasic() {
return (
<div className="flex w-full max-w-lg flex-col gap-8">
<Accordion defaultValue={["billing"]} aria-label="Billing questions">
<AccordionItem value="billing">
<AccordionTrigger>How does billing work?</AccordionTrigger>
<AccordionContent>
You are billed monthly in NZD. Invoices are emailed on the first of each month.
</AccordionContent>
</AccordionItem>
<AccordionItem value="cancel">
<AccordionTrigger>Can I cancel at any time?</AccordionTrigger>
<AccordionContent>
Yes. Your plan runs to the end of the period you paid for.
</AccordionContent>
</AccordionItem>
<AccordionItem value="legacy" disabled>
<AccordionTrigger>Legacy plans (unavailable)</AccordionTrigger>
<AccordionContent>Not shown.</AccordionContent>
</AccordionItem>
</Accordion>
{/* `multiple` lets several sections stay open at once. */}
<Accordion multiple defaultValue={["a", "b"]} aria-label="Release notes">
<AccordionItem value="a">
<AccordionTrigger headingLevel={4}>New</AccordionTrigger>
<AccordionContent>Tabs, accordion and collapsible.</AccordionContent>
</AccordionItem>
<AccordionItem value="b">
<AccordionTrigger headingLevel={4}>Fixed</AccordionTrigger>
<AccordionContent>Scoped themes now work when light-dark() is lowered.</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
}Each trigger sits inside a heading so assistive technology can list the sections. It defaults to an <h3>: set headingLevel to fit the page outline. defaultValue (uncontrolled) or value and onValueChange (controlled) take an array of item values, even when only one can be open.
Tabs
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@azmr/ui/disclosure";
export default function TabsBasic() {
return (
<div className="flex w-full max-w-lg flex-col gap-8">
<Tabs defaultValue="overview">
<TabsList aria-label="Account sections">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="billing">Billing</TabsTrigger>
<TabsTrigger value="team" disabled>
Team
</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="p-2 text-sm">
A summary of your account.
</TabsContent>
<TabsContent value="billing" className="p-2 text-sm">
Invoices and payment methods.
</TabsContent>
<TabsContent value="team" className="p-2 text-sm">
Not available on your plan.
</TabsContent>
</Tabs>
<Tabs defaultValue="profile" orientation="vertical">
<TabsList aria-label="Settings">
<TabsTrigger value="profile">Profile</TabsTrigger>
<TabsTrigger value="security">Security</TabsTrigger>
</TabsList>
<TabsContent value="profile" className="p-2 text-sm">
Name, photo and contact details.
</TabsContent>
<TabsContent value="security" className="p-2 text-sm">
Password and two-factor sign-in.
</TabsContent>
</Tabs>
</div>
);
}Give the TabsList an accessible name with aria-label. Tabs are selected manually by default: arrows move focus, Enter or Space selects. That is the safer default when a panel is expensive to render or load. Add activateOnFocus to TabsList to select as you arrow. Disabled tabs stay focusable (and are announced as disabled) so people can discover them; they cannot be selected.
Panels are focusable (tabindex="0"), so a keyboard user can reach and scroll a panel that has no controls in it. Only the selected panel is in the DOM unless you pass keepMounted.
Keep the tab in the URL
useUrlTab stores the selected tab in a search parameter, so a tab can be linked to and reloaded, and Back restores it.
?demo-tab=api. Reload, or press Back."use client";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@azmr/ui/disclosure";
import { useUrlTab } from "@azmr/ui/hooks";
const VALUES = ["usage", "api"] as const;
export default function TabsUrl() {
const [tab, setTab] = useUrlTab({ param: "demo-tab", values: VALUES, defaultValue: "usage" });
return (
<Tabs value={tab} onValueChange={(next) => setTab(next as (typeof VALUES)[number])}>
<TabsList aria-label="Example">
<TabsTrigger value="usage">Usage</TabsTrigger>
<TabsTrigger value="api">API</TabsTrigger>
</TabsList>
<TabsContent value="usage" className="p-2 text-sm">
Select API, then look at the address bar: <code>?demo-tab=api</code>. Reload, or press Back.
</TabsContent>
<TabsContent value="api" className="p-2 text-sm">
The tab is in the URL, so it can be linked to.
</TabsContent>
</Tabs>
);
}const [tab, setTab] = useUrlTab({ values: ["usage", "api"], defaultValue: "usage" });
<Tabs value={tab} onValueChange={(v) => setTab(v as typeof tab)}>…</Tabs>- The URL is untrusted input. A value that is not in
valuesis ignored and the default is used. - The default tab has no parameter, so URLs stay clean. Other parameters and the hash are kept.
history: "replace"(default) rewrites the current entry. Use"push"if each tab should be a Back step.- On the server and during hydration it returns the default, so there is no mismatch.
- It is router-agnostic: it reads and writes
window.locationand the History API. In a framework with its own router you can driveTabsfrom that router's search params instead.
Motion
Accordion and Collapsible panels animate their height. Base UI measures the panel and exposes --accordion-panel-height (or --collapsible-panel-height); the styles transition from 0 to that height with --motion-duration-base. Under prefers-reduced-motion those tokens collapse to near zero, so panels open and close instantly.
The original plan was a grid-template-rows trick with no JavaScript measuring. Base UI already measures the panel to keep hidden and aria-expanded correct, and a second mechanism fighting it is how animations get stuck, so the shipped components use Base UI's own height variable.
Props
Prop
Type
The rest are Base UI's props, passed through. className on AccordionContent and CollapsibleContent styles an inner wrapper, so padding never fights the height animation.
Accessibility
- Accordion triggers are buttons in headings with
aria-expandedandaria-controls. - Tabs follow the WAI-ARIA tabs pattern:
tablist,tabwitharia-selected,tabpanelwitharia-labelledby, and a roving tab stop. - Focus rings are full strength.
- Chevron icons are decorative (
aria-hidden).
Accordion keys
| Key | Action |
|---|---|
| Tab | Moves to the next trigger (or the first focusable element in an open panel). |
| EnterorSpace | Opens or closes the focused section. |
| ArrowDownorArrowUp | Moves focus to the next or previous trigger and wraps. It does not open anything. |
| HomeorEnd | Moves focus to the first or last trigger. |
Base UI 1.9 declares loopFocus but does not implement arrow navigation for the accordion, so @azmr/ui adds it. Keys pressed inside a panel (in a text field, say) are never taken, and a nested accordion handles its own keys.
Tabs keys
| Key | Action |
|---|---|
| Tab | Enters the tab list at the selected tab, then moves on to the panel. |
| ArrowRightorArrowLeft | Moves focus between tabs and wraps (ArrowDown and ArrowUp when vertical). |
| HomeorEnd | Moves focus to the first or last tab. |
| EnterorSpace | Selects the focused tab (not needed with activateOnFocus). |