Accordion

Expandable sections with configurable heading levels.

organismsSource
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@elirobinson/react/components/organisms/Accordion';

Styles: @elirobinson/react/styles/organisms/Accordion.css — already included when you import @elirobinson/react/styles.css.

Most guides cover ages 6–14, split by skill level rather than a strict age range — check the first page of each guide for the specific band.

Show code
import {
  Accordion,
  AccordionContent,
  AccordionItem,
  AccordionTrigger,
} from '@elirobinson/react/components/organisms/Accordion';

export default function Basic() {
  return (
    <Accordion type="single" defaultValue="age" className="demo-col">
      <AccordionItem value="age">
        <AccordionTrigger>What age groups are the guides written for?</AccordionTrigger>
        <AccordionContent>
          Most guides cover ages 6–14, split by skill level rather than a strict age range — check
          the first page of each guide for the specific band.
        </AccordionContent>
      </AccordionItem>
      <AccordionItem value="experience">
        <AccordionTrigger>Do I need coaching experience to use them?</AccordionTrigger>
        <AccordionContent>
          No. Every drill includes setup instructions and the common mistakes to watch for, written
          for a first-season volunteer coach.
        </AccordionContent>
      </AccordionItem>
      <AccordionItem value="updates">
        <AccordionTrigger>How do I get updates after I buy?</AccordionTrigger>
        <AccordionContent>
          Guides are versioned PDFs — re-download from your order confirmation email any time I ship
          a revision.
        </AccordionContent>
      </AccordionItem>
    </Accordion>
  );
}

When to use it

Use Accordion for content that's fine collapsed by default — FAQs, settings groups, secondary detail the reader can opt into rather than has to read. type="single" is right when opening one section implies you're done with the others (an FAQ, a set of mutually exclusive options); type="multiple" is right when a reader might want two sections open at once to compare them.

Don't use it as a substitute for real page structure. If every "section" is content someone always needs, put it directly on the page — collapsing it just adds a click.

Multiple sections open at once

12 weeks of practice plans, scaled for a 45-minute session.

Show code
import {
  Accordion,
  AccordionContent,
  AccordionItem,
  AccordionTrigger,
} from '@elirobinson/react/components/organisms/Accordion';

export default function Multiple() {
  return (
    <Accordion type="multiple" defaultValue={['soccer']} className="demo-col">
      <AccordionItem value="soccer">
        <AccordionTrigger>Soccer — U8 season plan</AccordionTrigger>
        <AccordionContent>
          12 weeks of practice plans, scaled for a 45-minute session.
        </AccordionContent>
      </AccordionItem>
      <AccordionItem value="basketball">
        <AccordionTrigger>Basketball — U10 season plan</AccordionTrigger>
        <AccordionContent>10 weeks, built around three drills per practice.</AccordionContent>
      </AccordionItem>
      <AccordionItem value="track">
        <AccordionTrigger>Track — sprint fundamentals</AccordionTrigger>
        <AccordionContent>6 weeks of starts, form drills, and pacing work.</AccordionContent>
      </AccordionItem>
    </Accordion>
  );
}

Controlled

Pass value and onValueChange to drive the open section from outside — useful when something else on the page (a link, a search result) needs to jump to a specific section.

Open section: shipping

Coaching guide orders ship as an instant download — nothing physical to wait on.

Show code
import { useState } from 'react';

import {
  Accordion,
  AccordionContent,
  AccordionItem,
  AccordionTrigger,
} from '@elirobinson/react/components/organisms/Accordion';

export default function Controlled() {
  const [open, setOpen] = useState('shipping');

  return (
    <div className="demo-col">
      <p>
        Open section: <strong>{open || 'none'}</strong>
      </p>
      <Accordion type="single" value={open} onValueChange={setOpen}>
        <AccordionItem value="shipping">
          <AccordionTrigger>Shipping</AccordionTrigger>
          <AccordionContent>
            Coaching guide orders ship as an instant download — nothing physical to wait on.
          </AccordionContent>
        </AccordionItem>
        <AccordionItem value="refunds">
          <AccordionTrigger>Refunds</AccordionTrigger>
          <AccordionContent>
            Full refund within 14 days if a guide isn&apos;t useful to you — just reply to the
            receipt email.
          </AccordionContent>
        </AccordionItem>
      </Accordion>
    </div>
  );
}

Heading level

There's no single correct heading level for an arbitrary document outline, so headingLevel is exposed rather than hardcoded. Nest an accordion under an <h2> page section and leave the default (headingLevel={3}, an <h3> per trigger); nest it directly under the page's own <h1> and pass headingLevel={2}. An out-of-range value falls back to 3 rather than crashing the tree.

An audit of your current stack, a short list of what's actually worth automating, and hands-on setup for the first one or two changes.

Show code
import {
  Accordion,
  AccordionContent,
  AccordionItem,
  AccordionTrigger,
} from '@elirobinson/react/components/organisms/Accordion';

export default function HeadingLevel() {
  return (
    <Accordion type="single" headingLevel={2} defaultValue="scope" className="demo-col">
      <AccordionItem value="scope">
        <AccordionTrigger>What&apos;s included in AI consulting?</AccordionTrigger>
        <AccordionContent>
          An audit of your current stack, a short list of what&apos;s actually worth automating, and
          hands-on setup for the first one or two changes.
        </AccordionContent>
      </AccordionItem>
      <AccordionItem value="pricing">
        <AccordionTrigger>How is it priced?</AccordionTrigger>
        <AccordionContent>From $150/hr, billed in 30-minute increments.</AccordionContent>
      </AccordionItem>
    </Accordion>
  );
}

Props

PropTypeDefaultDescription
defaultValuestring | (readonly string[] & string) | ((string | number | readonly string[]) & string[])
headingLevel1 | 2 | 3 | 4 | 5 | 63Heading level (1-6) that wraps each trigger, per the WAI-ARIA accordion pattern. There is no single correct default for an arbitrary document outline, so this is exposed rather than hardcoded — a consumer nesting an Accordion under an `<h2>` section should pass `headingLevel={3}` (the default) while one nesting it directly under the page `<h1>` should pass `headingLevel={2}`. Defaults to 3, the common case (accordion under a page section heading).
onValueChange((value: string) => void) | ((value: string[]) => void)
type"single" | "multiple"single
valuestring | string[]

AccordionItem

PropTypeDefaultDescription
valuerequiredstring
defaultValuestring | (readonly string[] & string) | ((string | number | readonly string[]) & string[])

Also accepts all HTMLAttributes<HTMLDivElement> props.

AccordionTrigger

PropTypeDefaultDescription
defaultValuestring | (readonly string[] & string) | ((string | number | readonly string[]) & string[])
onClickMouseEventHandler<HTMLButtonElement>

Also accepts all Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'id' | 'onClick' | 'aria-expanded' | 'aria-controls'> props.

AccordionContent

PropTypeDefaultDescription
defaultValuestring | (readonly string[] & string) | ((string | number | readonly string[]) & string[])

Also accepts all Omit<HTMLAttributes<HTMLDivElement>, 'id' | 'role' | 'aria-labelledby'> props.

Accessibility

  • Follows the WAI-ARIA accordion pattern: each AccordionTrigger renders a real <button> wrapped in a heading tag (headingLevel, default h3).
  • The trigger carries aria-expanded and aria-controls; the matching AccordionContent carries id and aria-labelledby pointing back at the trigger. All of it is computed internally — id/aria-expanded/aria-controls/role/aria-labelledby are omitted from the public prop types so a consumer can't silently clobber the wiring via a spread.
  • A collapsed section's content isn't hidden with CSS — it isn't rendered at all. Tab order and screen reader traversal skip it entirely rather than landing on invisible text.
  • Keyboard: Tab/Shift+Tab move between triggers, since every trigger is already a naturally focusable <button> in document order. Enter/Space toggle the focused trigger — native button behavior, nothing re-implemented. Arrow-key navigation between headers is intentionally not implemented: per the WAI-ARIA pattern it's optional for accordions (unlike tabs, which uses a single roving tab stop and requires it), so there's nothing broken here to fix.
  • No Escape handling — accordions don't have an escape-to-collapse convention, and none is added here.

Do

  • Set headingLevel to match where the accordion sits in your page outline.
  • Use type="single" for mutually exclusive content and type="multiple" when sections can be compared side by side.
  • Give every AccordionItem a stable, unique value.
  • Let content unmount when collapsed rather than hiding it with CSS — the built-in behavior already does this.

Don't

  • Rely on hover to reveal content — it's click/keyboard only, by design.
  • Use Accordion for primary navigation — a real nav list is more discoverable.
  • Try to override aria-expanded or aria-controls via props — both are omitted from the type specifically to stop that.
  • Nest another single roving-tabindex widget inside a trigger — Tab already handles trigger-to-trigger movement correctly.