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

  • 🎨 6 Variants: solid, flat, bordered, outline, ghost, text
  • 🔄 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 {
  computed,
  createContext,
  defineComponent,
  handle,
  html,
  typed,
  provide,
  type ReadonlySignal,
} from '@vielzeug/craftit';

import type { ComponentSize, VisualVariant } from '../../types';

/** Context provided by bit-accordion to its bit-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');

import styles from './accordion.css?inline';

/** Accordion component properties */

export type BitAccordionEvents = {
  change: { expandedItem: HTMLElement };
};

export type BitAccordionProps = {
  /** 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 bit-accordion
 *
 * @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
 * @fires change - Emitted when selection changes (single mode)
 *
 * @slot - Accordion item elements (bit-accordion-item)
 *
 * @example
 * ```html
 * <bit-accordion selection-mode="single"><bit-accordion-item>...</bit-accordion-item></bit-accordion>
 * <bit-accordion variant="frost" size="lg"><bit-accordion-item>...</bit-accordion-item></bit-accordion>
 * ```
 */

export const ACCORDION_TAG = defineComponent<BitAccordionProps, BitAccordionEvents>({
  props: {
    selectionMode: typed<BitAccordionProps['selectionMode']>(undefined),
    size: typed<BitAccordionProps['size']>(undefined),
    variant: typed<BitAccordionProps['variant']>(undefined),
  },
  setup({ emit, host, props }) {
    const notifyExpand = (expandedItem: HTMLElement) => {
      if (props.selectionMode.value === 'single') {
        host.querySelectorAll('bit-accordion-item').forEach((item) => {
          if (item !== expandedItem && item.hasAttribute('expanded')) {
            item.removeAttribute('expanded');
          }
        });
        emit('change', { expandedItem });
      }
    };

    provide(ACCORDION_CTX, {
      notifyExpand,
      selectionMode: computed(() => props.selectionMode.value),
      size: props.size,
      variant: props.variant,
    });

    // Listen for expanded events bubbling up from child accordion-items.
    // This allows single-selection management without tight coupling via context calls.
    const handleExpand = (e: Event) => notifyExpand(e.target as HTMLElement);

    handle(host, 'expand', handleExpand);
    // Group-level arrow-key navigation between accordion item summaries (WAI-ARIA Accordion pattern).
    handle(host, 'keydown', (e: KeyboardEvent) => {
      if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp' && e.key !== 'Home' && e.key !== 'End') return;

      const items = [...host.querySelectorAll<HTMLElement>('bit-accordion-item:not([disabled])')];
      const summaries = items
        .map((item) => item.shadowRoot?.querySelector<HTMLElement>('summary'))
        .filter(Boolean) as HTMLElement[];

      if (!summaries.length) return;

      const focused = summaries.indexOf(document.activeElement as HTMLElement);

      if (focused === -1) return; // the focus is not on a summary — let native handling proceed

      let next = focused;

      if (e.key === 'ArrowDown') next = (focused + 1) % summaries.length;
      else if (e.key === 'ArrowUp') next = (focused - 1 + summaries.length) % summaries.length;
      else if (e.key === 'Home') next = 0;
      else if (e.key === 'End') next = summaries.length - 1;

      e.preventDefault();
      summaries[next]?.focus();
    });

    return html`<slot></slot>`;
  },
  styles: [styles],
  tag: 'bit-accordion',
});
View Source Code (Accordion Item)
ts
import {
  computed,
  defineComponent,
  handle,
  html,
  typed,
  inject,
  onMount,
  ref,
  syncContextProps,
  watch,
} from '@vielzeug/craftit';

import type { ComponentSize, VisualVariant } from '../../types';

import { coarsePointerMixin } from '../../styles';
import { ACCORDION_CTX } from '../accordion/accordion';
import styles from './accordion-item.css?inline';

/** Accordion item component properties */

export type BitAccordionItemEvents = {
  collapse: { expanded: boolean; item: HTMLElement };
  expand: { expanded: boolean; item: HTMLElement };
};

export type BitAccordionItemProps = {
  /** 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 bit-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
 * @fires collapse - Emitted when item collapses
 *
 * @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-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
 *
 * @example
 * ```html
 * <bit-accordion-item><span slot="title">Click to expand</span><p>Content</p></bit-accordion-item>
 * <bit-accordion-item expanded variant="bordered"><span slot="title">Title</span><p>Content</p></bit-accordion-item>
 * ```
 */

export const ACCORDION_ITEM_TAG = defineComponent<BitAccordionItemProps, BitAccordionItemEvents>({
  props: {
    disabled: typed<boolean>(false),
    expanded: typed<boolean>(false),
    size: typed<BitAccordionItemProps['size']>(undefined),
    variant: typed<BitAccordionItemProps['variant']>(undefined),
  },
  setup({ emit, host, props }) {
    // Inherit size/variant from a parent bit-accordion when present.
    const accordionCtx = inject(ACCORDION_CTX, undefined);

    syncContextProps(accordionCtx, props, ['size', 'variant']);

    const titleId = 'accordion-item-title';
    const detailsRef = ref<HTMLDetailsElement>();
    const summaryRef = ref<HTMLElement>();
    const handleToggle = () => {
      const isOpen = detailsRef.value?.open ?? false;

      // Notify accordion parent for single-selection management
      if (isOpen && !host.hasAttribute('expanded')) {
        host.setAttribute('expanded', '');
        emit('expand', { expanded: true, item: host });
      } else if (!isOpen && host.hasAttribute('expanded')) {
        host.removeAttribute('expanded');
        emit('collapse', { expanded: false, item: host });
      }
    };

    onMount(() => {
      const details = detailsRef.value;
      const summary = summaryRef.value;

      if (!details || !summary) return;

      // Sync details.open when expanded prop changes (needs live DOM refs)
      watch(
        props.expanded,
        (v) => {
          const expanded = Boolean(v);

          details.open = expanded;
          summary.setAttribute('aria-expanded', expanded ? 'true' : 'false');
        },
        { immediate: true },
      );
      handle(details, 'toggle', handleToggle);
    });

    const ariaExpanded = computed(() => (props.expanded.value ? 'true' : 'false'));
    const ariaDisabled = computed(() => (props.disabled.value ? 'true' : 'false'));

    return html` <details part="item" ?open=${props.expanded} ref=${detailsRef}>
      <summary part="summary" :aria-expanded=${ariaExpanded} :aria-disabled=${ariaDisabled} 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>
        <svg
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          stroke-width="2"
          stroke-linecap="round"
          stroke-linejoin="round"
          class="chevron"
          part="chevron"
          xmlns="http://www.w3.org/2000/svg">
          <path d="m 14.999979,5.9999793 -5.9999997,5.9999997 5.9999997,6" />
        </svg>
      </summary>
      <div class="content-wrapper" part="content" role="region" aria-labelledby="${titleId}">
        <div class="content-inner">
          <slot></slot>
        </div>
      </div>
    </details>`;
  },
  styles: [coarsePointerMixin, styles],
  tag: 'bit-accordion-item',
});

Basic Usage

html
<bit-accordion>
  <bit-accordion-item>
    <span slot="title">First Section</span>
    Content for the first section goes here.
  </bit-accordion-item>
</bit-accordion>

<script type="module">
  import '@vielzeug/buildit/accordion';
  import '@vielzeug/buildit/accordion-item';
</script>

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 visual variants applied to all items via the parent accordion.

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

bit-accordion Attributes

AttributeTypeDefaultDescription
selection-mode'single' | 'multiple''multiple'Whether multiple items can be expanded simultaneously
variant'solid' | 'flat' | 'bordered' | 'outline' | 'ghost' | 'text''solid'Visual variant applied to all items
size'sm' | 'md' | 'lg''md'Size applied to all items

bit-accordion-item Attributes

AttributeTypeDefaultDescription
expandedbooleanfalseWhether the item is expanded
disabledbooleanfalseDisable the item (prevents toggling)
variant'solid' | 'flat' | 'bordered' | 'outline' | 'ghost' | 'text''solid'Visual variant (usually set via parent)
size'sm' | 'md' | 'lg''md'Size (usually set via parent)

Slots

bit-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

bit-accordion

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

bit-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-bgBackground colortransparent
--accordion-item-radiusBorder radius0.375rem
--accordion-item-paddingInner paddingSize-dependent
--accordion-item-transitionTransition duration for expand/collapse animationvar(--transition-normal)

Accessibility

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

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