Skip to content

Accordion

Accordion is a stack of labelled sections that open and close, for content that is worth having on the page but not worth showing all at once: a settings screen, a list of questions and answers, a long form broken into steps. Each section carries its own content, so a section is one value rather than a header and a body the caller has to keep in step.

A closed section is not in the tree at all. It costs no layout, and it says nothing to a screen reader. Reach for Tabs instead when exactly one of the views should be showing and the choice is a navigation rather than a disclosure.

Settings is controlled, and the application pins Basics open: click its header and it stays open, because a controlled accordion shows only what is written back. Expand all opens two sections with no gesture at all. Retention is disabled. Shipping is uncontrolled and exclusive, so opening one of its sections closes the other.

tsx
/**
 * The sections, declared once at module scope.
 *
 * Each one carries its own content, so a section is a value rather
 * than a pair of elements the caller has to keep in step. `Retention`
 * is marked `disabled`: it is drawn, refuses a click, and cannot be
 * focused, so the keyboard walks past it.
 */
const SETTINGS: readonly AccordionSection[] = [
  {
    value: 'basics',
    label: 'Basics',
    content: <text text="Name, time zone and the language the app is read in." fontSize={13} color="text" />
  },
  {
    value: 'notifications',
    label: 'Notifications',
    content: <text text="What is worth interrupting someone for, and by which route." fontSize={13} color="text" />
  },
  {
    value: 'retention',
    label: 'Retention',
    content: <text text="How long a deleted item is recoverable." fontSize={13} color="text" />,
    disabled: true
  }
];

const SHIPPING: readonly AccordionSection[] = [
  {
    value: 'delivery',
    label: 'When will it arrive?',
    content: <text text="Two to five working days, and sooner within the city." fontSize={13} color="text" />
  },
  {
    value: 'returns',
    label: 'Can I send it back?',
    content: <text text="Within thirty days, in the packaging it arrived in." fontSize={13} color="text" />
  }
];

/** The section the application will not let anyone close. */
const PINNED = 'basics';

/**
 * Two accordions, and the two ways the open set can be owned.
 *
 * **Settings is controlled.** The application holds the list of open
 * sections and writes back every change except one: `Basics` is
 * pinned, so clicking its header does not close it. The Expand all
 * button opens everything with no gesture at all, which is the same
 * write arriving from somewhere else in the application.
 *
 * **Shipping is uncontrolled and `exclusive`.** It owns its own open
 * set, and opening one section closes the other.
 *
 * Every header is its own tab stop. Tab to one and press Space or
 * Enter to open and close it. A closed section is not in the tree at
 * all, so it costs no layout and says nothing to a screen reader.
 */
export function Settings(_inputs: Inputs<{}>, _ctx: ComponentContext) {
  const open = internalState<readonly string[]>([PINNED]);

  return (
    <column gap={16} padding={20} width={percent(100)} height={percent(100)}>
      <text text="Settings" fontSize={13} fontWeight={600} color="text" />
      <Accordion
        sections={SETTINGS}
        open={open}
        onOpenChange={next => (open.value = next.includes(PINNED) ? next : [PINNED, ...next])}
      />

      <row gap={12} y="center">
        <button
          label="Expand all"
          onClick={() => (open.value = ['basics', 'notifications'])}
          padding={8}
          borderRadius={6}
          borderWidth={1}
          borderColor="border"
          backgroundColor="background"
          cursor="pointer"
          modifiers={[HOVER_CONTROL]}>
          <text text="Expand all" fontSize={12} color="text" />
        </button>
        <text
          text={open.pipe(map(values => `${values.length} open, and Basics stays open`))}
          fontSize={12}
          color="textMuted"
        />
      </row>

      <text text="Shipping" fontSize={13} fontWeight={600} color="text" />
      <Accordion sections={SHIPPING} defaultOpen={['delivery']} exclusive />
    </column>
  );
}

Props

PropTypeDefaultWhat it does
sectionsreadonly AccordionSection[]requiredThe sections, in the order they are drawn. Declare the array once: a fresh one is a fresh set.
openreadonly string[]noneThe values of the open sections. Supplying it makes the accordion controlled.
defaultOpenreadonly string[]noneWhat is open to begin with, for an accordion that owns its own. Supplying both throws, naming the component.
onOpenChange(open: readonly string[]) => voidnoneCalled with the whole set the accordion would take, not with the one section that moved.
exclusivebooleanfalseOpening a section closes the rest.

An AccordionSection is four fields:

FieldTypeDefaultWhat it does
valuestringrequiredWhat appears in the open set, and the section's key.
labelstringrequiredDrawn on the header, and the header's accessible name.
contentUiChildrequiredWhat the section shows while it is open.
disabledbooleanfalseThe header refuses a click, cannot be focused, and so cannot be toggled.

Supplying neither open nor defaultOpen starts with everything closed. exclusive applies as a section is toggled and not as the accordion is built, so a defaultOpen naming three sections opens three of them; the next toggle is what reduces it to one.

There is no label prop and no role on the accordion itself: it is a column of sections, and each section names itself. Give it a heading of your own where a group needs a name, the way the example does.

Layout and modifiers

The shared layout props land on the accordion's column.

rootModifiers is declared on the shared props type but is not attached by this component, so a modifier passed there does nothing. Put it on a box around the accordion until that changes.

Controlled and uncontrolled

tsx
// Controlled: the application owns the open set, and the accordion shows it.
<Accordion sections={SETTINGS} open={open} onOpenChange={next => (open.value = next)} />

// Uncontrolled: the accordion owns the open set, and reports it if asked.
<Accordion sections={SETTINGS} defaultOpen={['basics']} />

Which form applies is decided once, when the component is built, from whether open was supplied; supplying both throws.

onOpenChange is handed the whole set the accordion would take, which is what makes a policy easy to write: the example filters the pinned section back in, and a handler that wanted at most two open would slice the array. What the application writes back is what is drawn, and a controlled accordion given no onOpenChange does not move at all. The spec proves both directions: a click that the handler declines leaves the section open, and a write from a button somewhere else opens two sections that nobody clicked.

An uncontrolled accordion keeps one cell of its own and still calls onOpenChange, so an open set you only want to observe needs no state at the call site.

Accordion takes no child: its content arrives inside sections, unlike Card, Toolbar and Tabs, which each hold one child written between their tags.

Keyboard

Every header is its own tab stop, so Tab and Shift+Tab walk them. A disabled header cannot be focused, so the keyboard walks past it.

KeyWhat it does
SpaceOpens the focused section, or closes it
EnterThe same

Both bindings toggle, and both consume the event, so nothing above the accordion sees the key. Enter toggles as well as Space because a header here is a row rather than a native button, and a reader who has just arrived on it should not have to know which of the two this framework chose. Nothing else is bound: the arrows are free, so an accordion inside a scroller still scrolls.

Semantics

WhatValue
Headerbutton, on the row that takes focus
NameThe section's label
Statesexpanded while open, collapsed while closed, and always exactly one of them
Disableddisabled on the record of a section marked so, and inherited by its subtree
ContentNothing of its own: an open section's content announces itself as it is

The accordion emits nothing at its own level, so what a screen reader meets is a run of buttons that each say whether they are open. A closed section's content is absent rather than hidden, which means there is nothing there to skip past and nothing to accidentally read.

The states arrive as they change, so a section the application opens from elsewhere updates what an assistive technology sees without anything re-rendering. The marker drawn beside the label is text with no record of its own; the state is what says whether the section is open, and the triangle is for the eye.

Next

Tabs shows one view at a time from a strip, and the library overview has the contract both of them are an instance of.