Accordion
A flexible accordion component for organizing collapsible content sections. Built with native <details> and <summary> elements for accessibility and progressive enhancement.
Features
8 Variants: solid, flat, bordered, outline, ghost, text, glass, frost Selection Modes: Single or multiple expansion **3 Sizes**: sm, md, lg- **Smooth Animation**: content height animates via CSS `grid-template-rows` — no layout thrashing- Accessible: Native HTML semantics, keyboard navigation, screen reader friendly Flexible Content: Support for icons, subtitles, and custom content
Source Code
View Source Code
import { createContext, define, html, prop } from '@vielzeug/craft';
import { computed, type ReadonlySignal } from '@vielzeug/ripple';
import type { ComponentSize, VisualVariant } from '../../types';
import { createListControl } from '../../headless';
import styles from './accordion.css?inline';
/** Context provided by sg-accordion to its sg-accordion-item children. */
export type AccordionContext = {
notifyExpand: (expandedItem: HTMLElement) => void;
selectionMode: ReadonlySignal<'single' | 'multiple' | undefined>;
size: ReadonlySignal<ComponentSize | undefined>;
variant: ReadonlySignal<VisualVariant | undefined>;
};
/** Injection key for the accordion context. */
export const ACCORDION_CTX = createContext<AccordionContext>('AccordionContext');
/** Accordion component properties */
export type SgAccordionEvents = {
change: { expandedItem: HTMLElement };
};
export type SgAccordionProps = {
/** Selection mode (single = only one opens, multiple = multiple can be open) */
selectionMode?: 'single' | 'multiple';
/** Size for all items (propagated via context) */
size?: ComponentSize;
/** Visual variant for all items (propagated via context) */
variant?: VisualVariant;
};
/**
* A container for accordion items with single or multiple selection modes.
*
* @element sg-accordion
* @element sg-accordion-item - Child element for each collapsible panel
*
* @attr {string} selection-mode - Selection mode: 'single' | 'multiple'
* @attr {string} size - Size for all items: 'sm' | 'md' | 'lg' (propagated to children)
* @attr {string} variant - Visual variant: 'solid' | 'flat' | 'bordered' | 'text' | 'glass' | 'frost' (propagated to children)
*
* @fires expand - Emitted when an item expands. detail: { expanded: boolean; item: HTMLElement }
* @fires change - Emitted when selection changes (single mode). detail: { expandedItem: HTMLElement }
*
* @slot - `sg-accordion-item` elements
*
* @cssprop --accordion-bg - Container background color (solid/flat/glass/frost variants)
* @cssprop --accordion-border-color - Container border color (solid/flat variants)
* @cssprop --accordion-divider-color - Divider color between items (text variant)
* @cssprop --accordion-shadow - Container box shadow
* @example
* ```html
* <sg-accordion selection-mode="single">
* <sg-accordion-item>
* <span slot="title">What is Sigil?</span>
* <p>Sigil is a headless web component library.</p>
* </sg-accordion-item>
* <sg-accordion-item>
* <span slot="title">How do I install it?</span>
* <p>Run <code>npm install @vielzeug/sigil</code>.</p>
* </sg-accordion-item>
* </sg-accordion>
* <sg-accordion variant="bordered" selection-mode="multiple">
* <sg-accordion-item expanded><span slot="title">Open by default</span><p>Content</p></sg-accordion-item>
* </sg-accordion>
* ```
*/
export const ACCORDION_TAG = 'sg-accordion' as const;
define<SgAccordionProps, SgAccordionEvents>(ACCORDION_TAG, {
props: {
selectionMode: prop.string<'single' | 'multiple'>(),
size: prop.string<ComponentSize>(),
variant: prop.string<VisualVariant>(),
},
setup(props, { bind, el, emit, provide }) {
const handleSelectionMode = (expandedItem: HTMLElement) => {
if (props.selectionMode.value !== 'single') return;
el.querySelectorAll('sg-accordion-item[expanded]').forEach((item) => {
if (item !== expandedItem && item.hasAttribute('expanded')) {
item.removeAttribute('expanded');
}
});
emit('change', { expandedItem });
};
const getAccordionItems = () => {
return [...el.querySelectorAll<HTMLElement>('sg-accordion-item:not([disabled])')];
};
const getSummaryElements = () => {
return getAccordionItems()
.map((item) => item.shadowRoot?.querySelector<HTMLElement>('summary'))
.filter(Boolean) as HTMLElement[];
};
const listControl = createListControl({
getItems: () => getAccordionItems(),
isItemDisabled: (item: HTMLElement) => item.hasAttribute('disabled'),
loop: true,
onNavigate: (_action, index) => {
const summaries = getSummaryElements();
summaries[index]?.focus();
},
});
provide(ACCORDION_CTX, {
notifyExpand: handleSelectionMode,
selectionMode: computed(() => props.selectionMode.value),
size: props.size,
variant: props.variant,
});
// Group-level event and keyboard handling for WAI-ARIA Accordion pattern
bind({
on: {
expand: (event: Event) => {
const eventTarget = event.composedPath().find((node): node is HTMLElement => node instanceof HTMLElement);
const expandedItem = (event as CustomEvent<{ item?: HTMLElement }>).detail?.item ?? eventTarget;
if (!expandedItem || expandedItem.localName !== 'sg-accordion-item') return;
handleSelectionMode(expandedItem);
},
keydown: (evt: KeyboardEvent) => {
const summaries = getSummaryElements();
if (!summaries.length) return;
const activeSummary = evt
.composedPath()
.find((node): node is HTMLElement => node instanceof HTMLElement && node.localName === 'summary');
const focused = activeSummary ? summaries.indexOf(activeSummary) : -1;
if (focused === -1) return; // focus is not on a summary — let native handling proceed
listControl.set(focused);
listControl.handleKeydown(evt);
},
},
});
return html`<slot></slot>`;
},
styles: [styles],
});View Source Code (Accordion Item)
import { define, html, inject, prop, ref } from '@vielzeug/craft';
import type { ComponentSize, VisualVariant } from '../../types';
import '../../content/icon/icon';
import { coarsePointerMixin } from '../../styles';
import { ACCORDION_CTX } from '../accordion/accordion';
import styles from './accordion-item.css?inline';
/** Accordion item component properties */
export type SgAccordionItemEvents = {
collapse: { expanded: boolean; item: HTMLElement };
expand: { expanded: boolean; item: HTMLElement };
};
export type SgAccordionItemProps = {
/** Disable accordion item interaction */
disabled?: boolean;
/** Whether the item is expanded/open */
expanded?: boolean;
/** Item size */
size?: ComponentSize;
/** Visual style variant */
variant?: VisualVariant;
};
/**
* An individual accordion item with expand/collapse functionality using native details/summary.
*
* @element sg-accordion-item
*
* @attr {boolean} expanded - Whether the item is expanded/open
* @attr {boolean} disabled - Disable accordion item interaction
* @attr {string} size - Item size: 'sm' | 'md' | 'lg'
* @attr {string} variant - Visual variant: 'solid' | 'flat' | 'bordered' | 'outline' | 'ghost' | 'text' | 'glass' | 'frost'
*
* @fires expand - Emitted when item expands. detail: { expanded: boolean; item: HTMLElement }
* @fires collapse - Emitted when item collapses. detail: { expanded: boolean; item: HTMLElement }
*
* @slot prefix - Content before the title (e.g., icons)
* @slot title - Main accordion item title
* @slot subtitle - Optional subtitle text
* @slot suffix - Content after the title (e.g., badges)
* @slot - Accordion item content (shown when expanded)
*
* @cssprop --accordion-item-bg - Background color
* @cssprop --accordion-item-hover-bg - Background color on hover
* @cssprop --accordion-item-border-color - Border color
* @cssprop --accordion-item-title-color - Title text color
* @cssprop --accordion-item-subtitle-color - Subtitle text color
* @cssprop --accordion-item-body-color - Body text color
* @cssprop --accordion-item-radius - Border radius
* @cssprop --accordion-item-transition - Transition duration
* @cssprop --accordion-item-title - Title font size
* @cssprop --accordion-item-subtitle-size - Subtitle font size
* @cssprop --accordion-item-body - Body font size
* @cssprop --accordion-item-details-padding - Summary/header padding
* @cssprop --accordion-item-summary-padding - Content padding
*
* @part item - Item root element.
* @part summary - Summary trigger row.
* @part header - Header container.
* @part title - Title text element.
* @part subtitle - Subtitle text element.
* @part content - Content container.
* @example
* ```html
* <sg-accordion-item><span slot="title">Click to expand</span><p>Content</p></sg-accordion-item>
* <sg-accordion-item expanded variant="bordered"><span slot="title">Title</span><p>Content</p></sg-accordion-item>
* ```
*/
export const ACCORDION_ITEM_TAG = 'sg-accordion-item' as const;
define<SgAccordionItemProps, SgAccordionItemEvents>(ACCORDION_ITEM_TAG, {
props: {
disabled: prop.bool(false),
expanded: prop.bool(false),
size: prop.string<ComponentSize>(),
variant: prop.string<VisualVariant>(),
},
setup(props, { el, emit, onMounted, watch }) {
// Inherit size/variant from a parent sg-accordion when present.
const accordionCtx = inject(ACCORDION_CTX);
if (accordionCtx) {
watch(() => {
const size = accordionCtx.size.value;
const variant = accordionCtx.variant.value;
if (size !== undefined) el.setAttribute('size', size);
if (variant !== undefined) el.setAttribute('variant', variant);
});
}
const titleId = 'accordion-item-title';
const detailsRef = ref<HTMLDetailsElement>();
const summaryRef = ref<HTMLElement>();
let isAnimating = false;
const openItem = () => {
const details = detailsRef.value;
if (!details || details.open) return;
details.classList.add('opening');
details.open = true;
el.toggleAttribute('expanded', true);
emit('expand', { expanded: true, item: el });
requestAnimationFrame(() => {
const inner = details.querySelector<HTMLElement>('.content-inner');
if (!inner) {
details.classList.remove('opening');
isAnimating = false;
return;
}
const onDone = () => {
details.classList.remove('opening');
isAnimating = false;
};
const transitions = inner.getAnimations?.().filter((a) => a instanceof CSSTransition) ?? [];
if (transitions.length > 0) {
Promise.allSettled(transitions.map((a) => a.finished)).then(onDone);
} else {
onDone();
}
});
};
const closeItem = () => {
const details = detailsRef.value;
if (!details || !details.open || isAnimating) return;
isAnimating = true;
details.classList.add('closing');
const inner = details.querySelector<HTMLElement>('.content-inner');
const onDone = () => {
details.classList.remove('closing');
details.open = false;
el.toggleAttribute('expanded', false);
emit('collapse', { expanded: false, item: el });
isAnimating = false;
};
if (inner) {
const transitions = inner.getAnimations?.().filter((a) => a instanceof CSSTransition) ?? [];
if (transitions.length > 0) {
Promise.allSettled(transitions.map((a) => a.finished)).then(onDone);
} else {
onDone();
}
} else {
onDone();
}
};
const handleSummaryClick = (e: Event) => {
e.preventDefault();
const details = detailsRef.value;
if (!details) return;
if (details.open) {
closeItem();
} else {
openItem();
}
};
const handleToggle = () => {
// Only fires for programmatic open/close (e.g. from accordion parent)
const isOpen = detailsRef.value?.open ?? false;
const wasExpanded = Boolean(props.expanded.value);
if (isOpen && !wasExpanded) {
el.toggleAttribute('expanded', true);
emit('expand', { expanded: true, item: el });
} else if (!isOpen && wasExpanded) {
el.toggleAttribute('expanded', false);
emit('collapse', { expanded: false, item: el });
}
};
onMounted(() => {
const details = detailsRef.value;
const summary = summaryRef.value;
if (!details || !summary) return;
// Detect RTL by preferring the closest explicit dir="..." ancestor.
const checkRTL = () => {
let isRTL: boolean | undefined;
// 1) Closest ancestor dir always wins (supports local RTL sections).
let parent: HTMLElement | null = el;
while (parent) {
const dir = parent.getAttribute('dir');
if (dir === 'rtl') {
isRTL = true;
break;
}
if (dir === 'ltr') {
isRTL = false;
break;
}
parent = parent.parentElement;
}
// 2) Fallback to computed direction when no explicit dir is found.
if (isRTL === undefined) {
isRTL = getComputedStyle(el).direction === 'rtl';
}
// 3) Keep markup simple for CSS targeting.
details.classList.toggle('rtl', isRTL);
};
// Check initially
checkRTL();
// Re-check when DOM attributes change
const observer = new MutationObserver((mutations) => {
const dirChanged = mutations.some((m) => m.attributeName === 'dir');
if (dirChanged) {
checkRTL();
}
});
observer.observe(document.documentElement, {
attributeFilter: ['dir'],
attributes: true,
subtree: true,
});
details.addEventListener('toggle', handleToggle);
summary.addEventListener('click', handleSummaryClick);
return () => {
observer.disconnect();
details.removeEventListener('toggle', handleToggle);
summary.removeEventListener('click', handleSummaryClick);
};
});
return html` <details part="item" ?open="${props.expanded}" ref="${detailsRef}">
<summary
part="summary"
:aria-expanded="${() => String(props.expanded.value)}"
:aria-disabled="${() => (props.disabled.value ? 'true' : 'false')}"
ref="${summaryRef}">
<slot name="prefix"></slot>
<div class="header-content" part="header">
<span class="title" part="title" id="${titleId}">
<slot name="title"></slot>
</span>
<span class="subtitle" part="subtitle">
<slot name="subtitle"></slot>
</span>
</div>
<slot name="suffix"></slot>
<sg-icon class="chevron" name="chevron-down" size="20" stroke-width="2" aria-hidden="true"></sg-icon>
</summary>
<div class="content-wrapper" part="content" role="region" aria-labelledby="${titleId}">
<div class="content-inner">
<slot></slot>
</div>
</div>
</details>`;
},
styles: [coarsePointerMixin, styles],
});Basic Usage
<sg-accordion>
<sg-accordion-item>
<span slot="title">First Section</span>
Content for the first section goes here.
</sg-accordion-item>
</sg-accordion>Visual Options
Selection Modes
Multiple (Default)
Allow multiple items to be expanded simultaneously.
Single
Only one item can be expanded at a time.
Variants
Eight variants applied to all items via the parent accordion — six standard plus glass and frost for translucent effects.
Glass & Frost Variants
Modern effects with backdrop blur for elevated UI elements.
Best Used With
Glass and frost variants work best when placed over colorful backgrounds or images to showcase the blur and transparency effects.
Sizes
Three sizes for different contexts.
Customization
Icons & Subtitles
Add icons or descriptive subtitles using slots.
States
Disabled
Prevent interaction with specific items.
API Reference
sg-accordion Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
selection-mode | 'single' | 'multiple' | 'multiple' | Whether multiple items can be expanded simultaneously |
variant | 'solid' | 'flat' | 'bordered' | 'outline' | 'ghost' | 'text' | 'glass' | 'frost' | 'solid' | Visual variant applied to all items |
size | 'sm' | 'md' | 'lg' | 'md' | Size applied to all items |
sg-accordion-item Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
expanded | boolean | false | Whether the item is expanded |
disabled | boolean | false | Disable the item (prevents toggling) |
variant | 'solid' | 'flat' | 'bordered' | 'outline' | 'ghost' | 'text' | 'glass' | 'frost' | 'solid' | Visual variant (usually set via parent) |
size | 'sm' | 'md' | 'lg' | 'md' | Size (usually set via parent) |
Slots
sg-accordion-item
| Slot | Description |
|---|---|
| (default) | Content shown when item is expanded |
title | Title/summary content |
subtitle | Subtitle text shown below the title |
prefix | Content before the title (icons, etc.) |
suffix | Content after the title (badges, custom chevron, etc.) |
Events
sg-accordion
| Event | Detail | Description |
|---|---|---|
change | { expandedItem: HTMLElement | null } | Emitted when selection changes (single mode only) |
sg-accordion-item
| Event | Detail | Description |
|---|---|---|
expand | { expanded: true, item: HTMLElement } | Emitted when the item is expanded |
collapse | { expanded: false, item: HTMLElement } | Emitted when the item is collapsed |
CSS Custom Properties
| Property | Description | Default |
|---|---|---|
--accordion-item-bg | Item background color | transparent |
--accordion-item-radius | Item border radius | var(--rounded-lg) |
--accordion-item-padding | Item inner padding | Size-dependent |
--accordion-item-transition | Transition duration for expand/collapse animation | var(--transition-normal) |
--accordion-border-color | Container border color (solid/flat variants) | Theme-dependent |
--accordion-divider-color | Divider color between items (text variant) | Theme-dependent |
--accordion-shadow | Container box shadow | Theme-dependent |
Accessibility
The accordion component follows WAI-ARIA Accordion Pattern best practices.
sg-accordion
- Built with native
<details>and<summary>elements. - Progressive enhancement - works without JavaScript.
- Content height transitions via
grid-template-rows: 0fr → 1fr— no JavaScript height calculations and no layout thrashing. - Respects
prefers-reduced-motion: the transition plays only when the user hasn’t opted out of animations. - Override the speed with
--accordion-item-transition.
EnterandSpacetoggle expansion.Tabmoves focus between accordion items.
Best Practices
Do:
- Use clear, descriptive titles.
- Use
singlemode for mutually exclusive content.
Don't:
- Nest accordions deeply (max 1-2 levels).
- Hide critical information in a collapsed state.