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 { Accordion } from 'frontile';
@title and the default block cover the common case. Nothing needs a key.
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>
@isDefaultOpen on an item opens it on first render. In single mode, if
several items declare it, the first in document order wins.
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>
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>
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.
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.
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>
}
separated
ghost
soft
enclosed
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>
sm
md
lg
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>
Named blocks take over whenever the argument shortcuts are not enough. Two Ember rules to know:
<:title>, the body has to become <:content>.<: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}}.
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>
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>
@isCollapsible={{false}} stops the open item from being closed, so something
is always showing. It applies to single mode only.
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>
Accordion implements the WAI-ARIA accordion pattern.
<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.aria-expanded and aria-controls; the panel is a
role="region" labelled by its trigger.inert, so anything focusable inside them leaves the tab
order rather than being reachable by Tab while invisible.Element: HTMLDivElement
| 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 |
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. |
| Name | Type | Default | Description |
|---|---|---|---|
default
*
|
Array
|
- |
Element: HTMLDivElement
| 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.
|
| Name | Type | Default | Description |
|---|---|---|---|
default
*
|
Array
|
- | |
content
*
|
Array
|
- | |
title
*
|
Array
|
- | |
subtitle
*
|
Array
|
- | |
startContent
*
|
Array
|
- | |
indicator
*
|
Array
|
- |