role disclosure
Accordion draft v0.1.0
A stack of sections whose content shows and hides under a header people activate.
An accordion stacks sections of content, each behind a header: activating the header shows the section, activating it again hides it. It saves vertical space when people need only one or two sections at a time (settings groups, frequently asked questions, order details). Several sections may be open at once; the component never closes one when another opens. One section alone is the same thing with a single item.
Web implementation
Root .ds-accordion on a <div>. Properties map to attributes: size → data-size
packages/components/src/components/accordion/accordion.css
/*
* Accordion — vanilla CSS reference implementation.
* Spec: packages/components/specs/components/accordion.spec.json
* Tokens: packages/tokens/dist/css/components/accordion.css (generated from packages/tokens/src/components/accordion.tokens.json)
*
* Rules:
* - Only --accordion-* custom properties may appear here. No raw values, no semantic tokens.
* - No script: each item is a native details element; its summary is the trigger, the browser
* toggles it with Enter and Space and exposes its expanded state.
* - Size is a data attribute on the root; the states are the summary's pseudo-classes and the
* details' open attribute; [data-state="<state>"] on the root or on an item forces them.
*/
.ds-accordion {
--_title-font-family: var(--accordion-title-typography-medium-font-family);
--_title-font-size: var(--accordion-title-typography-medium-font-size);
--_title-font-weight: var(--accordion-title-typography-medium-font-weight);
--_title-line-height: var(--accordion-title-typography-medium-line-height);
--_title-letter-spacing: var(--accordion-title-typography-medium-letter-spacing);
--_panel-font-family: var(--accordion-panel-typography-medium-font-family);
--_panel-font-size: var(--accordion-panel-typography-medium-font-size);
--_panel-font-weight: var(--accordion-panel-typography-medium-font-weight);
--_panel-line-height: var(--accordion-panel-typography-medium-line-height);
--_panel-letter-spacing: var(--accordion-panel-typography-medium-letter-spacing);
--_trigger-min-height: var(--accordion-trigger-min-height-medium);
--_indicator-size: var(--accordion-indicator-size-medium);
box-sizing: border-box;
display: flex;
flex-direction: column;
min-inline-size: 0;
}
.ds-accordion[data-size="small"] {
--_title-font-family: var(--accordion-title-typography-small-font-family);
--_title-font-size: var(--accordion-title-typography-small-font-size);
--_title-font-weight: var(--accordion-title-typography-small-font-weight);
--_title-line-height: var(--accordion-title-typography-small-line-height);
--_title-letter-spacing: var(--accordion-title-typography-small-letter-spacing);
--_panel-font-family: var(--accordion-panel-typography-small-font-family);
--_panel-font-size: var(--accordion-panel-typography-small-font-size);
--_panel-font-weight: var(--accordion-panel-typography-small-font-weight);
--_panel-line-height: var(--accordion-panel-typography-small-line-height);
--_panel-letter-spacing: var(--accordion-panel-typography-small-letter-spacing);
--_trigger-min-height: var(--accordion-trigger-min-height-small);
--_indicator-size: var(--accordion-indicator-size-small);
}
.ds-accordion__item {
--_trigger-background: var(--accordion-trigger-background-default);
border-block-end: var(--accordion-item-border-width) solid var(--accordion-item-border-color);
}
/* ---- Trigger ------------------------------------------------------------- */
.ds-accordion__trigger {
box-sizing: border-box;
display: flex;
align-items: center;
gap: var(--accordion-trigger-gap);
min-height: var(--_trigger-min-height);
padding-block: var(--accordion-trigger-padding-block);
padding-inline: var(--accordion-trigger-padding-inline);
border-radius: var(--accordion-trigger-border-radius);
background-color: var(--_trigger-background);
color: var(--accordion-title-color);
cursor: pointer;
list-style: none;
user-select: none;
transition: background-color var(--accordion-trigger-transition-duration) var(--accordion-trigger-transition-easing);
}
.ds-accordion__trigger::-webkit-details-marker {
display: none;
}
.ds-accordion__trigger::marker {
content: none;
}
.ds-accordion__title {
flex: 1 1 auto;
font-family: var(--_title-font-family);
font-size: var(--_title-font-size);
font-weight: var(--_title-font-weight);
line-height: var(--_title-line-height);
letter-spacing: var(--_title-letter-spacing);
}
.ds-accordion__indicator {
display: inline-block;
flex: none;
inline-size: var(--_indicator-size);
block-size: var(--_indicator-size);
overflow: hidden;
font-family: var(--accordion-indicator-font-family);
font-size: var(--_indicator-size);
font-weight: var(--accordion-indicator-font-weight);
font-style: normal;
font-variation-settings:
"FILL" var(--accordion-indicator-fill),
"GRAD" var(--accordion-indicator-grade),
"opsz" var(--accordion-indicator-optical-size);
font-feature-settings: "liga";
line-height: var(--_indicator-size);
letter-spacing: normal;
text-transform: none;
white-space: nowrap;
direction: ltr;
-webkit-font-smoothing: antialiased;
transition: rotate var(--accordion-indicator-transition-duration) var(--accordion-indicator-transition-easing);
}
/* ---- Panel --------------------------------------------------------------- */
.ds-accordion__panel {
padding-block: var(--accordion-panel-padding-block);
padding-inline: var(--accordion-panel-padding-inline);
color: var(--accordion-panel-color);
font-family: var(--_panel-font-family);
font-size: var(--_panel-font-size);
font-weight: var(--_panel-font-weight);
line-height: var(--_panel-line-height);
letter-spacing: var(--_panel-letter-spacing);
}
.ds-accordion__panel > :first-child {
margin-block-start: 0;
}
.ds-accordion__panel > :last-child {
margin-block-end: 0;
}
/* ---- States -------------------------------------------------------------- */
:where(.ds-accordion[data-state="hover"], .ds-accordion__item[data-state="hover"]) .ds-accordion__trigger,
.ds-accordion:not([data-state]) .ds-accordion__item:not([data-state]) > .ds-accordion__trigger:hover {
--_trigger-background: var(--accordion-trigger-background-hover);
}
:where(.ds-accordion[data-state="active"], .ds-accordion__item[data-state="active"]) .ds-accordion__trigger,
.ds-accordion__trigger:active {
--_trigger-background: var(--accordion-trigger-background-active);
}
/* Expanded: the details is open (the template also sets open for the forced state). The chevron turns. */
:where(.ds-accordion[data-state="expanded"], .ds-accordion__item[data-state="expanded"]) .ds-accordion__indicator,
.ds-accordion__item[open] > .ds-accordion__trigger .ds-accordion__indicator {
rotate: 180deg;
}
.ds-accordion__trigger:focus {
outline: none;
}
:where(.ds-accordion[data-state="focus-visible"], .ds-accordion__item[data-state="focus-visible"]) .ds-accordion__trigger,
.ds-accordion__trigger:focus-visible {
outline: var(--accordion-trigger-outline-width-focus-visible) solid var(--accordion-trigger-outline-color-focus-visible);
outline-offset: var(--accordion-trigger-outline-offset-focus-visible);
}
@media (prefers-reduced-motion: reduce) {
.ds-accordion__trigger,
.ds-accordion__indicator {
transition: none;
}
}
packages/components/src/components/accordion/accordion.template.mjs
/**
* Accordion — HTML template for the web implementation.
* Pure function of the spec properties (packages/components/specs/components/accordion.spec.json) -> HTML string.
* No script: each item is a native details element whose summary is the trigger; the browser
* toggles it, exposes its expanded state and keeps the content out of the tab order when closed.
*
* Extra prop: `id` names the accordion (item ids derive from it). The title is not a heading: a
* summary is exposed as a button, whose children are presentational, so a heading inside it would
* be lost (see the spec).
* Extra sandbox-only props: `state` on the root forces a state on every item; an entry's `state`
* forces it on that item. The forced expanded state also sets the item open.
*/
import { escapeHtml, escapeAttr, renderIcon } from '../../lib/template-utils.mjs';
export const meta = { id: 'accordion', selector: '.ds-accordion' };
let count = 0;
export function render(props = {}) {
const { items = [{ title: 'Section', content: 'Content' }], size = 'medium', state = null, id = null } = props;
const rootId = id ?? `ds-accordion-${++count}`;
const rootAttrs = ['class="ds-accordion"', `data-size="${escapeAttr(size)}"`];
if (state && state !== 'default') rootAttrs.push(`data-state="${escapeAttr(state)}"`);
const list = items.map((entry, i) => {
const { title = `Section ${i + 1}`, content = '', open = false, state: itemState = null } = entry;
const itemAttrs = ['class="ds-accordion__item"', 'data-part="item"', `id="${escapeAttr(`${rootId}-${i + 1}`)}"`];
const expanded = open || itemState === 'expanded' || state === 'expanded';
if (expanded) itemAttrs.push('open');
if (itemState && itemState !== 'default') itemAttrs.push(`data-state="${escapeAttr(itemState)}"`);
return `<details ${itemAttrs.join(' ')}><summary class="ds-accordion__trigger" data-part="trigger"><span class="ds-accordion__title" data-part="title">${escapeHtml(title)}</span><span class="ds-accordion__indicator" data-part="indicator" aria-hidden="true">${renderIcon('expand_more')}</span></summary><div class="ds-accordion__panel" data-part="panel"><p>${escapeHtml(content)}</p></div></details>`;
});
return `<div ${rootAttrs.join(' ')}>${list.join('')}</div>`;
}
No other platform implementation recorded yet. Platform token outputs live under Platforms.