Docs
Next

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.

Accordion
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:

PartRendersRole
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.

The default variant
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.

Starts expanded
  • 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().

Section with a fixed id
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.

Mixed content

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.

systhema.config.ts
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 config

PropsLink to this section

AccordionLink to this section

PropTypeDefaultDescription
asElementType'div'
classNamestring-
expandedboolean-
idstring-
variantAccordionVariant (default)-
disableAnimationboolean-
…and all <div> attributes

AccordionTitleLink to this section

PropTypeDefaultDescription
asElementType'button'
classNamestring-
…and all <button> attributes

AccordionContentLink to this section

PropTypeDefaultDescription
asElementType'div'
classNamestring-
…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.

ClassStyles
accordion-titlecursor: 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-wrapperoverflow: 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"> with aria-expanded and aria-controls pointing at the content. Rendered as any other element (AccordionTitle.div, or an <a> without href), it becomes a custom button: role="button", tabIndex={0} and Enter / Space handling.
  • The content has aria-labelledby pointing at the title. While collapsed it is aria-hidden and inert, so its links and fields leave the tab order; expanded, it is a role="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.