Accordion
Vertically stacked sections that expand to reveal content.
onChange called 0 time(s)
import { accordion } from '@markless/ui';export default function AccordionBasic() @{ const questions = [ { value: 'ship', question: 'When does my order ship?', answer: 'Orders leave within two working days.' }, { value: 'returns', question: 'How do I return something?', answer: 'Start a return from your orders page.' }, { value: 'overseas', question: 'Do you ship overseas?', answer: 'Everywhere but Antarctica.' }, ]; <accordion.root class="accordion" value="ship"> {/* A repeat cannot open directly inside a component tag, so a host element carries it. */} <div class="sections" role="presentation"> @for (const q of questions; key q.value) { <accordion.item class="section" value={q.value}> <accordion.itemlabel class="heading"> <accordion.itemtrigger class="trigger">{q.question}</accordion.itemtrigger> </accordion.itemlabel> <accordion.itemcontent class="panel">{q.answer}</accordion.itemcontent> </accordion.item> } </div> </accordion.root>}
.accordion { display: grid; gap: 0.5em; width: min(32rem, 100%);}.sections { display: contents;}.section { border: 2px solid var(--code-edge); border-radius: 6px; background: var(--paper); overflow: clip;}.section[ui-open] { border-color: var(--pink);}.heading { margin: 0; font-size: var(--step-0);}.trigger { display: flex; gap: 0.6em; align-items: center; justify-content: space-between; width: 100%; padding: 0.6em 0.8em; border: 0; background: transparent; color: var(--ink); font: inherit; font-weight: 700; text-align: left; cursor: pointer;}.trigger::after { content: '▾'; transition: transform 140ms ease;}.trigger[ui-open]::after { transform: rotate(180deg);}.trigger:hover { background: var(--tinted-quiet);}.trigger:disabled { cursor: not-allowed; opacity: 0.55;}.section[ui-open] .panel { padding: 0 0.8em 0.7em; font-size: var(--step--1);}
Examples
With multiple, sections open on their own and value becomes the list of open ones. Opening the
third here leaves the first two as they are.
import { accordion } from '@markless/ui';export default function AccordionMultiple() @{ const questions = [ { value: 'ship', question: 'When does my order ship?', answer: 'Orders leave within two working days.' }, { value: 'returns', question: 'How do I return something?', answer: 'Start a return from your orders page.' }, { value: 'overseas', question: 'Do you ship overseas?', answer: 'Everywhere but Antarctica.' }, ]; <accordion.root class="accordion" multiple value={['ship', 'returns']}> {/* A repeat cannot open directly inside a component tag, so a host element carries it. */} <div class="sections" role="presentation"> @for (const q of questions; key q.value) { <accordion.item class="section" value={q.value}> <accordion.itemlabel class="heading"> <accordion.itemtrigger class="trigger">{q.question}</accordion.itemtrigger> </accordion.itemlabel> <accordion.itemcontent class="panel">{q.answer}</accordion.itemcontent> </accordion.item> } </div> </accordion.root>}
.accordion { display: grid; gap: 0.5em; width: min(32rem, 100%);}.sections { display: contents;}.section { border: 2px solid var(--code-edge); border-radius: 6px; background: var(--paper); overflow: clip;}.section[ui-open] { border-color: var(--pink);}.heading { margin: 0; font-size: var(--step-0);}.trigger { display: flex; gap: 0.6em; align-items: center; justify-content: space-between; width: 100%; padding: 0.6em 0.8em; border: 0; background: transparent; color: var(--ink); font: inherit; font-weight: 700; text-align: left; cursor: pointer;}.trigger::after { content: '▾'; transition: transform 140ms ease;}.trigger[ui-open]::after { transform: rotate(180deg);}.trigger:hover { background: var(--tinted-quiet);}.trigger:disabled { cursor: not-allowed; opacity: 0.55;}.section[ui-open] .panel { padding: 0 0.8em 0.7em; font-size: var(--step--1);}
A disabled item's trigger is a disabled <button>, so the arrow-key walk steps straight past it and
nothing has to remember to skip it.
import { accordion } from '@markless/ui';export default function AccordionDisabled() @{ const questions = [ { value: 'ship', question: 'When does my order ship?', answer: 'Orders leave within two working days.', disabled: false }, { value: 'trade', question: 'Trade accounts', answer: 'Sign in to a trade account to read this.', disabled: true }, { value: 'overseas', question: 'Do you ship overseas?', answer: 'Everywhere but Antarctica.', disabled: false }, ]; <accordion.root class="accordion" value="ship"> {/* A repeat cannot open directly inside a component tag, so a host element carries it. */} <div class="sections" role="presentation"> @for (const q of questions; key q.value) { <accordion.item class="section" value={q.value} disabled={q.disabled}> <accordion.itemlabel class="heading"> <accordion.itemtrigger class="trigger">{q.question}</accordion.itemtrigger> </accordion.itemlabel> <accordion.itemcontent class="panel">{q.answer}</accordion.itemcontent> </accordion.item> } </div> </accordion.root>}
.accordion { display: grid; gap: 0.5em; width: min(32rem, 100%);}.sections { display: contents;}.section { border: 2px solid var(--code-edge); border-radius: 6px; background: var(--paper); overflow: clip;}.section[ui-open] { border-color: var(--pink);}.heading { margin: 0; font-size: var(--step-0);}.trigger { display: flex; gap: 0.6em; align-items: center; justify-content: space-between; width: 100%; padding: 0.6em 0.8em; border: 0; background: transparent; color: var(--ink); font: inherit; font-weight: 700; text-align: left; cursor: pointer;}.trigger::after { content: '▾'; transition: transform 140ms ease;}.trigger[ui-open]::after { transform: rotate(180deg);}.trigger:hover { background: var(--tinted-quiet);}.trigger:disabled { cursor: not-allowed; opacity: 0.55;}.section[ui-open] .panel { padding: 0 0.8em 0.7em; font-size: var(--step--1);}
value on the root names the section that is open, matched against each item's own value. Name a
section and the accordion starts with that one showing.
import { accordion } from '@markless/ui';export default function AccordionValue() @{ const questions = [ { value: 'ship', question: 'When does my order ship?', answer: 'Orders leave within two working days.' }, { value: 'returns', question: 'How do I return something?', answer: 'Start a return from your orders page.' }, ]; <accordion.root class="accordion" value="returns"> {/* A repeat cannot open directly inside a component tag, so a host element carries it. */} <div class="sections" role="presentation"> @for (const q of questions; key q.value) { <accordion.item class="section" value={q.value}> <accordion.itemlabel class="heading"> <accordion.itemtrigger class="trigger">{q.question}</accordion.itemtrigger> </accordion.itemlabel> <accordion.itemcontent class="panel">{q.answer}</accordion.itemcontent> </accordion.item> } </div> </accordion.root>}
.accordion { display: grid; gap: 0.5em; width: min(32rem, 100%);}.sections { display: contents;}.section { border: 2px solid var(--code-edge); border-radius: 6px; background: var(--paper); overflow: clip;}.section[ui-open] { border-color: var(--pink);}.heading { margin: 0; font-size: var(--step-0);}.trigger { display: flex; gap: 0.6em; align-items: center; justify-content: space-between; width: 100%; padding: 0.6em 0.8em; border: 0; background: transparent; color: var(--ink); font: inherit; font-weight: 700; text-align: left; cursor: pointer;}.trigger::after { content: '▾'; transition: transform 140ms ease;}.trigger[ui-open]::after { transform: rotate(180deg);}.trigger:hover { background: var(--tinted-quiet);}.trigger:disabled { cursor: not-allowed; opacity: 0.55;}.section[ui-open] .panel { padding: 0 0.8em 0.7em; font-size: var(--step--1);}
A closed panel is hidden="until-found", so searching this page for Antarctica opens the section
holding it. Setting disableUntilFound on the root hides closed panels outright instead.
import { accordion } from '@markless/ui';export default function AccordionFind() @{ const questions = [ { value: 'ship', question: 'When does my order ship?', answer: 'Orders leave within two working days.' }, { value: 'overseas', question: 'Do you ship overseas?', answer: 'Everywhere but Antarctica, which keeps losing our parcels.' }, ]; <accordion.root class="accordion" value="ship"> {/* A repeat cannot open directly inside a component tag, so a host element carries it. */} <div class="sections" role="presentation"> @for (const q of questions; key q.value) { <accordion.item class="section" value={q.value}> <accordion.itemlabel class="heading"> <accordion.itemtrigger class="trigger">{q.question}</accordion.itemtrigger> </accordion.itemlabel> <accordion.itemcontent class="panel">{q.answer}</accordion.itemcontent> </accordion.item> } </div> </accordion.root>}
.accordion { display: grid; gap: 0.5em; width: min(32rem, 100%);}.sections { display: contents;}.section { border: 2px solid var(--code-edge); border-radius: 6px; background: var(--paper); overflow: clip;}.section[ui-open] { border-color: var(--pink);}.heading { margin: 0; font-size: var(--step-0);}.trigger { display: flex; gap: 0.6em; align-items: center; justify-content: space-between; width: 100%; padding: 0.6em 0.8em; border: 0; background: transparent; color: var(--ink); font: inherit; font-weight: 700; text-align: left; cursor: pointer;}.trigger::after { content: '▾'; transition: transform 140ms ease;}.trigger[ui-open]::after { transform: rotate(180deg);}.trigger:hover { background: var(--tinted-quiet);}.trigger:disabled { cursor: not-allowed; opacity: 0.55;}.section[ui-open] .panel { padding: 0 0.8em 0.7em; font-size: var(--step--1);}
collapsible={false} keeps one section open: pressing the trigger of the open section does nothing,
and only opening the other one closes it.
import { accordion } from '@markless/ui';export default function AccordionLocked() @{ const options = [ { value: 'ship', name: 'Standard delivery', detail: 'Two working days, no charge over twenty pounds.' }, { value: 'express', name: 'Express delivery', detail: 'Next working day if you order before noon.' }, ]; <accordion.root class="accordion" value="ship" collapsible={false}> {/* A repeat cannot open directly inside a component tag, so a host element carries it. */} <div class="sections" role="presentation"> @for (const option of options; key option.value) { <accordion.item class="section" value={option.value}> <accordion.itemlabel class="heading"> <accordion.itemtrigger class="trigger">{option.name}</accordion.itemtrigger> </accordion.itemlabel> <accordion.itemcontent class="panel">{option.detail}</accordion.itemcontent> </accordion.item> } </div> </accordion.root>}
.accordion { display: grid; gap: 0.5em; width: min(32rem, 100%);}.sections { display: contents;}.section { border: 2px solid var(--code-edge); border-radius: 6px; background: var(--paper); overflow: clip;}.section[ui-open] { border-color: var(--pink);}.heading { margin: 0; font-size: var(--step-0);}.trigger { display: flex; gap: 0.6em; align-items: center; justify-content: space-between; width: 100%; padding: 0.6em 0.8em; border: 0; background: transparent; color: var(--ink); font: inherit; font-weight: 700; text-align: left; cursor: pointer;}.trigger::after { content: '▾'; transition: transform 140ms ease;}.trigger[ui-open]::after { transform: rotate(180deg);}.trigger:hover { background: var(--tinted-quiet);}.trigger:disabled { cursor: not-allowed; opacity: 0.55;}.section[ui-open] .panel { padding: 0 0.8em 0.7em; font-size: var(--step--1);}
Real-world examples
A help centre block. It opens with one answer showing so the section is never a row of empty
headings, and multiple lets a reader keep two answers side by side while comparing them.
Frequently asked questions
Still stuck? Write to the help desk and a person answers.
import { accordion } from '@markless/ui';export default function Faq() @{ const questions = [ { value: 'ship', question: 'When does my order ship?', answer: 'Orders placed before 3pm leave the same working day. You get a tracking link as soon as the parcel is scanned.', }, { value: 'returns', question: 'How do I return something?', answer: 'Start a return from your orders page within 30 days. Post it back with the printed label and the refund lands about a week later.', }, { value: 'overseas', question: 'Do you ship overseas?', answer: 'Everywhere but Antarctica, which keeps losing our parcels. Duties are shown at checkout, so nothing turns up with a bill attached.', }, { value: 'invoice', question: 'Can I have a VAT invoice?', answer: 'Every order page has a Download invoice button. Add your company details first and they are printed on it.', }, ]; <section class="faq"> <h2 class="faq-title">Frequently asked questions</h2> <accordion.root class="faq-list" multiple value={['ship']}> {/* A repeat cannot open directly inside a component tag, so a host element carries it. */} <div class="faq-items" role="presentation"> @for (const q of questions; key q.value) { <accordion.item class="faq-item" value={q.value}> <accordion.itemlabel class="faq-heading"> <accordion.itemtrigger class="faq-trigger">{q.question}</accordion.itemtrigger> </accordion.itemlabel> <accordion.itemcontent class="faq-panel">{q.answer}</accordion.itemcontent> </accordion.item> } </div> </accordion.root> <p class="faq-foot">Still stuck? Write to the help desk and a person answers.</p> </section>}
.faq { width: min(34rem, 100%); padding: 1.1em 1.2em 0.9em; border: 2px solid var(--code-edge); border-radius: 10px; background: var(--raised);}.faq-title { margin: 0 0 0.6em; font-size: var(--step-0); font-weight: 700; text-shadow: none;}.faq-list { display: grid;}.faq-items { display: contents;}.faq-item { border-top: 1px solid var(--code-edge);}.faq-heading { margin: 0; font-size: var(--step--1);}.faq-trigger { display: flex; gap: 0.6em; align-items: center; justify-content: space-between; width: 100%; padding: 0.7em 0; border: 0; background: transparent; color: var(--ink); font: inherit; font-weight: 700; text-align: left; cursor: pointer;}.faq-trigger::after { content: '+'; font-weight: 400; font-size: 1.3em; line-height: 1;}.faq-trigger[ui-open]::after { content: '–';}.faq-trigger:hover { color: var(--pink);}/* A closed panel keeps its box for find-in-page, so the padding waits for ui-open or every closed question sits on empty space. */.faq-panel { font-size: var(--step--1); line-height: 155%;}.faq-panel[ui-open] { padding: 0 0 0.9em;}.faq-foot { margin: 0.9em 0 0; padding-top: 0.8em; border-top: 1px solid var(--code-edge); font-size: var(--step--2); opacity: 0.75;}
Three groups of settings, one open at a time. Each trigger carries the group name and a line saying what is inside, and both sit in the one button, so the group is a single stop for the keyboard.
import { accordion } from '@markless/ui';export default function Settings() @{ const groups = [ { value: 'profile', name: 'Profile', note: 'Name and public handle', tone: 'default', fields: [ { id: 'settings-name', name: 'name', label: 'Display name', value: 'Ada Lovelace', placeholder: '' }, { id: 'settings-handle', name: 'handle', label: 'Handle', value: 'ada-lovelace', placeholder: '' }, ], checks: [], actions: [], }, { value: 'notifications', name: 'Notifications', note: 'What we are allowed to email you about', tone: 'default', fields: [], checks: [ { name: 'digest', label: 'Weekly digest', checked: true }, { name: 'mentions', label: 'Someone mentions me', checked: false }, ], actions: [], }, { value: 'danger', name: 'Danger zone', note: 'Deleting an account cannot be undone', tone: 'danger', fields: [{ id: 'settings-confirm', name: 'confirm', label: 'Type DELETE to confirm', value: '', placeholder: 'DELETE' }], checks: [], actions: [{ name: 'delete', label: 'Delete this account' }], }, ]; <accordion.root class="settings" value="profile"> {/* A repeat cannot open directly inside a component tag, so a host element carries it. */} <div class="groups" role="presentation"> @for (const group of groups; key group.value) { <accordion.item class="group" value={group.value} data-tone={group.tone}> <accordion.itemlabel class="group-heading"> <accordion.itemtrigger class="group-trigger"> <span class="group-name">{group.name}</span> <span class="group-note">{group.note}</span> </accordion.itemtrigger> </accordion.itemlabel> <accordion.itemcontent class="group-panel"> <div class="group-fields" role="presentation"> @for (const field of group.fields; key field.id) { <div class="field"> <label class="field-name" for={field.id}>{field.label}</label> <input class="field-input" id={field.id} name={field.name} value={field.value} placeholder={field.placeholder} /> </div> } @for (const check of group.checks; key check.name) { <label class="check"> <input class="check-box" type="checkbox" name={check.name} checked={check.checked} /> <span>{check.label}</span> </label> } @for (const action of group.actions; key action.name) { <button class="danger-button" type="button">{action.label}</button> } </div> </accordion.itemcontent> </accordion.item> } </div> </accordion.root>}
.settings { display: grid; gap: 0.6em; width: min(34rem, 100%);}.groups,.group-fields { display: contents;}.group { border: 2px solid var(--code-edge); border-radius: 8px; background: var(--raised); overflow: clip;}.group[ui-open] { border-color: var(--ink);}.group[data-tone='danger'][ui-open] { border-color: var(--pink);}.group-heading { margin: 0; font-size: var(--step--1);}.group-trigger { display: grid; grid-template-columns: 1fr auto; gap: 0.15em 0.8em; width: 100%; padding: 0.75em 0.9em; border: 0; background: transparent; color: var(--ink); font: inherit; text-align: left; cursor: pointer;}.group-trigger:hover { background: var(--tinted-quiet);}.group-name { font-weight: 700;}.group-note { grid-column: 1; font-size: var(--step--2); opacity: 0.7;}.group-trigger::after { content: '▾'; grid-column: 2; grid-row: 1 / span 2; align-self: center; transition: transform 140ms ease;}.group-trigger[ui-open]::after { transform: rotate(180deg);}/* A closed panel keeps its box for find-in-page, so the padding waits for ui-open or every closed group carries a strip of empty paper. */.group-panel { display: grid; gap: 0.7em; background: var(--paper);}.group-panel[ui-open] { padding: 0.2em 0.9em 0.9em;}.field { display: grid; gap: 0.25em;}.field-name { font-size: var(--step--2); font-weight: 700;}.field-input { padding: 0.4em 0.6em; border: 2px solid var(--code-edge); border-radius: 6px; background: var(--raised); color: var(--ink); font: inherit; font-size: var(--step--1);}.field-input:focus-visible { outline: 2px solid var(--pink); outline-offset: 1px;}.check { display: flex; gap: 0.5em; align-items: center; font-size: var(--step--1);}.check-box { width: 1.1em; height: 1.1em; accent-color: var(--pink);}.danger-button { justify-self: start; padding: 0.45em 0.9em; border: 2px solid var(--pink); border-radius: 6px; background: transparent; color: var(--ink); font: inherit; font-size: var(--step--1); font-weight: 700; cursor: pointer;}.danger-button:hover { background: var(--pink);}
Anatomy
| Part | What it is |
|---|---|
accordion.root | The accordion itself; every section goes inside it. It holds which section is showing, and every other accordion part reads that from here rather than keeping a copy. |
accordion.item | One section: its label, trigger and panel go inside. It carries the section's own value, which is the name the root's value matches against. |
accordion.itemlabel | The heading a section's trigger sits in. The APG asks for one, and it is what names the panel: accordion.itemcontent points its aria-labelledby here. |
accordion.itemtrigger | A consumer's onClick runs after the section has opened or closed. |
accordion.itemcontent | The panel. It stays in the page when its section is closed - hidden decides whether it shows, never an arm - so focus, scroll position and the ids other parts point at all survive a close. |
Keyboard
| Key | What it does |
|---|---|
| ↓↓ | Focus the next trigger, wrapping round to the first. |
| ↑↑ | Focus the previous trigger, wrapping round to the last. |
| HomeHome | Focus the first trigger. |
| EndEnd | Focus the last trigger. |
| ⏎Enteror␣Space | Open or close the focused section. |
API reference
accordion.root
| Prop | What it does |
|---|---|
value | Which section is showing. A plain string names one section; with multiple it is the list of sections that are showing. Omit it and everything starts closed. Default ''. |
multiple | Several sections may show at once. Without it, opening one closes the rest. Default false. |
collapsible | Whether the section that is showing may be closed again, leaving nothing open. On by default. Default true. |
disabled | Nothing opens or closes while this is set. Default false. |
disableUntilFound | Closed panels are hidden outright rather than with until-found, so the browser's find-in-page cannot reach the text inside them. Default false. |
onChange | Called with the new value when a person opens or closes a section. Omit it and the accordion still works; the call site simply does nothing. |
accordion.item
| Prop | What it does |
|---|---|
value | Required. This section's own name, and what the root's value names. |
disabled | This one section opens and closes for nobody. Default false. |
accordion.itemlabel
No props of its own.
accordion.itemtrigger
No props of its own.
accordion.itemcontent
No props of its own.
