Accordion
A vertically stacked set of interactive headings that each reveal a section of content.
Standard shipping takes 3–5 business days. Express delivery arrives in 1–2 business days.
Orders placed before noon ship the same day.
Return any item within 30 days of delivery. Opened items may be subject to a 10% restocking fee.
Yes, we ship to over 40 countries. International orders usually arrive within 7–14 business days.
Gift cards are coming soon.
Open: shipping
Codeapp/demos/public/accordion.tsx
import { clientEntry, type Handle } from 'remix/component'
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@stemsui/registry/ui/accordion.tsx'
export const AccordionDemo = clientEntry(import.meta.url, function AccordionDemo(handle: Handle) {
let open: string[] = ['shipping']
return () => (
<div class="flex w-full max-w-lg flex-col gap-4">
<Accordion
defaultValue={['shipping']}
hiddenUntilFound
onValueChange={(value) => {
open = value
void handle.update()
}}
>
<AccordionItem value="shipping">
<AccordionTrigger>How long does shipping take?</AccordionTrigger>
<AccordionContent>
<p>
Standard shipping takes 3–5 business days. Express delivery arrives in 1–2 business
days.
</p>
<p>Orders placed before noon ship the same day.</p>
</AccordionContent>
</AccordionItem>
<AccordionItem value="returns">
<AccordionTrigger>What is your return policy?</AccordionTrigger>
<AccordionContent>
<p>
Return any item within 30 days of delivery. Opened items may be subject to a 10%
restocking fee.
</p>
</AccordionContent>
</AccordionItem>
<AccordionItem value="international">
<AccordionTrigger>Do you ship internationally?</AccordionTrigger>
<AccordionContent>
<p>
Yes, we ship to over 40 countries. International orders usually arrive within 7–14
business days.
</p>
</AccordionContent>
</AccordionItem>
<AccordionItem value="gift-cards" disabled>
<AccordionTrigger>Can I pay with a gift card?</AccordionTrigger>
<AccordionContent>
<p>Gift cards are coming soon.</p>
</AccordionContent>
</AccordionItem>
</Accordion>
<p class="text-sm text-muted-foreground" data-testid="accordion-result">
Open: <span class="font-medium text-foreground">{open.join(', ') || 'none'}</span>
</p>
</div>
)
})
Installation
npx shadcn add @stemsui/accordionRequires the one-time setup. Behavior: stems-ui/accordion.
API reference
The styled component is built on the headless module below: its props mirror these options, and the same events and data attributes apply.
stems-ui/accordion
accordion wires a set of headings with collapsible panels: open state, ARIA, focus movement
between triggers, and the same height-animation hooks as collapsible. App code owns the markup
and visual styles.
Base UI equivalent: Accordion.
Usage
import * as accordion from 'stems-ui/accordion'
let panelClass =
'h-(--accordion-panel-height) overflow-hidden transition-[height] duration-200 data-starting-style:h-0 data-ending-style:h-0'
function Faq() {
return () => (
<accordion.Context defaultValue={['shipping']}>
<div mix={accordion.root()}>
<accordion.ItemContext value="shipping">
<div mix={accordion.item()}>
<h3 mix={accordion.header()}>
<button mix={accordion.trigger()}>How long does shipping take?</button>
</h3>
<div class={panelClass} mix={accordion.panel()}>
Standard shipping takes 3–5 business days.
</div>
</div>
</accordion.ItemContext>
<accordion.ItemContext value="returns">
<div mix={accordion.item()}>
<h3 mix={accordion.header()}>
<button mix={accordion.trigger()}>What is your return policy?</button>
</h3>
<div class={panelClass} mix={accordion.panel()}>
Return any item within 30 days.
</div>
</div>
</accordion.ItemContext>
</div>
</accordion.Context>
)
}
The value is always an array of item values, in single and multiple mode. Listen for bubbling
changes on the root or an ancestor:
<div
mix={accordion.onAccordionChange((event) => {
console.log(event.itemValue, event.open, event.value)
})}
>
…
</div>
stems-ui/accordion
Context: provider that owns the open items.value:string[]. Controlled open items.defaultValue:string[]. Initial uncontrolled open items. Defaults to[].onValueChange(value, event?): called with the next open items.multiple:boolean. Allow several open items. Defaults tofalse(opening an item closes the others; the open item can still be closed).disabled:boolean. Disables every item. Defaults tofalse.orientation:'vertical' | 'horizontal'. Picks the arrow keys. Defaults to'vertical'.loopFocus:boolean. Arrow keys wrap around at the ends. Defaults totrue.hiddenUntilFound:boolean. Default for every panel'shiddenUntilFound. Defaults tofalse.keepMounted: accepted for Base UI parity; panels are always kept mounted (see below).
ItemContext: provider for one item.value:string. Identifies the item in the accordion value. Defaults to a generated id.disabled:boolean. Disables this item. Defaults tofalse.onOpenChange(open, event?): called before the rootonValueChangewhen this item toggles.
root(): the root element. Sets state attributes and is where change events are dispatched from (without it they're dispatched from the trigger or panel).item(): the element grouping one header and panel.header(): the heading around the trigger. Use a real heading (<h3>, …) for the level that fits the page.trigger(): the toggle button. Setstype="button",id,aria-expanded,aria-controls, nativedisabled, and handles the keys below. Non-<button>hosts getrole="button",tabIndex,aria-disabled, and Enter/Space activation. An explicitidis kept and used for the panel'saria-labelledby.panel(options?): the item content. Setsid,role="region",aria-labelledby,hiddenwhile closed, and the transition attributes and size variables below.hiddenUntilFound:boolean. Overrides the root option. Closed panels usehidden="until-found"; a find-in-page match opens the item without animation.keepMounted: accepted for Base UI parity. Has no effect.
onAccordionChange(handler): listener mixin forAccordionChangeEvent(stems:accordion-change, bubbles, hasvalue,itemValue,open).
Behavior notes
| Key | Action |
|---|---|
| Enter / Space | Toggle the focused item (native button behavior) |
| ArrowDown / ArrowUp | Focus the next / previous enabled trigger (vertical) |
| ArrowRight / ArrowLeft | Focus the next / previous enabled trigger (horizontal, RTL ok) |
| Home / End | Focus the first / last enabled trigger |
- Disabled items are skipped by arrow keys. Focus wraps unless
loopFocus={false}. - Base UI 1.9 deprecated
orientationandloopFocusand removed arrow-key focus movement (following an APG update). Stems keeps the movement, as@remix-run/uidoes; Tab still moves through triggers normally. - Panels share
collapsible's logic: closed panels gethidden(in server HTML too), opening showsdata-starting-stylefor one frame with--accordion-panel-heightmeasured in px, closing setsdata-ending-styleand hides the panel once its CSS transitions and animations finish. Once open, the variables areauto. Classes that setdisplayoverridehidden. - Panels are always kept mounted: the mixin can't remove app-owned markup.
data-indexis the item's render order in the accordion.
Styling hooks
| Part | Attribute | Values / notes |
|---|---|---|
| root | data-orientation | vertical | horizontal |
| root | data-disabled | present when the accordion is disabled |
| item | data-open | present when the item is open |
| item | data-disabled | present when the item is disabled |
| item | data-index | item index |
| header | data-open | present when the item is open |
| header | data-disabled | present when the item is disabled |
| header | data-index | item index |
| trigger | data-panel-open | present when the panel is open |
| trigger | data-disabled | present when the item is disabled |
| trigger | data-index | item index |
| trigger | aria-expanded | "true" | "false" |
| panel | data-open | present when open |
| panel | data-closed | present when closed (also while animating out) |
| panel | data-orientation | vertical | horizontal |
| panel | data-disabled | present when the item is disabled |
| panel | data-index | item index |
| panel | data-starting-style | present on the first frame of opening |
| panel | data-ending-style | present while animating out |
| panel | hidden | present when closed; "until-found" if enabled |
| Part | CSS variable | Value |
|---|---|---|
| panel | --accordion-panel-height | content height in px while animating; auto once open |
| panel | --accordion-panel-width | content width, same rules |