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 { define, computed, createContext, html, provide, signal, type ReadonlySignal } from '@vielzeug/craftit';
import { createListControl } from '@vielzeug/craftit/controls';

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)
 *
 * @cssprop --blur-lg - Backdrop blur for frosted accordion variants
 * @cssprop --border - Border token used for accordion item outlines
 * @cssprop --color-canvas - Base panel background color
 * @cssprop --color-contrast - Primary text contrast color within items
 * @cssprop --color-contrast-100 - Hover and active background contrast tone
 * @cssprop --color-contrast-200 - Divider and border contrast color
 * @cssprop --color-contrast-50 - Soft background for subtle variants
 * @cssprop --color-secondary - Accent color for selected or emphasized states
 * @cssprop --inset-shadow-xs - Inset shadow used for bordered/flat depth
 * @cssprop --rounded-lg - Corner radius for accordion item containers
 * @cssprop --shadow-md - Elevated shadow for prominent accordion styles
 * @cssprop --shadow-xs - Subtle shadow for default accordion depth
 * @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 = define<BitAccordionProps, BitAccordionEvents>('bit-accordion', {
  props: {
    selectionMode: undefined,
    size: undefined,
    variant: undefined,
  },

  setup(props, { emit, host }) {
    const focusedIndex = signal(0);

    const handleSelectionMode = (expandedItem: HTMLElement) => {
      if (props.selectionMode.value !== 'single') return;

      host.el.querySelectorAll('bit-accordion-item[expanded]').forEach((item) => {
        if (item !== expandedItem && item.hasAttribute('expanded')) {
          item.removeAttribute('expanded');
        }
      });

      emit('change', { expandedItem });
    };

    const getAccordionItems = () => {
      return [...host.el.querySelectorAll<HTMLElement>('bit-accordion-item:not([disabled])')];
    };

    const getSummaryElements = () => {
      return getAccordionItems()
        .map((item) => item.shadowRoot?.querySelector<HTMLElement>('summary'))
        .filter(Boolean) as HTMLElement[];
    };

    const listControl = createListControl({
      getIndex: () => focusedIndex.value,
      getItems: () => getAccordionItems(),
      isItemDisabled: (item: HTMLElement) => item.hasAttribute('disabled'),
      loop: true,
      setIndex: (index) => {
        focusedIndex.value = 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

    host.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 !== 'bit-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

          focusedIndex.value = focused;
          listControl.handleKeydown(evt);
        },
      },
    });

    return () => html`<slot></slot>`;
  },

  styles: [styles],
});
View Source Code (Accordion Item)
ts
import { define, effect, html, inject, ref, onMounted } from '@vielzeug/craftit';

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 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
 *
 * @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
 * <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 = define<BitAccordionItemProps, BitAccordionItemEvents>('bit-accordion-item', {
  props: {
    disabled: false,
    expanded: false,
    size: undefined,
    variant: undefined,
  },

  setup(props, { emit, host }) {
    // Inherit size/variant from a parent bit-accordion when present.
    const accordionCtx = inject(ACCORDION_CTX);

    if (accordionCtx) {
      effect(() => {
        const size = accordionCtx.size.value;
        const variant = accordionCtx.variant.value;

        if (size !== undefined) props.size.value = size;

        if (variant !== undefined) props.variant.value = variant;
      });
    }

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

      // Notify accordion parent for single-selection management
      if (isOpen && !wasExpanded) {
        props.expanded.value = true;
        emit('expand', { expanded: true, item: host.el });
      } else if (!isOpen && wasExpanded) {
        props.expanded.value = false;
        emit('collapse', { expanded: false, item: host.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 = host.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(host.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);

      return () => {
        observer.disconnect();
        details.removeEventListener('toggle', handleToggle);
      };
    });

    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>
          <bit-icon class="chevron" name="chevron-down" size="20" stroke-width="2" aria-hidden="true"></bit-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
<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.