Skip to content

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
ts
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)
ts
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

html
<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.

PreviewCode
RTL

Single

Only one item can be expanded at a time.

PreviewCode
RTL

Variants

Eight variants applied to all items via the parent accordion — six standard plus glass and frost for translucent effects.

PreviewCode
RTL

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.

PreviewCode
RTL

Sizes

Three sizes for different contexts.

PreviewCode
RTL

Customization

Icons & Subtitles

Add icons or descriptive subtitles using slots.

PreviewCode
RTL

States

Disabled

Prevent interaction with specific items.

PreviewCode
RTL

API Reference

sg-accordion Attributes

AttributeTypeDefaultDescription
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

AttributeTypeDefaultDescription
expandedbooleanfalseWhether the item is expanded
disabledbooleanfalseDisable 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

SlotDescription
(default)Content shown when item is expanded
titleTitle/summary content
subtitleSubtitle text shown below the title
prefixContent before the title (icons, etc.)
suffixContent after the title (badges, custom chevron, etc.)

Events

sg-accordion

EventDetailDescription
change{ expandedItem: HTMLElement | null }Emitted when selection changes (single mode only)

sg-accordion-item

EventDetailDescription
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

PropertyDescriptionDefault
--accordion-item-bgItem background colortransparent
--accordion-item-radiusItem border radiusvar(--rounded-lg)
--accordion-item-paddingItem inner paddingSize-dependent
--accordion-item-transitionTransition duration for expand/collapse animationvar(--transition-normal)
--accordion-border-colorContainer border color (solid/flat variants)Theme-dependent
--accordion-divider-colorDivider color between items (text variant)Theme-dependent
--accordion-shadowContainer box shadowTheme-dependent

Accessibility

The accordion component follows WAI-ARIA Accordion Pattern best practices.

sg-accordion

Native Semantics
  • Built with native <details> and <summary> elements.
  • Progressive enhancement - works without JavaScript.
Smooth Animation
  • 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.
Keyboard Navigation
  • Enter and Space toggle expansion.
  • Tab moves focus between accordion items.

Best Practices

Do:

  • Use clear, descriptive titles.
  • Use single mode for mutually exclusive content.

Don't:

  • Nest accordions deeply (max 1-2 levels).
  • Hide critical information in a collapsed state.