🚀

Web Componentsで作る、Radix UIライクなAccordionコンポーネント

2025/02/08に公開

はじめに

最近のWebフロントエンド開発はshadcn/ui(中身の大部分はRadix UI)やreact-ariaなどのHeadlessUIコンポーネントを利用し、より宣言的で再利用性の高いUI構築が主流になってきました。

今回は、HeadlessUIの代表格であるRadix UIのAccordionコンポーネントにインスパイアされたコンポーネントを、Web Componentsを使って自作してみようと思います。
Web Componentsの基本的な概念から解説しつつ、段階的にAccordionを構築していく過程を説明します。

Web Componentsとは?

Web Componentsは、再利用可能なカスタムHTML要素を作成するための標準仕様です。主な技術要素は以下の3つです。

  • Custom Elements: 独自のHTMLタグ(要素)を定義できます。
  • Shadow DOM: 要素の内部構造(DOM)やスタイルをカプセル化し、外部の影響を受けにくくします。(今回は使用しません)
  • HTML Templates: <template>タグや<slot>タグを使って、要素の構造をテンプレートとして定義できます。(今回は使用しません)

これらの技術を組み合わせることで、特定のフレームワークに依存しない、独立したコンポーネントを作成できます。
今回はより柔軟で再利用可能なコンポーネントにするためにShadow DOMは利用せずLight DOMで外部のCSSやJSからのアクセスを可能とします。

なぜWeb ComponentsとRadix UIを選ぶのか?

Web Componentsの利点は以下の通りです。

  • 相互運用性: どのJavaScriptフレームワーク(React, Vue, Angularなど)とも組み合わせて使用できます。
  • 再利用性: 一度作成したコンポーネントは、さまざまなプロジェクトで再利用できます。
  • 標準ベース: Web標準技術に基づいているため、将来にわたって利用できる可能性が高いです。

さらに、今回のコンポーネントのモデルとなっている Radix UI (や、それを利用した shadcn/ui) を選ぶ理由、つまり Headless UI を選ぶ理由は以下の通りです。

  • 柔軟なスタイリング: Headless UI はスタイルを持たないため、Tailwind CSS などのユーティリティファースト CSS フレームワークや、CSS Modules、CSS-in-JS など、好みのスタイリング手法を自由に適用できます。
  • アクセシビリティ: Radix UI は WAI-ARIA に準拠しており、アクセシブルなコンポーネントを容易に構築できます。
  • ロジックと UI の分離: Headless UI は状態管理とインタラクションのロジックのみを提供し、UI のレンダリングは開発者に委ねられます。これにより、コンポーネントの見た目を完全に制御できます。
  • 高いカスタマイズ性: 必要に応じてコンポーネントの振る舞いを細かく調整したり、独自の機能を追加したりすることが容易です。

Web Components と Headless UI (Radix UI) の組み合わせは、これらの利点を最大限に活かすことができます。
Web Components の標準ベースの相互運用性、Radix UI の柔軟性とアクセシビリティ、これらを組み合わせることで、特定のフレームワークに縛られず、スタイリングの自由度が高く、アクセシブルで、再利用可能な UI コンポーネント を効率的に開発できます。

作成するAccordionコンポーネントの仕様

今回作成するAccordionコンポーネントは、以下のRadix UIライクな仕様とします。

  • 複数のセクション(アイテム)を持つことができる。
  • 各セクションは、ヘッダー(ui-accordion-header)、トリガー(ui-accordion-trigger)、コンテンツ(ui-accordion-content)部分から構成される。
  • トリガーをクリックすると、対応するコンテンツの表示/非表示が切り替わる。
  • mode属性で挙動を制御:
    • single: 常に1つのセクションのみが開いている状態。
    • multiple (デフォルト): 複数のセクションを同時に開ける。
  • collapsible属性(mode="single"の時のみ有効): 開いているセクションのトリガーを再度クリックしたときに、セクションを閉じることができる。
  • value属性で、開いているセクションを外部から制御可能 (Controlled Component)。
    • single モード: 開いているセクションのvalue属性値 (文字列)。
    • multiple モード: 開いているセクションのvalue属性値をカンマ区切りにした文字列。
  • disabled属性で、Accordion全体または個々のアイテムを無効化できる。
  • ネストされたAccordionに対応。
  • onValueChangeカスタムイベントで、開閉状態の変化を外部に通知。
  • WAI-ARIAに準拠したアクセシビリティ対応。

実装サンプル

開発環境

TypeScriptとzustandをインストールした環境を用意してください。
サンプルコードはCodepenなどでもJSの設定JavaScript PreprocessorTypeScriptを選択することで動作が可能です。

Accordionコンポーネントの実装

1. 状態管理ストア (Zustand)の準備

まず、Accordion全体の状態と、各Accordion Itemの状態を管理するために、Zustandという軽量な状態管理ライブラリを使用します。
Zustandは主にReactで利用されるライブラリですが、vanillajsでも利用が可能です。
ここでは、Zustandのインポート、属性設定を簡略化するユーティリティ関数を準備します。

import { createStore } from "zustand/vanilla";
import { subscribeWithSelector } from "zustand/middleware";

// ユーティリティ関数:属性の一括設定
const setAttrsElement = (element: HTMLElement | null, attributes: { [key: string]: string | undefined }) => {
  for (const [key, value] of Object.entries(attributes)) {
    if (value === undefined) {
      element?.removeAttribute(key);
    } else {
      element?.setAttribute(key, value);
    }
  }
};

2. UiAccordion (Accordion Rootコンポーネント)

Accordion全体の動作を制御するコンポーネントです。子要素のUiAccordionItemを管理し、状態の変更を伝播させます。
Zustandのsubscribeを使うことで、状態の変更を監視し、リアクティブにUIを更新します。これにより、命令的にDOMを操作するのではなく、宣言的なスタイルでUIを構築できます。
reactのuseEffectでUIの更新を行なっているとイメージするとよいかもしれません。

type AccordionMode = 'single' | 'multiple';
type AccordionValue = string | string[] | null;

// Accordion全体の状態
interface AccordionStoreState {
  value: AccordionValue;
  mode: AccordionMode;
  collapsible: boolean;
  disabled: boolean;
  items: UiAccordionItem[]; // 各アイテムのストアを配列で保持
  removeItems: (itemToRemove: UiAccordionItem) => void;
  addItems: (newItems: UiAccordionItem[]) => void;
}

export class UiAccordion extends HTMLElement {
  unsubscribe: (() => void) | undefined = undefined;
  // Zustandストアの作成
  useRootStore = createStore(
    subscribeWithSelector<AccordionStoreState>((set) => ({
      value: [],
      mode: 'multiple', // デフォルトはmultiple
      collapsible: false,
      disabled: false,
      items: [],
      removeItems: (itemToRemove) =>
        set((state) => ({
          items: state.items.filter((item) => item !== itemToRemove),
        })),
      addItems: (newItems) =>
        set((state) => ({
          items: [...state.items, ...newItems],
        })),
    })),
  );

  // 監視する属性
  static get observedAttributes() {
    return ['disabled', 'value'];
  }

  constructor() {
    super();
  }

  connectedCallback(): void {
    // 属性値の取得、デフォルト値の設定
    const mode = (this.getAttribute('mode') as AccordionMode) || this.useRootStore.getState().mode;
    const attrValue = this.getAttribute('value') || null;
    const defaultValue = mode === 'single' ? attrValue : attrValue?.split(',').map((item) => item.trim()) || null;

    // 初期状態を store に反映
    this.useRootStore.setState({
      value: defaultValue,
      mode: mode,
      collapsible: this.hasAttribute('collapsible'),
      disabled: this.hasAttribute('disabled'),
      items: [], // itemsはconnectedCallbackで追加される
    });

    // valueの変更を監視し、onValueChangeイベントを発火
    this.unsubscribe = this.useRootStore.subscribe(
      (state) => ({ value: state.value }),
      (state) => {
        this.dispatchEvent(
          new CustomEvent('onValueChange', {
            detail: { value: state.value },
          }),
        );
      },
    );

    // 接続時に data-ready 属性を追加(data-ready追加後にattributeChangedCallbackが有効になる)
    this.setAttribute('data-ready', '');
  }

  disconnectedCallback(): void {
    if (this.unsubscribe) this.unsubscribe();
    // コンポーネントが破棄される際に、itemsを空にする
    this.useRootStore.setState({ items: [] });
  }

  attributeChangedCallback(property: string, oldValue: string | null, newValue: string | null) {
    // connectedCallback前にも実行されるためdata-ready属性が存在するか確認
    const isReady = this.hasAttribute('data-ready');
    if (!isReady) return;

    // attribute [disabled] の変更を store に反映
    if (property === 'disabled' && oldValue !== newValue) {
      const isDisabled = newValue !== null;
      this.useRootStore.setState({ disabled: isDisabled });
    }
    // attribute [value] の変更を store に反映
    if (property === 'value' && oldValue !== newValue) {
      // singleモードの場合はstring型、multipleモードの場合はstring[]型
      if (this.useRootStore.getState().mode === 'single') {
        this.useRootStore.setState({ value: newValue });
      } else {
        const newValues = newValue?.split(',').map((item) => item.trim()) || [];
        this.useRootStore.setState({ value: newValues });
      }
    }
  }

  // 開いているアイテムのvalueを取得 (getter)
  private getAccordionValue(): AccordionValue {
    const { mode, items } = this.useRootStore.getState();

    if (mode === 'single') {
      const openItem = items.find((item) => item.useItemStore.getState().isOpen);
      return openItem ? openItem.useItemStore.getState().value : null;
    } else {
      const openValues = items
        .filter((item) => item.useItemStore.getState().isOpen)
        .map((item) => item.useItemStore.getState().value)
        .filter((item) => item !== null);
      return openValues.length ? openValues : null;
    }
  }

  // アイテムの状態が変わったときに、Accordion全体のvalueを更新
  updateToggledItems() {
    const newValue = this.getAccordionValue();
    this.useRootStore.setState({ value: newValue });
      // value属性も更新(controlled componentとして)
    setAttrsElement(this, {
      value: Array.isArray(newValue) ? newValue.join(',') : newValue || undefined,
    });
  }
}

Prop

プロパティ名 説明 デフォルト値 備考
mode アコーディオンの動作モード。single(1つのみ開く)または multiple(複数開ける)。 multiple
value 開いているアイテムのvalue。modesingleの場合は文字列、multipleの場合はカンマ区切りの文字列。 null 外部から開閉状態を制御する場合に使用(Controlled Component)。
collapsible modesingleの場合に、開いているアイテムを再度クリックで閉じられるようにするかどうか。 false
disabled アコーディオン全体を無効化するかどうか。 false

onValueChangeカスタムイベントの使い方

UiAccordionコンポーネントは、開いているアイテムが変更されたときにonValueChangeカスタムイベントを発火します。
このイベントをリッスンすることで、Accordionの状態変化を外部から検知し、他の処理と連携できます。

<ui-accordion id="my-accordion" mode="multiple">
  〜省略〜
</ui-accordion>

<script>
  const accordion = document.getElementById('my-accordion');
  accordion.addEventListener('onValueChange', (event) => {
    console.log('Accordion value changed:', event.detail.value);
    // ここで、event.detail.value を使って何か処理を行う
  });
</script>
  • event.detail.value: 開いているアイテムのvalue値。
    • mode="single" の場合は、開いているアイテムのvalue (文字列)、またはnull (開いているアイテムがない場合)。
    • mode="multiple" の場合は、開いているアイテムのvalueの配列、またはnull (開いているアイテムがない場合)。

このonValueChangeイベントは、例えば、Accordionの開閉状態をURLのクエリパラメータと同期させたり、開いているアイテムに応じて動的にコンテンツを読み込んだりする際に活用できます。

3. UiAccordionItem (Accordionの各アイテム)

Accordionの各セクションを表すコンポーネントです。UiAccordionの子要素として使用されます。
ここでもZustandのsubscribeを利用して、状態(isOpen, disabled)の変更を監視し、data-state属性とdata-disabled属性を自動的に更新します。これにより、CSSセレクタで状態に応じたスタイルを適用できます。

// 各Accordion Itemの状態
interface AccordionItemStoreState {
  isOpen: boolean;
  value: string | null;
  disabled: boolean;
}

export class UiAccordionItem extends HTMLElement {
  private $root: UiAccordion | null = null; // 親のUiAccordion要素
  // 各アイテムのZustandストア
  useItemStore = createStore(
    subscribeWithSelector<AccordionItemStoreState>((set) => ({
      value: null,
      isOpen: false,
      disabled: false,
    })),
  );

  unsubscribe: (() => void) | undefined = undefined;
  unsubscribeRoot: (() => void) | undefined = undefined;

  constructor() {
    super();
  }

  static get observedAttributes() {
    return ['disabled', 'value'];
  }

  connectedCallback(): void {
    this.$root = this.closest('ui-accordion'); // 親のui-accordion要素を取得
    if (!this.$root) {
      console.error('ui-accordion-item must be child of ui-accordion');
      return;
    }

    const rootState = this.$root.useRootStore.getState();
    const attrValue = this.getAttribute('value') || null;
    const disabled = rootState.disabled || this.hasAttribute('disabled') || this.useItemStore.getState().disabled;

    // デフォルトで開くかどうかを判定
    const isDefaultOpenSingle = attrValue !== null && rootState.value === attrValue;
    const isDefaultOpenMultiple = !!rootState.value?.includes(attrValue || '');
    const isDefaultOpen = rootState.mode === 'single' ? isDefaultOpenSingle : isDefaultOpenMultiple;

    // 初期状態をストアに設定
    this.useItemStore.setState({
      value: attrValue,
      isOpen: isDefaultOpen,
      disabled: disabled,
    });

    // storeの変更をDOMに反映
    // subscribeWithSelectorで、必要なstateだけ監視
    this.unsubscribe = this.useItemStore.subscribe(
      (state) => ({ disabled: state.disabled, isOpen: state.isOpen }), // 監視するstate
      (state) => {
        setAttrsElement(this, {
          'data-state': state.isOpen ? 'open' : 'closed', // Radix UIに倣ったdata属性
          'data-disabled': state.disabled ? '' : undefined,
        });
      },
    );

    // 親(root)のstoreの変更を監視
    this.unsubscribeRoot = this.$root.useRootStore.subscribe(
      (state) => ({ disabled: state.disabled, value: state.value }),
      (state, oldState) => {
        if (oldState.disabled !== state.disabled) {
          // rootのdisabled 状態を UiAccordionItem で受け取り
          const isItemDisabled = this.hasAttribute('disabled');
          this.useItemStore.setState({ disabled: state.disabled || isItemDisabled });
        }

        if (state.value !== oldState.value) {
          // rootのvalue を UiAccordionItem で受け取り
          // UiAccordionItemのvalueがnullの場合(valueを持っていない場合)は更新しない
          const { value, isOpen } = this.useItemStore.getState();
          if (value === null) return;

          const setOpen = Array.isArray(state.value) ? !!state.value.find((item) => item === value) : state.value === value;

          if (setOpen !== isOpen) {
            this.toggle(setOpen);
          }
        }
      },
    );

    // 自分自身を親のストアのitemsに追加
    this.$root.useRootStore.getState().addItems([this]);
    // 接続時に data-ready 属性を追加(attributeChangedCallbackを初回実行するため)
    this.setAttribute('data-ready', '');
  }

  disconnectedCallback(): void {
    if (this.unsubscribe) this.unsubscribe();
    if (this.unsubscribeRoot) this.unsubscribeRoot();
    // コンポーネントが破棄される際に、親のストアから自分自身を削除
    this.$root?.useRootStore.getState().removeItems(this);
  }

  attributeChangedCallback(property: string, oldValue: string, newValue: string) {
    const isReady = this.hasAttribute('data-ready');
      if (!isReady) return;

    if (property === 'disabled' && oldValue !== newValue) {
        const isDisabled = newValue !== null;
        // rootのdisabledがtrueの場合のみdisabledを更新
        if (this.$root?.useRootStore.getState().disabled) {
          this.useItemStore.setState({ disabled: isDisabled });
        }
    }

    if (property === 'value' && oldValue !== newValue) {
        this.useItemStore.setState({value: newValue});
    }
  }

  // 開閉状態を切り替える
  toggle(_setOpen?: boolean): void {
    if (this.$root) {
      const { collapsible, items, mode } = this.$root.useRootStore.getState();
      const setOpen = _setOpen ?? !this.useItemStore.getState().isOpen;

      // collapsible が false の場合、setOpen = false は無視する(閉じれない)
      if (!collapsible && !setOpen && mode === 'single') return;

      this.useItemStore.setState({ isOpen: setOpen });

      // modeがsingleの場合、開いたアイテム以外を閉じる
      if (mode === 'single' && setOpen) {
        const filteredItems = items.filter((item) => item !== this && item.useItemStore.getState().isOpen);
        filteredItems.forEach((item) => {
          item.useItemStore.setState({ isOpen: false });
        });
      }

      // 親のストアに、変更を通知
      this.$root.updateToggledItems();
    }
  }

    open(): void {
      this.toggle(true);
    }

    close(): void{
      this.toggle(false);
    }
}

Prop

プロパティ名 説明 デフォルト値 備考
value アイテムを識別するための値。 null UiAccordionvalue属性と連携して、開閉状態を制御するために使用。指定しない場合は、UiAccordionvalueによる制御を受けない。
disabled アイテムを無効化するかどうか。 false

Attribute

属性名 説明 デフォルト値 備考
data-state(自動で付与) アイテムの開閉状態(open or closed -
data-disabled(自動で付与) アイテム無効状態(属性が存在すれば無効) -

4. UiAccordionTrigger (Accordionのトリガー)

Accordionの各セクションの開閉を制御するトリガーとなるコンポーネントです。
Web Componentsでは現状button要素を拡張することが難しく、Web Componentsでbutton要素機能を持つコンポーネントを作成することもアクセシビリティ観点から難しさがあるため、button要素をラップしUiAccordionTriggerから必要なイベントなどを設定する形で利用します。

UiAccordionTriggerも、親のUiAccordionItemの状態をsubscribeで監視し、aria-expanded属性やdata-state属性、data-disabled属性を適切に更新します。これにより、アクセシビリティが向上し、CSSでのスタイリングも容易になります。

export class UiAccordionTrigger extends HTMLElement {
  private $parentItem: UiAccordionItem | null = null;
  private $button: HTMLButtonElement | null = null;
  private unsubscribe: (() => void) | undefined = undefined;

  constructor() {
    super();
  }

  connectedCallback(): void {
    this.$parentItem = this.closest('ui-accordion-item');
    if (!this.$parentItem) {
      console.error('ui-accordion-trigger must be child of ui-accordion-item');
      return;
    }

    // button要素を取得 (ネストを考慮)
    this.$button = this.querySelector('button:not(:scope ui-accordion *)');

    // buttonとcontentのIDを生成(存在しない場合)
    const triggerId = this.$button?.id || `accordion-trigger-${Math.random().toString(36).slice(2)}`;
    const $content = this.$parentItem.querySelector('ui-accordion-content:not(:scope ui-accordion *)');
    const contentId = $content?.id || `accordion-content-${Math.random().toString(36).slice(2)}`;

    const { isOpen, disabled } = this.$parentItem.useItemStore.getState();

    // 初期属性設定 & 監視
    this.updateAttrs(isOpen, disabled, triggerId, contentId);
    this.unsubscribe = this.$parentItem.useItemStore.subscribe((state) => {
      this.updateAttrs(state.isOpen, state.disabled, triggerId, contentId);
    });
    this.$button?.addEventListener('click', this.handleClick);
  }

  disconnectedCallback(): void {
    if (this.unsubscribe) this.unsubscribe();
    this.$button?.removeEventListener('click', this.handleClick);
  }

  private updateAttrs(isOpen: boolean | undefined, isDisabled: boolean | undefined, triggerId: string, contentId: string): void {
    setAttrsElement(this, {
      'data-state': isOpen ? 'open' : 'closed',
      'data-disabled': isDisabled ? '' : undefined,
    });
    setAttrsElement(this.$button, {
      'data-state': isOpen ? 'open' : 'closed',
      'data-disabled': isDisabled ? '' : undefined,
      disabled: isDisabled ? '' : undefined,
      'aria-expanded': isOpen ? 'true' : 'false',
      'aria-controls': contentId,
      id: triggerId,
    });
  }
  private handleClick = (): void => {
    this.$parentItem?.toggle();
  };
}

Attribute

属性名 説明 デフォルト値 備考
data-state(自動で付与) アイテムの開閉状態(open or closed -
data-disabled(自動で付与) アイテムの無効状態(属性が存在すれば無効) -
aria-expanded(自動で付与) 開閉状態を示すWAI-ARIA属性 false

5. UiAccordionHeader (Accordionのヘッダー)

Accordionの各セクションのヘッダーを表すコンポーネントです。UiAccordionTriggerをラップし、WAI-ARIAのrole="heading"aria-level属性を付与して、セマンティックな構造を提供します。
UiAccordionHeaderは必須要素ではなく、Trigger要素に見出しをつけたい場合に利用します。またdata-stateなどの属性が不要であれば<h3>などのhtml要素を利用することも可能です。

export class UiAccordionHeader extends HTMLElement {
  private $parentItem: UiAccordionItem | null = null;
  private unsubscribe: (() => void) | undefined = undefined;

  constructor() {
    super();
  }

  connectedCallback(): void {
    this.$parentItem = this.closest('ui-accordion-item');
    if (!this.$parentItem) {
      console.error('ui-accordion-trigger must be child of ui-accordion-item');
      return;
    }

    const level = '3'; // デフォルトの見出しレベル
    const role = this.getAttribute('role');
    if (!role) {
      this.setAttribute('role', 'heading');
    }
    if (this.getAttribute('role') === 'heading') {
      const ariaLevel = this.getAttribute('level') || this.getAttribute('aria-level') || level;
      this.setAttribute('aria-level', ariaLevel);
    }

    const { isOpen, disabled } = this.$parentItem.useItemStore.getState();
    this.updateAttrs(isOpen, disabled);

    // 親アイテムの状態を監視し、data属性を更新
    this.unsubscribe = this.$parentItem.useItemStore.subscribe((state) => {
      this.updateAttrs(state.isOpen, state.disabled);
    });
  }
  disconnectedCallback(): void {
    if (this.unsubscribe) this.unsubscribe();
  }
  private updateAttrs(isOpen: boolean | undefined, isDisabled: boolean | undefined): void {
    setAttrsElement(this, {
      'data-state': isOpen ? 'open' : 'closed',
      'data-disabled': isDisabled ? '' : undefined,
    });
  }
}

Attribute

属性名 説明 デフォルト値 備考
data-state(自動で付与) アイテムの開閉状態(open or closed -
data-disabled(自動で付与) アイテムの無効状態(属性が存在すれば無効) -

6. UiAccordionContent (Accordionのコンテンツ)

Accordionの各セクションのコンテンツ部分を表すコンポーネントです。UiAccordionItemの子要素として使用され、hidden属性によって表示/非表示が切り替わります。
非表示にはhidden='until-found'が設定されるため、ブラウザ側のページ内検索で内部コンテンツにマッチした場合はhiddenが外されます。。

export class UiAccordionContent extends HTMLElement {
  private $parentItem: UiAccordionItem | null = null;
  private unsubscribe: (() => void) | undefined = undefined;

  connectedCallback(): void {
    this.$parentItem = this.closest('ui-accordion-item');
    if (!this.$parentItem) {
      console.error('ui-accordion-trigger must be child of ui-accordion-item');
      return;
    }

    const { isOpen, disabled } = this.$parentItem.useItemStore.getState();
    const $button = this.$parentItem.querySelector('ui-accordion-trigger button');
    const triggerId = $button?.id;
    const contentId = this.id || $button?.getAttribute('aria-controls') || undefined;
    let isTransitioning = false;

    this.updateAttrs(isOpen, disabled, triggerId, contentId);

    const handleTransitionRun = () => {
      this.removeEventListener('transitionrun', handleTransitionRun);
      isTransitioning = true;
    };
    const handleTransitionEnd = () => {
      this.removeEventListener('transitionend', handleTransitionEnd);
      isTransitioning = false;

      this.removeAttribute('data-ending-style');
      this.style.removeProperty('--accordion-content-height');

      if (this.$parentItem) {
        // transition中にopen, closeが切り替わるためUiAccordionItemの状態を確認
        const { isOpen, disabled } = this.$parentItem.useItemStore.getState();
        if (!isOpen) {
          // close
          this.updateAttrs(false, disabled, triggerId, contentId);
        }
      }
    };
    this.unsubscribe = this.$parentItem.useItemStore.subscribe((state) => {
      // transitionの有無を確認
      const hasTransition = window.getComputedStyle(this).transitionDuration !== '0s';

      if (!hasTransition) {
        // transitionなしならシンプルに開閉のみ
        this.updateAttrs(state.isOpen, state.disabled, triggerId, contentId);
        return;
      }

      if (isTransitioning) {
        // transition中
        this.removeAttribute('data-ending-style');

        if (state.isOpen) {
          // open状態に変更
          this.updateAttrs(true, state.disabled, triggerId, contentId);
          // アニメーションのための準備
          this.style.setProperty('--accordion-content-height', `${this.scrollHeight}px`);
        } else {
          // アニメーションのための準備
          this.style.setProperty('--accordion-content-height', `0px`);
        }
      } else {
        // transition中ではない時
        this.style.removeProperty('--accordion-content-height');

        if (state.isOpen) {
          // open状態に変更
          this.updateAttrs(true, state.disabled, triggerId, contentId);
          // アニメーションのための準備
          this.setAttribute('data-starting-style', '');
          this.removeAttribute('data-ending-style');
          this.style.setProperty('--accordion-content-height', `${this.scrollHeight}px`);

          this.addEventListener('transitionrun', handleTransitionRun);
          this.addEventListener('transitionend', handleTransitionEnd);

          requestAnimationFrame(() => {
            requestAnimationFrame(() => {
              // アニメーション開始
              this.removeAttribute('data-starting-style');
            });
          });
        } else {
          // close状態に変更
          // アニメーションのための準備
          this.style.setProperty('--accordion-content-height', `${this.scrollHeight}px`);

          this.addEventListener('transitionrun', handleTransitionRun);
          this.addEventListener('transitionend', handleTransitionEnd);

          requestAnimationFrame(() => {
            requestAnimationFrame(() => {
              // アニメーション開始
              this.setAttribute('data-ending-style', '');
            });
          });
        }
      }
    });
  }
  disconnectedCallback(): void {
    if (this.unsubscribe) this.unsubscribe();
  }

  private updateAttrs(
    isOpen: boolean | undefined,
    isDisabled: boolean | undefined,
    triggerId: string | undefined,
    contentId: string | undefined,
  ): void {
    setAttrsElement(this, {
      'data-state': isOpen ? 'open' : 'closed',
      'data-disabled': isDisabled ? '' : undefined,
      hidden: !isOpen ? 'until-found' : undefined,
      role: 'region',
      'aria-labelledby': triggerId,
      id: contentId || undefined,
    });
  }
}

Attribute

属性名 説明 デフォルト値 備考
data-state(自動で付与) アイテムの開閉状態(open or closed -
data-disabled(自動で付与) アイテムの無効状態(属性が存在すれば無効) -
aria-labelledby(自動で付与) コンテンツと関連付けられたトリガーのID。 -
id(指定がない場合、自動で付与) コンテンツのID。トリガーのaria-controls属性と関連付けられます。 -

CSS Variable

属性名 説明
--accordion-content-height コンテンツの高さ(cssでtransitionが設定されている場合のみ、transition中に設定。)

7. カスタム要素の定義

最後に、作成したWeb Componentsをカスタム要素として登録します。

customElements.define('ui-accordion', UiAccordion);
customElements.define('ui-accordion-item', UiAccordionItem);
customElements.define('ui-accordion-header', UiAccordionHeader);
customElements.define('ui-accordion-trigger', UiAccordionTrigger);
customElements.define('ui-accordion-content', UiAccordionContent);

グローバルに型を宣言します。

declare global {
  interface HTMLElementTagNameMap {
    'ui-accordion': UiAccordion;
    'ui-accordion-item': UiAccordionItem;
    'ui-accordion-header': UiAccordionHeader;
    'ui-accordion-trigger': UiAccordionTrigger;
    'ui-accordion-content': UiAccordionContent;
  }
}

HTMLでの使用例

HTML

<div class="container">
  <h2>Single Mode</h2>
  <ui-accordion mode="single">
    <ui-accordion-item>
      <ui-accordion-trigger>
        <button type="button">トリガー1</button>
      </ui-accordion-trigger>
      <ui-accordion-content> コンテンツ1 </ui-accordion-content>
    </ui-accordion-item>
    <ui-accordion-item>
      <ui-accordion-trigger>
        <button type="button">トリガー2</button>
      </ui-accordion-trigger>
      <ui-accordion-content> コンテンツ2 </ui-accordion-content>
    </ui-accordion-item>
  </ui-accordion>
</div>

CSS

:where(ui-accordion, ui-accordion-item, ui-accordion-trigger, ui-accordion-header, ui-accordion-content) {
  display: block;
}

ui-accordion {
  border: 1px solid #ccc;
  border-radius: 4px;
  padding: 10px;
  --accordion-content-height: auto;
}

ui-accordion-item + ui-accordion-item {
  margin-top: 4px;
}

ui-accordion-trigger button {
  width: 100%;
  text-align: left;
  padding: 12px;
  background-color: #eee;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

ui-accordion-content {
  height: var(--accordion-content-height);
  overflow: hidden;
  transition: height 150ms ease-out;

  &[data-starting-style],
  &[data-ending-style] {
    height: 0;
  }
  &[hidden] {
    content-visibility: hidden;
  }
}

/* chromeのみ以下のCSSでアニメーション可能
ui-accordion-content[data-state='open'] {
  height: auto;
  height: calc-size(auto, size);
} */

/* layout */
.container {
  max-width: 500px;
  margin: auto;
}

h2 {
  margin-top: 32px;
  margin-bottom: 8px;
  font-size: 1.2rem;
}

.inner {
  padding: 10px;
}

Web Components Accordionの欠点

通常reactなどで利用できるHeadlessUIに比べるとDOMの自由度が下がります。
例えば、コンポーネントの要素を変更できる仕組みがあれば<UiAccordionItem>を<li>に変更することができますが、今回利用しているLight DOMなWeb Componentsでは要素の変更ができないため、DOMの構造はコンポーネント以外のところで工夫する必要があります。

// Radix UI Accordion
<Accordion.Root asChild>
  <ul>
    <AccordionItem asChild>
      <li>
        <Accordion.Trigger>トリガー</Accordion.Trigger>
        <Accordion.Content>コンテンツ</Accordion.Content>
      </li>
    </AccordionItem>
  </ul>
</Accordion.Root>

// Web Components版 Accordion
<ui-accordion>
  <ul>
    <li>
      <ui-accordion-item>
        <ui-accordion-trigger>トリガー</ui-accordion-trigger>
        <ui-accordion-content>コンテンツ</ui-accordion-content>
      </ui-accordion-item>
    </li>
  </ul>
</ui-accordion>

まとめ

この記事では、Web Componentsを使って、Radix UIライクなAccordionコンポーネントを実装する方法を解説しました。
Web Componentsの主要な概念(Custom Elements)と、Zustandによる状態管理を組み合わせることで、
フレームワークに依存しない、再利用可能で保守性の高いコンポーネントを作成できることができます。

特にコーポレートweb開発のようなページごとに個別のhtmlやcss、jsが利用され、複数の作業者がいるような環境では便利に使えるのではないでしょうか。

以上です。

Discussion