Frontile

Accordion

A vertically stacked set of headings that each reveal a section of content. Use it to collapse long secondary material — FAQs, settings groups, detail panels — so the page stays scannable.

For a single expandable region with no grouping and no coordination, reach for Collapsible, which is the primitive this is built on.

Import

import { Accordion } from 'frontile';

Usage

@title and the default block cover the common case. Nothing needs a key.

Standard shipping arrives in three to five business days. Express arrives the next business day.

Unopened items can be returned within thirty days for a full refund.

Email support@example.com, or use the chat widget in the bottom corner.
import { Accordion } from 'frontile';

<template>
  <div class='demo-stack'>
    <Accordion as |a|>
      <a.Item @title='What are your shipping options?'>
        Standard shipping arrives in three to five business days. Express
        arrives the next business day.
      </a.Item>
      <a.Item @title='What is your return policy?'>
        Unopened items can be returned within thirty days for a full refund.
      </a.Item>
      <a.Item @title='How can I contact support?'>
        Email support@example.com, or use the chat widget in the bottom corner.
      </a.Item>
    </Accordion>
  </div>
</template>

Starting open

@isDefaultOpen on an item opens it on first render. In single mode, if several items declare it, the first in document order wins.

Three to five business days.

Thirty days, unopened.
import { Accordion } from 'frontile';

<template>
  <div class='demo-stack'>
    <Accordion as |a|>
      <a.Item @title='Shipping' @isDefaultOpen={{true}}>
        Three to five business days.
      </a.Item>
      <a.Item @title='Returns'>Thirty days, unopened.</a.Item>
    </Accordion>
  </div>
</template>

Several open at once

Three to five business days.

Thirty days, unopened.

Email or chat.
import { Accordion } from 'frontile';

<template>
  <div class='demo-stack'>
    <Accordion @selectionMode='multiple' as |a|>
      <a.Item @title='Shipping' @isDefaultOpen={{true}}>
        Three to five business days.
      </a.Item>
      <a.Item @title='Returns' @isDefaultOpen={{true}}>
        Thirty days, unopened.
      </a.Item>
      <a.Item @title='Support'>Email or chat.</a.Item>
    </Accordion>
  </div>
</template>

Keys, and when you need them

An item's @key names it so something outside the accordion can address it — controlled mode, a query parameter, persisted state. Without one an item still works; it just isn't addressable. Identity then falls back to a generated id, never to a position, so an item behind an {{#if}} cannot inherit its neighbour's open state.

Controlled and uncontrolled

The mode is decided by whether @keys is passed, not by what it holds. Omit it and Accordion tracks the open items itself; write it at all — including @keys={{undefined}} — and it is controlled.

Note this differs from Tabs, which names its arguments @value/@defaultValue. Tabs holds one scalar selection; an accordion holds a set of open items identified by each item's @key.

Three to five business days.

Thirty days, unopened.
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { on } from '@ember/modifier';
import { Accordion, Button } from 'frontile';

export default class ControlledAccordion extends Component {
  @tracked openKeys = ['shipping'];

  setOpenKeys = (keys) => {
    this.openKeys = keys;
  };

  openAll = () => {
    this.openKeys = ['shipping', 'returns'];
  };

  <template>
    <div class='demo-stack'>
      <Button @size='sm' {{on 'click' this.openAll}}>Open all</Button>

      <Accordion
        @selectionMode='multiple'
        @keys={{this.openKeys}}
        @onChange={{this.setOpenKeys}}
        as |a|
      >
        <a.Item @key='shipping' @title='Shipping'>
          Three to five business days.
        </a.Item>
        <a.Item @key='returns' @title='Returns'>
          Thirty days, unopened.
        </a.Item>
      </Accordion>
    </div>
  </template>
}

Variants

separated

Some value 1…

Some value 2…

Some value 3…

ghost

Some value 1…

Some value 2…

Some value 3…

soft

Some value 1…

Some value 2…

Some value 3…

enclosed

Some value 1…

Some value 2…

Some value 3…
import { array } from '@ember/helper';
import { Accordion } from 'frontile';

<template>
  {{! `demo-stack` has to be the demo's root for the full-width rule to fire,
      but its own `gap-4` is too tight to read as four separate groups. A
      `gap-8` utility on the same element loses to the helper's `@apply gap-4`
      on source order, so the wider rhythm goes on an inner wrapper. }}
  <div class='demo-stack'>
    <div class='flex flex-col gap-8'>
      {{#each (array 'separated' 'ghost' 'soft' 'enclosed') as |variant|}}
        <div>
          <p class='text-label-xs text-neutral-firm mb-2'>{{variant}}</p>
          <Accordion @variant={{variant}} as |a|>
            <a.Item @title='First item' @isDefaultOpen={{true}}>
              Some value 1…
            </a.Item>
            <a.Item @title='Second item'>Some value 2…</a.Item>
            <a.Item @title='Third item'>Some value 3…</a.Item>
          </Accordion>
        </div>
      {{/each}}
    </div>
  </div>
</template>

Sizes

sm

Some value 1…

Some value 2…

md

Some value 1…

Some value 2…

lg

Some value 1…

Some value 2…
import { array } from '@ember/helper';
import { Accordion } from 'frontile';

<template>
  <div class='demo-stack'>
    <div class='flex flex-col gap-8'>
      {{#each (array 'sm' 'md' 'lg') as |size|}}
        <div>
          <p class='text-label-xs text-neutral-firm mb-2'>{{size}}</p>
          <Accordion @size={{size}} as |a|>
            <a.Item @title='First item' @isDefaultOpen={{true}}>
              Some value 1…
            </a.Item>
            <a.Item @title='Second item'>Some value 2…</a.Item>
          </Accordion>
        </div>
      {{/each}}
    </div>
  </div>
</template>

Subtitles, icons and a custom indicator

Named blocks take over whenever the argument shortcuts are not enough. Two Ember rules to know:

  • A default block cannot coexist with named blocks — the moment you use <:title>, the body has to become <:content>.
  • Block params go on the named block, not the invocation tag. Write <:indicator as |i|>, not <a.Item as |i|> — the latter errors with "the invocation tag cannot take block params" once any named block is present.

Every block yields {{isOpen}} and {{toggle}}.

Ada, Grace and Katherine.

Monthly or annual, cancel anytime.
import { Accordion, Avatar } from 'frontile';

<template>
  <div class='demo-stack'>
    <Accordion as |a|>
      <a.Item @key='team'>
        <:startContent><Avatar
            @name='Ada Lovelace'
            @size='sm'
          /></:startContent>
        <:title>Team</:title>
        <:subtitle>Three members</:subtitle>
        <:indicator as |i|>{{if i.isOpen '−' '+'}}</:indicator>
        <:content>Ada, Grace and Katherine.</:content>
      </a.Item>

      <a.Item @title='Billing' @subtitle='Plans and invoices'>
        Monthly or annual, cancel anytime.
      </a.Item>
    </Accordion>
  </div>
</template>

Disabled items

You can open this one.

You cannot reach this.
import { Accordion } from 'frontile';

<template>
  <div class='demo-stack'>
    <Accordion as |a|>
      <a.Item @title='Available'>You can open this one.</a.Item>
      <a.Item @title='Unavailable' @isDisabled={{true}}>
        You cannot reach this.
      </a.Item>
    </Accordion>
  </div>
</template>

Keeping one open

@isCollapsible={{false}} stops the open item from being closed, so something is always showing. It applies to single mode only.

Some value 1…

Some value 2…
import { Accordion } from 'frontile';

<template>
  <div class='demo-stack'>
    <Accordion @isCollapsible={{false}} as |a|>
      <a.Item @title='First item' @isDefaultOpen={{true}}>Some value 1…</a.Item>
      <a.Item @title='Second item'>Some value 2…</a.Item>
    </Accordion>
  </div>
</template>

Accessibility

Accordion implements the WAI-ARIA accordion pattern.

  • Each trigger is a real <button> wrapped in a heading. Set @headingLevel to whatever fits the surrounding page outline — it defaults to 3, which is wrong as often as it is right.
  • The trigger carries aria-expanded and aria-controls; the panel is a role="region" labelled by its trigger.
  • Every header is its own tab stop, as the pattern requires. Tab moves through the headers; Enter and Space toggle. Arrow Down and Arrow Up move between headers and wrap, Home and End jump to the first and last, and disabled headers are stepped over.
  • Closed panels are inert, so anything focusable inside them leaves the tab order rather than being reachable by Tab while invisible.
  • Content stays in the DOM when closed, so find-in-page and search engines can still reach it.
  • The open/close animation is skipped for users who prefer reduced motion.

API

Accordion

Element: HTMLDivElement

Arguments

Name Type Default Description
classes SlotsToClasses<'base' | 'title' | 'trigger' | 'content' | 'startContent' | 'item' | 'indicator' | 'heading' | 'titleWrapper' | 'subtitle' | 'contentBody'> - Class names for each slot of the component, merged with the theme's.
defaultKeys Array - Seeds the open items when uncontrolled. Takes precedence over any item's @isDefaultOpen.
headingLevel enum 3 The heading level each trigger is wrapped in. Pick the one that fits the surrounding page outline.
hideIndicator boolean false Hides the chevron on every item.
isCollapsible boolean true Whether the open item can be closed again in single mode. Has no effect in multiple mode.
isDisabled boolean false Disables every item.
keys Array -

The keys of the open items.

Passing this argument at all puts the component in controlled mode -- passing it as undefined included. Omit it entirely to let Accordion track the open items itself.

onChange function - Called with the new set of open keys whenever an item is toggled.
selectionMode enum 'single' Whether one item or several may be open at a time.
size enum 'md' Drives padding and text size.
variant enum 'separated' The container treatment.

Blocks

Name Type Default Description
default * Array -

AccordionItem

Element: HTMLDivElement

Arguments

Name Type Default Description
context * Object - Supplied by Accordion. Not part of the public API.
class string - Class names appended to this item's theme classes.
isDefaultOpen boolean false Opens this item initially, without needing a key anywhere. Ignored when the accordion is controlled or when @defaultKeys is passed. In single mode, if several items declare it, the first in document order wins.
isDisabled boolean false Disables this item alone: it cannot be toggled and arrow-key navigation steps over it.
key string a generated unique id Names this item so it can be addressed from outside -- @keys, @defaultKeys, a query param, persisted state. Optional: without one the item still works, it is simply not externally addressable.
subtitle string - Shorthand for the subtitle block.
title string - Shorthand for the title block.

Blocks

Name Type Default Description
default * Array -
content * Array -
title * Array -
subtitle * Array -
startContent * Array -
indicator * Array -
Released under MIT License - Created by Josemar Luedke