Stems UI
Menu

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.

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/accordion

Requires 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 to false (opening an item closes the others; the open item can still be closed).
    • disabled: boolean. Disables every item. Defaults to false.
    • orientation: 'vertical' | 'horizontal'. Picks the arrow keys. Defaults to 'vertical'.
    • loopFocus: boolean. Arrow keys wrap around at the ends. Defaults to true.
    • hiddenUntilFound: boolean. Default for every panel's hiddenUntilFound. Defaults to false.
    • 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 to false.
    • onOpenChange(open, event?): called before the root onValueChange when 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. Sets type="button", id, aria-expanded, aria-controls, native disabled, and handles the keys below. Non-<button> hosts get role="button", tabIndex, aria-disabled, and Enter/Space activation. An explicit id is kept and used for the panel's aria-labelledby.
  • panel(options?): the item content. Sets id, role="region", aria-labelledby, hidden while closed, and the transition attributes and size variables below.
    • hiddenUntilFound: boolean. Overrides the root option. Closed panels use hidden="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 for AccordionChangeEvent (stems:accordion-change, bubbles, has value, itemValue, open).

Behavior notes

KeyAction
Enter / SpaceToggle the focused item (native button behavior)
ArrowDown / ArrowUpFocus the next / previous enabled trigger (vertical)
ArrowRight / ArrowLeftFocus the next / previous enabled trigger (horizontal, RTL ok)
Home / EndFocus the first / last enabled trigger
  • Disabled items are skipped by arrow keys. Focus wraps unless loopFocus={false}.
  • Base UI 1.9 deprecated orientation and loopFocus and removed arrow-key focus movement (following an APG update). Stems keeps the movement, as @remix-run/ui does; Tab still moves through triggers normally.
  • Panels share collapsible's logic: closed panels get hidden (in server HTML too), opening shows data-starting-style for one frame with --accordion-panel-height measured in px, closing sets data-ending-style and hides the panel once its CSS transitions and animations finish. Once open, the variables are auto. Classes that set display override hidden.
  • Panels are always kept mounted: the mixin can't remove app-owned markup.
  • data-index is the item's render order in the accordion.

Styling hooks

PartAttributeValues / notes
rootdata-orientationvertical | horizontal
rootdata-disabledpresent when the accordion is disabled
itemdata-openpresent when the item is open
itemdata-disabledpresent when the item is disabled
itemdata-indexitem index
headerdata-openpresent when the item is open
headerdata-disabledpresent when the item is disabled
headerdata-indexitem index
triggerdata-panel-openpresent when the panel is open
triggerdata-disabledpresent when the item is disabled
triggerdata-indexitem index
triggeraria-expanded"true" | "false"
paneldata-openpresent when open
paneldata-closedpresent when closed (also while animating out)
paneldata-orientationvertical | horizontal
paneldata-disabledpresent when the item is disabled
paneldata-indexitem index
paneldata-starting-stylepresent on the first frame of opening
paneldata-ending-stylepresent while animating out
panelhiddenpresent when closed; "until-found" if enabled
PartCSS variableValue
panel--accordion-panel-heightcontent height in px while animating; auto once open
panel--accordion-panel-widthcontent width, same rules