Skip to main content

Accordion

Accordion organizes related content into collapsible sections so users can scan dense pages and open the details they need. Keep behavior props on the root so Spar owns the open/closed state model.

Usage​

import { Accordion } from '@takeoff-ui/react-spar';
<Accordion>
<Accordion.Item>
<Accordion.Header>
<Accordion.Trigger>
<Accordion.Indicator />
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content />
</Accordion.Item>
</Accordion>

Playground​


function PlaygroundDemo() {
  return (
    <Accordion className="w-full max-w-90" defaultValue="fare">
      <Accordion.Item value="fare">
        <Accordion.Header>
          <Accordion.Trigger>
            Fare conditions
            <Accordion.Indicator />
          </Accordion.Trigger>
        </Accordion.Header>
        <Accordion.Content>Review refund windows, change fees, and cabin rules before confirming the itinerary.</Accordion.Content>
      </Accordion.Item>

      <Accordion.Item value="baggage">
        <Accordion.Header>
          <Accordion.Trigger>
            Baggage allowance
            <Accordion.Indicator />
          </Accordion.Trigger>
        </Accordion.Header>
        <Accordion.Content>
          Carry-on, checked baggage, and sports equipment allowances stay grouped in one collapsible panel.
        </Accordion.Content>
      </Accordion.Item>

      <Accordion.Item value="check-in">
        <Accordion.Header>
          <Accordion.Trigger>
            Online check-in
            <Accordion.Indicator />
          </Accordion.Trigger>
        </Accordion.Header>
        <Accordion.Content>Use the trigger text as the accessible heading, then place rich content in the content part.</Accordion.Content>
      </Accordion.Item>
    </Accordion>
  );
}

render(<PlaygroundDemo />);

Type​

type sets the visual grouping. grouped (default) renders each item as its own bordered card; divided joins the items into a single list separated by rules. The prop is set on the root and cascades to every item via data-type.


function TypeDemo() {
  return (
    <div className="flex w-full max-w-90 flex-col gap-8">
      <div>
        <div className="mb-2 text-sm text-sub-base">type="grouped"</div>
        <Accordion type="grouped" defaultValue="fare">
          <Accordion.Item value="fare">
            <Accordion.Header>
              <Accordion.Trigger>
                Fare conditions
                <Accordion.Indicator />
              </Accordion.Trigger>
            </Accordion.Header>
            <Accordion.Content>Grouped items render as separate cards, each with its own border and surface.</Accordion.Content>
          </Accordion.Item>

          <Accordion.Item value="baggage">
            <Accordion.Header>
              <Accordion.Trigger>
                Baggage allowance
                <Accordion.Indicator />
              </Accordion.Trigger>
            </Accordion.Header>
            <Accordion.Content>Use grouped when each section should read as a standalone block on the page.</Accordion.Content>
          </Accordion.Item>
        </Accordion>
      </div>

      <div>
        <div className="mb-2 text-sm text-sub-base">type="divided"</div>
        <Accordion type="divided" defaultValue="fare">
          <Accordion.Item value="fare">
            <Accordion.Header>
              <Accordion.Trigger>
                Fare conditions
                <Accordion.Indicator />
              </Accordion.Trigger>
            </Accordion.Header>
            <Accordion.Content>Divided items share one continuous list, separated by rules instead of card edges.</Accordion.Content>
          </Accordion.Item>

          <Accordion.Item value="baggage">
            <Accordion.Header>
              <Accordion.Trigger>
                Baggage allowance
                <Accordion.Indicator />
              </Accordion.Trigger>
            </Accordion.Header>
            <Accordion.Content>Use divided for dense FAQ-style lists where the sections belong to one group.</Accordion.Content>
          </Accordion.Item>
        </Accordion>
      </div>
    </div>
  );
}

render(<TypeDemo />);

Multiple Panels​


function MultipleDemo() {
  return (
    <Accordion multiple className="w-full max-w-90" defaultValue={['boarding', 'baggage']} mode="compact" type="divided">
      <Accordion.Item value="boarding">
        <Accordion.Header>
          <Accordion.Trigger>
            Boarding pass
            <Accordion.Indicator />
          </Accordion.Trigger>
        </Accordion.Header>
        <Accordion.Content>
          Show mobile pass availability, airport counter fallbacks, and printable document rules together.
        </Accordion.Content>
      </Accordion.Item>

      <Accordion.Item value="baggage">
        <Accordion.Header>
          <Accordion.Trigger>
            Bag drop
            <Accordion.Indicator />
          </Accordion.Trigger>
        </Accordion.Header>
        <Accordion.Content>Multiple panels can remain open when the workflow asks users to compare related instructions.</Accordion.Content>
      </Accordion.Item>

      <Accordion.Item value="security">
        <Accordion.Header>
          <Accordion.Trigger>
            Security notes
            <Accordion.Indicator />
          </Accordion.Trigger>
        </Accordion.Header>
        <Accordion.Content>
          Compact mode keeps dense operational guidance scannable without changing the compound anatomy.
        </Accordion.Content>
      </Accordion.Item>
    </Accordion>
  );
}

render(<MultipleDemo />);

Controlled​


function ControlledAccordionDemo() {
  const [value, setValue] = useState('documents');

  return (
    <div className="w-full max-w-90">
      <Badge variant="neutral" appearance="outlined">
        Open panel: {value}
      </Badge>
      <Accordion value={value} onValueChange={setValue} collapsible>
        <Accordion.Item value="documents">
          <Accordion.Header>
            <Accordion.Trigger>
              Travel documents
              <Accordion.Indicator />
            </Accordion.Trigger>
          </Accordion.Header>
          <Accordion.Content>Controlled state lets app logic decide which passenger task remains expanded.</Accordion.Content>
        </Accordion.Item>

        <Accordion.Item value="seat">
          <Accordion.Header>
            <Accordion.Trigger>
              Seat selection
              <Accordion.Indicator />
            </Accordion.Trigger>
          </Accordion.Header>
          <Accordion.Content>
            Pair value with onValueChange when the open panel should sync with route state, analytics, or form progress.
          </Accordion.Content>
        </Accordion.Item>
      </Accordion>
    </div>
  );
}

render(<ControlledAccordionDemo />);

Custom Indicator​


function CustomIndicatorDemo() {
  return (
    <Accordion className="w-full max-w-90" defaultValue="service" size="large">
      <Accordion.Item value="service">
        <Accordion.Header level={2}>
          <Accordion.Trigger>
            <Accordion.Indicator>{({ isOpen }) => (isOpen ? <RemoveIconOutlinedRounded /> : <AddIconOutlinedRounded />)}</Accordion.Indicator>
            Service options
          </Accordion.Trigger>
        </Accordion.Header>
        <Accordion.Content>
          Accordion.Indicator is opt-in. Place it before or after the title to control where the disclosure affordance appears; swap its
          content via children or the ({'{'} isOpen {'}'}) render prop.
        </Accordion.Content>
      </Accordion.Item>

      <Accordion.Item value="support">
        <Accordion.Header level={2}>
          <Accordion.Trigger>
            <Accordion.Indicator>{({ isOpen }) => (isOpen ? <RemoveIconOutlinedRounded /> : <AddIconOutlinedRounded />)}</Accordion.Indicator>
            Support channels
          </Accordion.Trigger>
        </Accordion.Header>
        <Accordion.Content>
          Indicators receive the live isOpen state, so each item can flip its own icon without reading the accordion context manually.
        </Accordion.Content>
      </Accordion.Item>
    </Accordion>
  );
}

render(<CustomIndicatorDemo />);

Disabled Item​


function DisabledDemo() {
  return (
    <Accordion className="w-full max-w-90" defaultValue="open">
      <Accordion.Item value="open">
        <Accordion.Header>
          <Accordion.Trigger>
            Refundable fares
            <Accordion.Indicator />
          </Accordion.Trigger>
        </Accordion.Header>
        <Accordion.Content>Active items respond to click and keyboard activation as usual.</Accordion.Content>
      </Accordion.Item>

      <Accordion.Item value="locked" disabled>
        <Accordion.Header>
          <Accordion.Trigger>
            Loyalty upgrades (coming soon)
            <Accordion.Indicator />
          </Accordion.Trigger>
        </Accordion.Header>
        <Accordion.Content>Disabled items render a disabled trigger and ignore both pointer and keyboard activation.</Accordion.Content>
      </Accordion.Item>
    </Accordion>
  );
}

render(<DisabledDemo />);

Without Indicator​


function WithoutIndicatorDemo() {
  return (
    <Accordion className="w-full max-w-90" defaultValue="hours">
      <Accordion.Item value="hours">
        <Accordion.Header>
          <Accordion.Trigger>Customer support hours</Accordion.Trigger>
        </Accordion.Header>
        <Accordion.Content>
          Skip Accordion.Indicator entirely and the trigger ships without a visual affordance — useful when the surrounding layout already
          telegraphs disclosure.
        </Accordion.Content>
      </Accordion.Item>
      <Accordion.Item value="contact">
        <Accordion.Header>
          <Accordion.Trigger>Contact channels</Accordion.Trigger>
        </Accordion.Header>
        <Accordion.Content>
          Trigger text remains the accessible label and clickable target; the panel still opens via click or Enter/Space.
        </Accordion.Content>
      </Accordion.Item>
    </Accordion>
  );
}

render(<WithoutIndicatorDemo />);

Force-Mounted Content​


function ForceMountDemo() {
  return (
    <Accordion className="w-full max-w-90">
      <Accordion.Item value="search">
        <Accordion.Header>
          <Accordion.Trigger>
            Search-friendly content
            <Accordion.Indicator />
          </Accordion.Trigger>
        </Accordion.Header>
        <Accordion.Content forceMount>
          Use forceMount when in-page search must reach the panel body even when the section is collapsed.
        </Accordion.Content>
      </Accordion.Item>
      <Accordion.Item value="default">
        <Accordion.Header>
          <Accordion.Trigger>
            Default mounting
            <Accordion.Indicator />
          </Accordion.Trigger>
        </Accordion.Header>
        <Accordion.Content>Without forceMount the panel mounts only while open, keeping the DOM lean.</Accordion.Content>
      </Accordion.Item>
    </Accordion>
  );
}

render(<ForceMountDemo />);

Accessibility & Keyboard​

  • Each Accordion.Trigger renders a native <button> and exposes aria-expanded plus aria-controls pointing at its panel.
  • Each Accordion.Content is a role="region" labelled by the matching trigger via aria-labelledby.
  • Accordion.Header renders an h1–h6 heading; pick the level that fits the surrounding document outline.
  • Stable trigger / panel ids (${id}-trigger / ${id}-content) derive from the id set on Accordion.Item; override there if you need deterministic markup. The item element itself does not receive that id.
KeyBehavior
Enter / SpaceToggle the focused trigger.
↓ / ↑Move focus to the next / previous trigger (vertical orientation).
→ / ←Move focus to the next / previous trigger (orientation="horizontal").
Home / EndMove focus to the first / last trigger.

Disabled items skip both pointer and keyboard activation and surface data-disabled on the item root for theme recipes.

API Reference​

Accordion​

See Spar Accordion docs for primitive behavior.

Props​

NameTypeDefaultDescription
childrenReact.ReactNode-Accordion.Item elements rendered inside the disclosure container.
typeAccordionType'grouped'Visual grouping.
modeAccordionMode'default'Density mode. Pairs with any AccordionType.
sizeAccordionSize'base'Size scale.
classNamesPartial<Record<"root", string>>-Per-slot class name overrides.
slotPropsPartial<Record<"root", React.HTMLAttributes<HTMLElement>>>-Per-slot HTML attribute overrides.
defaultValueAccordionCurrentValue-Uncontrolled initial item identifier(s). Used only on mount.
multiplebooleanfalseWhen true, multiple items can be expanded at once.
valueAccordionCurrentValue-Controlled item identifier(s). Match the value of the items that should be expanded.
collapsiblebooleantrueIn single mode, when true, an active item can be collapsed by clicking it again. When false, one item is always expanded. Has no effect in multi mode.
disabledbooleanfalseDisables every item in the accordion. Keyboard navigation skips disabled triggers.
orientationOrientation'vertical'Orientation for keyboard navigation.
classNamestring-Appends custom classes to the root slot of this part.

Events​

NameTypeDefaultDescription
onValueChange(next: AccordionCurrentValue) => void-Fired when the open value changes. The payload preserves the canonical shape: scalar in single mode, array in multiple mode.

Data attributes​

AttributeApplied whenPurpose
data-slot="root"AlwaysStable selector for wrapper styling on the root slot.
data-modeAlwaysReflects the resolved mode prop. Theme recipes can scope per-mode rules without reading React props.
data-sizeAlwaysReflects the resolved size prop so theme recipes can scope size variants.

Accordion.Item​

See Spar Accordion docs for primitive behavior.

Props​

NameTypeDefaultDescription
childrenReact.ReactNode-Accordion.Header and Accordion.Content elements that compose the item.
idstring-Base id for the item's ARIA wiring. Spar consumes it to derive the trigger (${id}-trigger) and panel (${id}-content) ids; it is not rendered on the item element itself. Omit it to get generated ids.
classNamesPartial<Record<"root", string>>-Per-slot class name overrides.
slotPropsPartial<Record<"root", React.HTMLAttributes<HTMLElement>>>-Per-slot HTML attribute overrides.
valueAccordionValue-Stable identity for this item.
disabledbooleanfalseDisables this item. Arrow/Home/End navigation skips its trigger.
classNamestring-Appends custom classes to the root slot of this part.

Data attributes​

AttributeApplied whenPurpose
data-slot="root"AlwaysStable selector for wrapper styling on the root slot.
data-typeAlwaysReflects the resolved type prop. Theme recipes can target items per type.
data-modeAlwaysReflects the resolved mode prop. Theme recipes can scope per-mode rules without reading React props.
data-sizeAlwaysReflects the resolved size prop so theme recipes can scope size variants.
data-state="open"When expanded.Spar open-state hook.
data-state="closed"When collapsed.Spar closed-state hook.

Accordion.Header​

See Spar Accordion docs for primitive behavior.

Props​

NameTypeDefaultDescription
childrenReact.ReactNode-Accordion.Trigger element rendered inside the heading tag.
levelAccordionHeadingLevel3Semantic heading level for the document outline; the rendered tag follows (h1–h6) unless as overrides it. Narrowed from Spar's number to AccordionHeadingLevel so an invalid level cannot type-check.
classNamesPartial<Record<"root", string>>-Per-slot class name overrides.
slotPropsPartial<Record<"root", React.HTMLAttributes<HTMLElement>>>-Per-slot HTML attribute overrides.
classNamestring-Appends custom classes to the root slot of this part.

Data attributes​

AttributeApplied whenPurpose
data-slot="root"AlwaysStable selector for wrapper styling on the root slot.
data-state="open"When expanded.Spar open-state hook.
data-state="closed"When collapsed.Spar closed-state hook.

Accordion.Trigger​

See Spar Accordion docs for primitive behavior.

Props​

NameTypeDefaultDescription
childrenReact.ReactNode-Accessible label for the trigger button.
startContentReact.ReactNode-Leading content rendered before the title — typically an icon, but accepts any node. The wrapper element (class + data-slot) is invariant; only the inner node is consumer-supplied.
classNamesPartial<Record<AccordionTriggerSlot, string>>-Per-slot class name overrides.
slotPropsPartial<Record<AccordionTriggerSlot, React.HTMLAttributes<HTMLElement>>>-Per-slot HTML attribute overrides.
classNamestring-Appends custom classes to the root slot of this part.

Data attributes​

AttributeApplied whenPurpose
data-slot="root"AlwaysStable selector for wrapper styling on the root slot.
data-state="open"When expanded.Spar open-state hook.
data-state="closed"When collapsed.Spar closed-state hook.

Accordion.Content​

See Spar Accordion docs for primitive behavior.

Props​

NameTypeDefaultDescription
childrenReact.ReactNode-Panel body. Animated open/closed by Spar.
classNamesPartial<Record<"root", string>>-Per-slot class name overrides.
slotPropsPartial<Record<"root", React.HTMLAttributes<HTMLElement>>>-Per-slot HTML attribute overrides.
forceMountbooleanfalseForce content to remain mounted when closed
classNamestring-Appends custom classes to the root slot of this part.

Events​

NameTypeDefaultDescription
onBeforeMatch(event: Event) => void-Callback fired when content is found via browser search

Data attributes​

AttributeApplied whenPurpose
data-slot="root"AlwaysStable selector for wrapper styling on the root slot.
data-state="open"When expanded.Spar open-state hook.
data-state="closed"When collapsed.Spar closed-state hook.

Type Definitions​

NameDefinition
AccordionType'grouped' | 'divided'
AccordionMode'default' | 'compact'
AccordionSize'base' | 'large'
AccordionCurrentValueAccordionValue | AccordionValue[]
Orientation'vertical' | 'horizontal'
AccordionValuestring | number
AccordionHeadingLevel1 | 2 | 3 | 4 | 5 | 6
AccordionTriggerSlot'root' | 'startContent' | 'title'