Accordion
Disclosure panels with AccordionTitle and AccordionContent, token variants, initial state and ARIA wiring.
On this page
An accordion hides a block of content behind a title the visitor clicks to expand or collapse. Use a group of them for FAQs, specifications or any long content a reader skims for one answer.
import { Accordion, AccordionContent, AccordionTitle, Paragraph, Stack } from '@systhemaui/next'
export default function Demo() {
return (
<Stack direction="col" gap="sm" style={{ width: '100%', maxWidth: 560 }}>
<Accordion>
<AccordionTitle>What is Systhema?</AccordionTitle>
<AccordionContent>
<Paragraph>
A design system framework: Figma tokens, a Tailwind CSS plugin and React components that
read from the same source.
</Paragraph>
</AccordionContent>
</Accordion>
<Accordion>
<AccordionTitle>Do I need Payload?</AccordionTitle>
<AccordionContent>
<Paragraph>No. The components work in any React or Next.js project.</Paragraph>
</AccordionContent>
</Accordion>
<Accordion>
<AccordionTitle>Can I change the icons?</AccordionTitle>
<AccordionContent>
<Paragraph>Yes, with the accordion options of the blocks config.</Paragraph>
</AccordionContent>
</Accordion>
</Stack>
)
}ImportLink to this section
import { Accordion, AccordionContent, AccordionTitle } from '@systhemaui/next'In a React app without Next.js, import them from @systhemaui/react.
Variants and tagsLink to this section
An accordion has three parts, always nested in this order:
| Part | Renders | Role |
|---|---|---|
Accordion | <div> (Accordion.section: <section>) | Holds the open state and the ids that link the parts |
AccordionTitle | <button> (AccordionTitle.div: <div>) | The trigger; appends the open/close icon after its text |
AccordionContent | <div> (AccordionContent.div) | The collapsible region; wraps its children in .accordion-content-wrapper |
AccordionTitle and AccordionContent throw when they are rendered outside an Accordion.
variant takes an accordion variant from your tokens. The default tokens ship one, default, which is also what you get without the prop. Each token variant becomes one class, accordion-<variant>, with its own padding, border, radius, shadow, title type and colors; add variants in Figma and they appear here.
import { Accordion, AccordionContent, AccordionTitle, Paragraph } from '@systhemaui/next'
export default function Demo() {
return (
<Accordion variant="default" style={{ width: '100%', maxWidth: 560 }}>
<AccordionTitle>Shipping and returns</AccordionTitle>
<AccordionContent>
<Paragraph>Orders ship within two working days. Returns are free for 30 days.</Paragraph>
</AccordionContent>
</Accordion>
)
}ExamplesLink to this section
ExpandedLink to this section
expanded sets the state the accordion starts in. It is the initial value only: after the first render the accordion owns its state, and changing the prop does not open or close it.
- Checked Token pipeline and Tailwind CSS plugin
- Checked React and Next.js components
- Checked Payload blocks and editors
import { Accordion, AccordionContent, AccordionTitle, List, ListItem } from '@systhemaui/next'
export default function Demo() {
return (
<Accordion expanded style={{ width: '100%', maxWidth: 560 }}>
<AccordionTitle>What is included</AccordionTitle>
<AccordionContent>
<List type="check">
<ListItem checked>Token pipeline and Tailwind CSS plugin</ListItem>
<ListItem checked>React and Next.js components</ListItem>
<ListItem checked>Payload blocks and editors</ListItem>
</List>
</AccordionContent>
</Accordion>
)
}To re-render an accordion in a new state, change its key together with expanded.
As a sectionLink to this section
Pass as="section" (or use Accordion.section) when each panel is a distinct section of the page. Use id to fix the ids of the title and content (accordion-title-<id>, accordion-content-<id>), for example to deep-link to a panel; without it the ids come from useId().
import { Accordion, AccordionContent, AccordionTitle, Paragraph } from '@systhemaui/next'
export default function Demo() {
return (
<Accordion as="section" id="opening-hours" style={{ width: '100%', maxWidth: 560 }}>
<AccordionTitle>Opening hours</AccordionTitle>
<AccordionContent>
<Paragraph>Monday to Friday, 9:00 to 17:00. Closed on public holidays.</Paragraph>
</AccordionContent>
</Accordion>
)
}The Accordion.div and Accordion.section shorthands take only the element's attributes. For variant, expanded, id or disableAnimation, use Accordion with as.
Rich contentLink to this section
AccordionContent takes any children. Each direct child gets the variant's content gap above it, and headings, paragraphs and lists take the content color.
Our support team answers within one working day.
Average response time last month: 3 hours.
import { Accordion, AccordionContent, AccordionTitle, Button, Paragraph } from '@systhemaui/next'
export default function Demo() {
return (
<Accordion expanded style={{ width: '100%', maxWidth: 560 }}>
<AccordionTitle>Need more help?</AccordionTitle>
<AccordionContent>
<Paragraph>Our support team answers within one working day.</Paragraph>
<Paragraph type="small">Average response time last month: 3 hours.</Paragraph>
<div>
<Button.a href="#examples" variant="primary">
Contact support
</Button.a>
</div>
</AccordionContent>
</Accordion>
)
}IconsLink to this section
The title icon is an empty <span class="accordion-title-icon"> masked with an icon from the accordion options of blocks: by default a plus while closed and a cross while open. closedIcon and openedIcon take a CSS image value, typically url("data:image/svg+xml,…"); openRotate rotates the icon by that many degrees while the panel is open. false turns an option off.
import type { SysthemaConfig } from '@systhemaui/core'
const config: SysthemaConfig = {
blocks: {
accordion: {
// One chevron that turns upside down when the panel opens.
closedIcon: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 -960 960 960'%3E%3Cpath d='M480-345 240-585l56-56 184 184 184-184 56 56-240 240Z'/%3E%3C/svg%3E")`,
openedIcon: false,
openRotate: 180,
},
},
}
export default configPropsLink to this section
AccordionLink to this section
| Prop | Type | Default | Description |
|---|---|---|---|
as | ElementType | 'div' | |
className | string | - | |
expanded | boolean | - | |
id | string | - | |
variant | AccordionVariant (default) | - | |
disableAnimation | boolean | - | |
…and all <div> attributes |
AccordionTitleLink to this section
| Prop | Type | Default | Description |
|---|---|---|---|
as | ElementType | 'button' | |
className | string | - | |
…and all <button> attributes |
AccordionContentLink to this section
| Prop | Type | Default | Description |
|---|---|---|---|
as | ElementType | 'div' | |
className | string | - | |
…and all <div> attributes |
HTML and CSSLink to this section
The open state lives in attributes: data-expanded on .accordion, aria-expanded on the title and aria-hidden on the content. The CSS collapses .accordion-content[aria-hidden='true'] with a grid-template-rows transition, so the markup needs a script that flips those attributes. The vanilla JS bundle does that for every .accordion-title click.
<div
class="accordion accordion-default"
data-expanded="false"
data-title-id="faq-1-title"
data-content-id="faq-1-content"
>
<button
id="faq-1-title"
class="accordion-title"
type="button"
aria-expanded="false"
aria-controls="faq-1-content"
>
What is Systhema?
<span class="accordion-title-icon" aria-hidden="true"></span>
</button>
<div
id="faq-1-content"
class="accordion-content"
aria-hidden="true"
aria-labelledby="faq-1-title"
tabindex="-1"
inert
>
<div class="accordion-content-wrapper">
<p class="text-body color-body">A design system framework.</p>
</div>
</div>
</div>The variant values come from the accordion tokens in spacing and colors.
| Class | Styles |
|---|---|
accordion-title | cursor: pointer;width: 100%;display: flex;flex-direction: row;align-items: center;justify-content: space-between;text-align: left;font-family: var(--font-accordion-font-family);font-style: var(--font-accordion-font-style);font-weight: var(--font-accordion-font-weight);font-variation-settings: 'wght' var(--font-accordion-font-weight); |
accordion-title-icon | & { flex-grow: 0; flex-shrink: 0; transform: rotate(0deg); transition-property: transform; }& svg { flex-grow: 0; flex-shrink: 0; transform: rotate(0deg); transition-property: transform; }& img { flex-grow: 0; flex-shrink: 0; transform: rotate(0deg); transition-property: transform; }div.accordion-title-icon:empty { display: inline-block; mask-image: url("data:image/svg+xml,…"); mask-repeat: no-repeat; mask-position: center; mask-size: contain; }+ 3 more rules |
accordion-content | & { overflow: hidden; display: grid; grid-template-rows: 1fr; transition-property: grid-template-rows; }&[aria-hidden='true'] { grid-template-rows: 0fr; } |
accordion-content-wrapper | overflow: hidden; |
accordion-default | :is(.richtext > :is(.columns, .stack, .card-default, .card-highlighted, :is(.accordion-default))):not(:first-child) { margin-top: calc(var(--typography-block-margin-y, 24px) * 2); }:is(.richtext > :is(.columns, .stack, .card-default, .card-highlighted, :is(.accordion-default))):not(:last-child) { margin-bottom: calc(var(--typography-block-margin-y, 24px) * 2); }+ 14 more rules |
Next.jsLink to this section
@systhemaui/next re-exports the accordion parts from @systhemaui/react unchanged. They are client components ('use client'), so you can render them from a Server Component without a wrapper.
AccessibilityLink to this section
- The title is a native
<button type="button">witharia-expandedandaria-controlspointing at the content. Rendered as any other element (AccordionTitle.div, or an<a>withouthref), it becomes a custom button:role="button",tabIndex={0}andEnter/Spacehandling. - The content has
aria-labelledbypointing at the title. While collapsed it isaria-hiddenandinert, so its links and fields leave the tab order; expanded, it is arole="region". - The icon is decorative and carries
aria-hidden="true". - To expose the titles to heading navigation, place each accordion under a heading, or put the title text in one: a heading inside the button is not announced as a heading.