Skip to content

Accordion

import "surface-elements/accordion"
<surface-accordion>
<details name="faq" open>
<summary>Shipping</summary>
<p>Orders ship within two business days.</p>
</details>
<details name="faq">
<summary>Returns</summary>
<p>Return anything within 30 days.</p>
</details>
</surface-accordion>

Put native <details> elements directly inside <surface-accordion>. They stay the source of truth: open state lives in their open attribute, the <summary> is the button, and find in page, keyboard and screen readers work as with plain HTML. The web component only animates them, and adds its styles to the document, so you do not import the CSS.

Open one, then another
Shipping

Orders ship within two business days, tracked.

Returns

Return anything within 30 days, in its original packaging. We refund the full price once it arrives.

Warranty

Two years on every product.

Give the <details> the same name and opening one closes the others. That is native HTML: it works before the script loads and without it. Leave name out to let several stay open.

<surface-accordion>
<details><summary>Ingredients</summary>…</details>
<details><summary>Nutrition</summary>…</details>
</surface-accordion>

Add persist and the last open item cannot be closed: clicking its summary does nothing until another item opens. If no item is open when the element connects, the first one opens. With a shared name that means exactly one open at all times.

<surface-accordion persist>
<details name="plan" open><summary>Monthly</summary>…</details>
<details name="plan"><summary>Yearly</summary>…</details>
</surface-accordion>

The locked summary gets aria-disabled="true" while it is the only one open, so screen readers announce that it cannot be collapsed. The attribute is removed as soon as another item opens or persist goes away.

Mark the summary with aria-disabled="true". Clicks, Enter and Space, and toggle() leave that item as it is, open or closed. The summary stays focusable, so the item can still be found and read.

<details>
<summary aria-disabled="true">Express shipping (unavailable)</summary>
<p>…</p>
</details>

To dim disabled items, match closed ones only, since with persist the open item’s summary carries aria-disabled too:

details:not([open]) > summary[aria-disabled="true"] {
opacity: 0.6;
}

In a name group, opening another item still closes a disabled one: that is the browser’s exclusivity, and only the item’s own summary is locked. Without the script, aria-disabled is only announced; the item still toggles.

toggle(item, force?) animates like a click. Without force it flips the item; with it, it opens (true) or closes (false). It resolves when the animation ends, and does nothing to a disabled item or, with persist, to the last open one.

const accordion = document.querySelector("surface-accordion")
for (const item of accordion.items) accordion.toggle(item, true)

Setting item.open directly still works; it just skips the fallback animation.

surface-accordion {
--surface-accordion-duration: 0.4s;
--surface-accordion-easing: cubic-bezier(0.2, 0.9, 0.3, 1);
}

Under prefers-reduced-motion: reduce the items open and close at once.

Where the fallback runs, a closing item keeps open until the animation ends and carries data-closing meanwhile. Match both, so a marker turns as soon as the reader clicks:

details[open]:not([data-closing]) > summary::after {
rotate: 45deg;
}

The whole web component in one file, with its styles and dependencies inlined. Save it as surface-accordion.js in your project and load it as a module. Nothing to install, and no updates unless you copy it again: see Use without installing.

<script type="module" src="/surface-accordion.js"></script>
surface-accordion.js (12.6 kB)
surface-accordion.js
/*! <surface-accordion> from surface-elements, https://github.com/anuarhdz/surface-elements
* MIT License, Anuar Reyes <anuar@hey.com>
*
* Copy file: the whole web component in one module, with nothing to
* install. Load it with <script type="module" src="…"></script>.
* Generated by `pnpm build` from src/; do not edit it in the repo.
*/
//#region src/utils/define.ts
function defineElement(tagName, constructor) {
if (customElements.get(tagName) === void 0) customElements.define(tagName, constructor);
}
//#endregion
//#region src/accordion/styles.ts
/**
* Accordion styles for the `<details>` directly inside `<surface-accordion>`.
* Light DOM, wrapped in the `surface-elements` cascade layer so any unlayered
* consumer rule wins. The selectors match before the web component is defined,
* so the styles also work without its JavaScript.
*
* Where `::details-content` and `interpolate-size` exist (Chromium), the
* open and close animation is pure CSS. Elsewhere the panels toggle at once,
* and the web component animates them with the Web Animations API.
*/
const accordionStyles = `
@layer surface-elements {
surface-accordion {
display: block;
}
/* Disabled by the author, or the last open item with the persist attribute. */
surface-accordion > details > summary[aria-disabled="true"] {
cursor: default;
}
@supports (interpolate-size: allow-keywords) and selector(::details-content) {
surface-accordion > details {
--_duration: var(--surface-accordion-duration, 0.3s);
interpolate-size: allow-keywords;
}
/* Clips only while closed or moving: overflow flips to visible once the
open transition ends (its delay), and back to clip as closing starts. */
surface-accordion > details::details-content {
block-size: 0;
opacity: 0;
overflow-y: clip;
transition-property: block-size, opacity, content-visibility, overflow;
transition-duration: var(--_duration), var(--_duration), var(--_duration), 0s;
transition-delay: 0s;
transition-timing-function: var(--surface-accordion-easing, ease);
transition-behavior: allow-discrete;
}
surface-accordion > details[open]::details-content {
block-size: auto;
opacity: 1;
overflow-y: visible;
transition-delay: 0s, 0s, 0s, var(--_duration);
}
}
@media (prefers-reduced-motion: reduce) {
surface-accordion > details {
--_duration: 0s;
}
}
}
`;
let adopted = false;
/** Adds {@link accordionStyles} to the document once. */
function adoptAccordionStyles(root = document) {
if (adopted) return;
adopted = true;
if ("adoptedStyleSheets" in root && "replaceSync" in CSSStyleSheet.prototype) {
const sheet = new CSSStyleSheet();
sheet.replaceSync(accordionStyles);
root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet];
return;
}
const style = root.createElement("style");
style.dataset["surfaceElements"] = "accordion";
style.textContent = accordionStyles;
root.head.append(style);
}
//#endregion
//#region src/accordion/accordion.ts
/** Set on a `<details>` while it animates closed, before `open` goes away. */
const CLOSING_ATTRIBUTE = "data-closing";
/**
* Web component that animates the native `<details>` elements directly
* inside it as they open and close. The `<details>` stay the source of
* truth: open state lives in `open`, and a shared `name` makes them
* exclusive (one open at a time), as in plain HTML. Adds the accordion styles
* to the document, so no CSS import is needed.
*
* Where the browser can animate `::details-content` to `auto` (Chromium), the
* styles do all the work. Elsewhere the web component animates the height and
* the content's opacity with the Web Animations API, and keeps the closing
* `<details>` open, marked with `data-closing`, until the animation ends.
*
* A `<summary aria-disabled="true">` disables its item: clicks, keys and
* {@link SurfaceAccordion.toggle} leave it as it is.
*
* @tag surface-accordion
* @slot - The `<details>` elements, as direct children.
* @attr {boolean} persist - Keep at least one item open: the last open item cannot be closed, and the first item opens if none is. Its summary gets `aria-disabled="true"` while it is the only one open.
* @cssprop [--surface-accordion-duration=0.3s] - Open and close duration.
* @cssprop [--surface-accordion-easing=ease] - Open and close easing.
*/
var SurfaceAccordion = class extends HTMLElement {
static tagName = "surface-accordion";
static observedAttributes = ["persist"];
#observer;
#animations = /* @__PURE__ */ new WeakMap();
#names = /* @__PURE__ */ new WeakMap();
#persisted = /* @__PURE__ */ new WeakSet();
#settles = /* @__PURE__ */ new WeakMap();
/** The direct child `<details>` elements. */
get items() {
return [...this.querySelectorAll(":scope > details")];
}
/**
* Opens or closes `item`, one of {@link SurfaceAccordion.items}, with the
* animation. Without `force` it flips the current state. Resolves when the
* animation ends or another toggle of the same item replaces it. Does
* nothing to a disabled item, or to the last open one with `persist`.
*/
toggle(item, force) {
const open = force ?? !isOpen(item);
if (open === isOpen(item) || isDisabled(item)) return Promise.resolve();
if (this.#animates()) return this.#animate(item, open);
this.#finish(item, open);
item.open = open;
this.#settles.get(item)?.();
return new Promise((resolve) => {
this.#settles.set(item, resolve);
transitionsOf(item).then(() => {
if (this.#settles.get(item) === resolve) this.#settles.delete(item);
resolve();
});
});
}
connectedCallback() {
adoptAccordionStyles();
this.addEventListener("click", this.#onClick);
this.addEventListener("toggle", this.#onToggle, true);
this.#observer ??= new MutationObserver((records) => {
for (const { removedNodes } of records) for (const node of removedNodes) if (node instanceof HTMLDetailsElement) this.#unlock(node);
this.#syncPersist();
});
this.#observer.observe(this, { childList: true });
this.#syncPersist();
}
attributeChangedCallback() {
if (this.isConnected) this.#syncPersist();
}
disconnectedCallback() {
this.removeEventListener("click", this.#onClick);
this.removeEventListener("toggle", this.#onToggle, true);
this.#observer?.disconnect();
for (const item of this.items) {
this.#finish(item, isOpen(item));
this.#unlock(item);
}
}
#onClick = (event) => {
if (event.defaultPrevented) return;
const target = event.target;
if (!(target instanceof Element)) return;
const summary = target.closest("summary");
const item = summary?.parentElement;
if (!(item instanceof HTMLDetailsElement) || item.parentElement !== this || summaryOf(item) !== summary) return;
if (target.closest("a, button, input, select, textarea, label") !== null) return;
if (isDisabled(item)) {
event.preventDefault();
return;
}
if (!this.#animates()) return;
event.preventDefault();
this.#animate(item, !isOpen(item));
};
#onToggle = (event) => {
if (event.target instanceof HTMLDetailsElement) this.#syncPersist();
};
#syncPersist(openFirst = true) {
const items = this.items;
const persist = this.hasAttribute("persist");
if (openFirst && persist && !items.some(isOpen)) {
const first = items.find((item) => !this.#authorDisabled(item));
if (first) first.open = true;
}
const open = items.filter(isOpen);
for (const item of items) {
const summary = summaryOf(item);
if (!summary) continue;
const lock = persist && open.length === 1 && open[0] === item;
if (lock && !isDisabled(item)) {
summary.setAttribute("aria-disabled", "true");
this.#persisted.add(summary);
} else if (!lock && this.#persisted.has(summary)) this.#unlock(item);
}
}
#authorDisabled(item) {
const summary = summaryOf(item);
return isDisabled(item) && !(summary && this.#persisted.has(summary));
}
#unlock(item) {
const summary = summaryOf(item);
if (!summary || !this.#persisted.has(summary)) return;
summary.removeAttribute("aria-disabled");
this.#persisted.delete(summary);
}
#animates() {
return "animate" in Element.prototype && !supportsDetailsAnimation() && !matchMedia("(prefers-reduced-motion: reduce)").matches;
}
async #animate(item, open) {
const name = item.getAttribute("name") ?? this.#names.get(item);
if (open && name) for (const other of this.items) {
if (other === item || !isOpen(other)) continue;
if ((other.getAttribute("name") ?? this.#names.get(other)) !== name) continue;
this.#animate(other, false);
}
const from = item.getBoundingClientRect().height;
const content = panels(item);
const opacity = content.map((el) => getComputedStyle(el).opacity);
this.#cancel(item);
let to;
if (open) {
this.#restoreName(item);
item.removeAttribute(CLOSING_ATTRIBUTE);
item.open = true;
to = item.getBoundingClientRect().height;
} else {
const stripped = item.getAttribute("name");
if (stripped !== null) {
this.#names.set(item, stripped);
item.removeAttribute("name");
}
item.setAttribute(CLOSING_ATTRIBUTE, "");
to = closedHeight(item);
}
const timing = timingOf(item);
const box = {
boxSizing: "border-box",
overflow: "hidden"
};
const animations = [item.animate([{
...box,
blockSize: `${from}px`
}, {
...box,
blockSize: `${to}px`
}], timing), ...content.map((el, i) => el.animate({ opacity: [opacity[i] ?? "1", open ? "1" : "0"] }, timing))];
this.#animations.set(item, animations);
this.#syncPersist(false);
try {
await Promise.all(animations.map((animation) => animation.finished));
} catch {
return;
}
this.#animations.delete(item);
this.#finish(item, open);
for (const animation of animations) animation.cancel();
}
#cancel(item) {
for (const animation of this.#animations.get(item) ?? []) animation.cancel();
this.#animations.delete(item);
}
#finish(item, open) {
this.#cancel(item);
if (item.hasAttribute("data-closing")) {
item.removeAttribute(CLOSING_ATTRIBUTE);
item.open = open;
}
this.#restoreName(item);
}
#restoreName(item) {
const name = this.#names.get(item);
if (name === void 0) return;
this.#names.delete(item);
item.setAttribute("name", name);
}
};
const isOpen = (item) => item.open && !item.hasAttribute("data-closing");
const summaryOf = (item) => item.querySelector(":scope > summary");
const isDisabled = (item) => summaryOf(item)?.getAttribute("aria-disabled") === "true";
/** Whether the styles alone animate `<details>` (Chromium). */
function supportsDetailsAnimation() {
if (!("CSS" in globalThis)) return false;
return CSS.supports("interpolate-size", "allow-keywords") && CSS.supports("selector(::details-content)");
}
async function transitionsOf(item) {
if (!supportsDetailsAnimation()) return;
const style = getComputedStyle(item, "::details-content");
const delays = style.transitionDelay.split(",").map(milliseconds);
const total = Math.max(0, ...style.transitionDuration.split(",").map((duration, i) => milliseconds(duration) + (delays[i] ?? 0)));
if (total === 0) return;
await nextFrame();
await new Promise((resolve) => setTimeout(resolve, total));
await nextFrame();
}
const nextFrame = () => new Promise((resolve) => {
requestAnimationFrame(() => resolve());
setTimeout(resolve, 100);
});
const panels = (item) => [...item.children].filter((el) => el instanceof HTMLElement && el.localName !== "summary");
function closedHeight(item) {
const style = getComputedStyle(item);
const summary = summaryOf(item);
let height = [
style.paddingBlockStart,
style.paddingBlockEnd,
style.borderBlockStartWidth,
style.borderBlockEndWidth
].reduce((sum, value) => sum + (Number.parseFloat(value) || 0), 0);
if (summary) {
const own = getComputedStyle(summary);
height += summary.getBoundingClientRect().height + (Number.parseFloat(own.marginBlockStart) || 0) + (Number.parseFloat(own.marginBlockEnd) || 0);
}
return height;
}
function timingOf(item) {
const hadStyle = item.hasAttribute("style");
const { animationDuration, animationTimingFunction } = item.style;
item.style.animationDuration = "var(--surface-accordion-duration, 0.3s)";
item.style.animationTimingFunction = "var(--surface-accordion-easing, ease)";
const style = getComputedStyle(item);
const timing = {
duration: milliseconds(style.animationDuration),
easing: style.animationTimingFunction || "ease"
};
item.style.animationDuration = animationDuration;
item.style.animationTimingFunction = animationTimingFunction;
if (!hadStyle && item.getAttribute("style") === "") item.removeAttribute("style");
return timing;
}
const milliseconds = (time) => {
const value = Number.parseFloat(time) || 0;
return time.trim().endsWith("ms") ? value : value * 1e3;
};
defineElement(SurfaceAccordion.tagName, SurfaceAccordion);
//#endregion
export { CLOSING_ATTRIBUTE, SurfaceAccordion, supportsDetailsAnimation };

Attributes

AttributeTypeDescription
persistbooleanKeep at least one item open: the last open item cannot be closed, and the first item opens if none is. Its summary gets aria-disabled="true" while it is the only one open.

Properties

PropertyTypeDescription
itemsHTMLDetailsElement[], read-onlyThe direct child <details> elements.

Methods

MethodReturnsDescription
toggle(item, force?)Promise<void>Opens or closes item, one of SurfaceAccordion.items, with the animation. Without force it flips the current state. Resolves when the animation ends or another toggle of the same item replaces it. Does nothing to a disabled item, or to the last open one with persist.

Slots

SlotDescription
(default)The <details> elements, as direct children.

CSS custom properties

PropertyDefaultDescription
--surface-accordion-duration0.3sOpen and close duration.
--surface-accordion-easingeaseOpen and close easing.

The fallback sets data-closing on a <details> while it animates closed.

The styles are in the surface-elements cascade layer, so your own rules win. For CSS-only use, import surface-elements/accordion.css: Chromium animates, other browsers open and close at once.

Minimal markup: native <details>, with a shared name for an exclusive group.

<div class="accordion">
<details name="faq"><summary>Shipping</summary><p>…</p></details>
<details name="faq"><summary>Returns</summary><p>…</p></details>
</div>

Minimal CSS. Where supported, ::details-content and interpolate-size animate to the content’s auto height, and content-visibility with allow-discrete keeps the content visible while it closes. The panel clips only while it is closed or moving: overflow flips to visible when the opening transition ends (its delay) and back to clip as soon as it starts closing, so dropdowns and focus rings inside an open panel are not cut off.

@supports (interpolate-size: allow-keywords) and selector(::details-content) {
.accordion details {
interpolate-size: allow-keywords;
}
.accordion details::details-content {
block-size: 0;
opacity: 0;
overflow-y: clip;
transition-property: block-size, opacity, content-visibility, overflow;
transition-duration: 0.3s, 0.3s, 0.3s, 0s;
transition-behavior: allow-discrete;
}
.accordion details[open]::details-content {
block-size: auto;
opacity: 1;
overflow-y: visible;
transition-delay: 0s, 0s, 0s, 0.3s;
}
}

What the JavaScript adds, and what happens without it:

  • Animation where CSS cannot. Where the CSS above is not supported, a click on a <summary> is intercepted (preventDefault). Opening sets open, then animates the <details> height from its closed size to its full size with element.animate() and fades the content in. Closing animates down to the summary’s height and removes open at the end. Without it, the items open and close at once, which is still correct.
  • Exclusive groups during the animation. Setting open on one item of a name group closes the others immediately, before they could animate. So the closing sibling loses its name while it animates and gets it back when done.
  • Interruptions. A click while an item animates starts the new animation from its current height.
  • Overflow. While it animates, the fallback sets overflow: hidden on the <details>; at rest nothing is clipped. Content that sticks out of a panel (a dropdown, a focus ring) is cut off only during the animation, as in the CSS above.
  • persist and disabled items. Native <details> cannot refuse to toggle, so the click on a locked summary is cancelled. A toggle listener keeps aria-disabled on the only open item’s summary.
  • Clicks on links and controls inside the <summary> are left alone, and nested accordions handle their own items.

Accessibility checklist:

  • Keep the <summary> as the first child of each <details>: it is the button, with its expanded state, Enter and Space built in.
  • Put a heading inside the <summary> if the panels are page sections (<summary><h3>Shipping</h3></summary>).
  • Respect prefers-reduced-motion.
  • Keep open on the item until it has visibly closed, so what is read and what is seen agree.
  • Use aria-disabled, not disabled (summaries have none), and keep disabled summaries focusable.
  • An expanded item that cannot be collapsed gets aria-disabled="true" on its summary (APG accordion pattern).

Browser support and fallbacks:

  • <details> and exclusive name groups are Baseline.
  • ::details-content is Baseline newly available; interpolate-size is Chromium only. The web component’s Web Animations fallback covers Safari and Firefox.