Accordion
Expandable sections with configurable heading levels.
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.
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
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
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'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.
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's included in AI consulting?</AccordionTrigger>
<AccordionContent>
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.
</AccordionContent>
</AccordionItem>
<AccordionItem value="pricing">
<AccordionTrigger>How is it priced?</AccordionTrigger>
<AccordionContent>From $150/hr, billed in 30-minute increments.</AccordionContent>
</AccordionItem>
</Accordion>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
defaultValue | string | (readonly string[] & string) | ((string | number | readonly string[]) & string[]) | — | — |
headingLevel | 1 | 2 | 3 | 4 | 5 | 6 | 3 | Heading 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 | — |
value | string | string[] | — | — |
AccordionItem
| Prop | Type | Default | Description |
|---|---|---|---|
valuerequired | string | — | — |
defaultValue | string | (readonly string[] & string) | ((string | number | readonly string[]) & string[]) | — | — |
Also accepts all HTMLAttributes<HTMLDivElement> props.
AccordionTrigger
| Prop | Type | Default | Description |
|---|---|---|---|
defaultValue | string | (readonly string[] & string) | ((string | number | readonly string[]) & string[]) | — | — |
onClick | MouseEventHandler<HTMLButtonElement> | — | — |
Also accepts all Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'id' | 'onClick' | 'aria-expanded' | 'aria-controls'> props.
AccordionContent
| Prop | Type | Default | Description |
|---|---|---|---|
defaultValue | string | (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
AccordionTriggerrenders a real<button>wrapped in a heading tag (headingLevel, defaulth3). - The trigger carries
aria-expandedandaria-controls; the matchingAccordionContentcarriesidandaria-labelledbypointing back at the trigger. All of it is computed internally —id/aria-expanded/aria-controls/role/aria-labelledbyare 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+Tabmove between triggers, since every trigger is already a naturally focusable<button>in document order.Enter/Spacetoggle 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
Escapehandling — 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.