🗂️

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

2025/02/16に公開

はじめに

Web Componentsで作る、Radix UIライクなAccordionコンポーネントWeb Componentsで作る、Radix UIライクなDialogコンポーネントに続き、今回はRadix UIライクなTabsを作成していきます。

AccordionとDialogコンポーネントに比べると、Tabsコンポーネントは<details><dialog>の様な標準のHTML要素がないため、Web Componentsで作成するメリットが多いコンポーネントです。

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

今回作成するTabsコンポーネントは、以下の仕様を満たすように実装します。

  • WAI-ARIAに準拠: アクセシビリティに配慮し、スクリーンリーダーなどの支援技術に対応します。
  • キーボードナビゲーションのループ: loop属性を指定することで、Tabキーによるフォーカス移動が最後のタブから最初のタブへ、またはその逆にループするようになります。
  • 状態変化の通知: onValueChangeカスタムイベントを発火し、タブの開閉状態の変化を外部に通知します。

Web Componentsで作るTabsコンポーネントのメリット

Web ComponentsでTabsコンポーネントを実装する主なメリットは、HTMLだけでタブの機能を実装できることです。

ReactやVueなどのJavaScriptフレームワークを使用する場合、UIコンポーネントの共有は比較的容易です。しかし、HTMLベースのWeb制作では、タブ機能を実装するために、別途ライブラリをインストールしたり、JavaScriptを記述したり、特定のクラスを追加したりする必要があり、作業コストが高くなる傾向があります。また、ページごとに異なるライブラリが使用されたり、実装方法が統一されなかったりする問題も発生しがちです。

Web Componentsで作成したTabsコンポーネントを使用すれば、HTML要素を記述するだけでタブ機能を組み込むことができ、Web制作をより効率的かつ安定したものにすることができます。

実装サンプル

開発環境

  • TypeScript
  • zustand (状態管理ライブラリ)

上記の開発環境を用意してください。
Codepenを使用する場合は、JS設定のJavaScript PreprocessorTypeScriptを選択することで、サンプルの動作確認が可能です。

Tabsコンポーネントの実装

UiTabs, UiTabsList, UiTabsTrigger, UiTabsPanelの実装コードは最後にまとめます。

1. コンポーネント作成の準備

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

開いて実装を確認する
import { createStore } from "https://esm.sh/zustand/vanilla";
import { subscribeWithSelector } from "https://esm.sh/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);
    }
  }
};

const removeAttrCloak = (element: HTMLElement | null): void => {
  element?.removeAttribute("cloak");
};

const activateModes = ["manual", "automatic"] as const;
type ActivationMode = (typeof activateModes)[number];
type TabsValue = string | string[] | null;
type TabsStoreState = {
  value: TabsValue;
  activationMode: ActivationMode;
  tabs: {
    value: string;
    tabId: string;
    panelId: string;
  }[];
};

2. UiTabs (Tabs Rootコンポーネント)

UiTabsは、Tabs全体の動作を制御するルートコンポーネントです。
このコンポーネントでは、主に全体のstore設定を行います。

※Radix UIにはactiveMode設定がありますが、今回の実装では省略しています。activeModeは、automatic(フォーカスを受け取った時にタブをアクティブにする)とmanual(クリックした時にタブをアクティブにする)の2つのモードをサポートします。

Prop

プロパティ名 説明 デフォルト値 備考
activeMode(未実装) automatic: フォーカスを受け取った時にタブがアクティブになる。(現在はこちらのモードしかサポートしていません)
manual: クリックした時にタブがアクティブになる。
automatic 将来実装予定
value 開いているタブのvalue。 null

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

UiTabsコンポーネントは、タブの開閉状態が変化した際にonValueChangeカスタムイベントを発火します。このイベントをリッスンすることで、タブの状態変化を外部から検知し、他の処理と連携できます。

<ui-tabs id="my-tabs">
~~~省略~~~
</ui-tabs>

<script>
  const tabs = document.getElementById("my-tabs");
  tabs.addEventListener("onValueChange", (event) => {
    console.log("Tabs value changed:", event.detail.value);
    // ここで、event.detail.value を使って何か処理を行う
  });
</script>
  • event.detail.value: 開いているタブのvalue文字列

3. UiTabsList

UiTabsListは、tablistロールを持つ要素で、タブのリストをまとめるコンテナです。支援技術に対して、タブの総数を伝えます。
UiTabsの子要素として使用します。

Prop

プロパティ名 説明 デフォルト値 備考
loop キーボードナビゲーションをループさせるかどうか。trueの場合、最後のタブから最初のタブへ、またはその逆に移動します。 -

4. UiTabsTrigger

UiTabsTriggerは、内部に配置した<button>要素がtabロールを持つようになるコンポーネントです。タブの選択状態(aria-selected)や、関連するtabpanelの制御(aria-controls)を行います。

Prop

プロパティ名 説明 デフォルト値 備考
value タブを識別するための値。 null

アクセシビリティ属性

<button>要素には、以下の属性が付与されます。

属性名 説明
aria-selected タブが選択されているかどうかを示します。選択されるとtrueになります。
aria-controls このボタンが制御するtabpanel要素のIDを指定します。これにより、支援技術はどの要素がタブに関連付けられているかを認識できます。

5. UiTabsPanel

UiTabsPanelは、tabpanelロールを持つ要素で、タブに対応するコンテンツ領域です。aria-labelledby属性によって、関連するタブの見出し(UiTabsTrigger内の<button>要素)を参照します。

Prop

プロパティ名 説明 デフォルト値 備考
value tabpanelを識別するための値。 null

アクセシビリティ属性

<ui-tabs-panel>要素には、以下の属性が付与されます。

属性名 説明
aria-labelledby 関連するタブのIDを指定します。

6. カスタム要素の定義

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

7. コードサンプル

HTML

開いてTabsコンポーネントのHTML実装を確認する
<ui-tabs value="tab1">
  <ui-tabs-list>
    <ui-tabs-trigger value="tab1"><button>Tab1</button></ui-tabs-trigger>
    <ui-tabs-trigger value="tab2"><button>Tab2</button></ui-tabs-trigger>
    <ui-tabs-trigger value="tab3"><button>Tab3</button></ui-tabs-trigger>
  </ui-tabs-list>
  <ui-tabs-panel value="tab1">
    Tab1 content
  </ui-tabs-panel>
  <ui-tabs-panel value="tab2">
    Tab2 content
  </ui-tabs-panel>
  <ui-tabs-panel value="tab3">
    Tab3 content
  </ui-tabs-panel>
</ui-tabs>

最低限上記の構造に従えば、<div>などの要素を各<UiTabs~~>の前後に配置可能です。

開いてHTMLの構造を確認する
<ui-tabs value="tab1">
  <div>
    <ui-tabs-list>
      <ui-tabs-trigger value="tab1"><div><button>Tab1</button></div></ui-tabs-trigger>
      <ui-tabs-trigger value="tab2"><div><button>Tab2</button></div></ui-tabs-trigger>
      <ui-tabs-trigger value="tab3"><div><button>Tab3</button></div></ui-tabs-trigger>
    </ui-tabs-list>
  </div>
  <div>
    <ui-tabs-panel value="tab1">
      <div>
      Tab1 content
      </div>
    </ui-tabs-panel>
    <ui-tabs-panel value="tab2">
      <div>
      Tab1 content
      </div>
    </ui-tabs-panel>
    <ui-tabs-panel value="tab3">
      <div>
      Tab3 content
      </div>
    </ui-tabs-panel>
  </div>
</ui-tabs>

UiTabsの入れ子も可能です。

開いてUiTabsの入れ子構造を確認する
<ui-tabs value="tab1">
  <ui-tabs-list>
    <ui-tabs-trigger value="tab1"><button>Tab1</button></ui-tabs-trigger>
    <ui-tabs-trigger value="tab2"><button>Tab2</button></ui-tabs-trigger>
  </ui-tabs-list>
  <ui-tabs-panel value="tab1">
    Tab1 content
  </ui-tabs-panel>
  <ui-tabs-panel value="tab2">
    Tab2 content

    <ui-tabs value="tab1">
      <ui-tabs-list>
        <ui-tabs-trigger value="tab1"><button>Tab1-1</button></ui-tabs-trigger>
        <ui-tabs-trigger value="tab2"><button>Tab1-2</button></ui-tabs-trigger>
      </ui-tabs-list>
      <ui-tabs-panel value="tab1">
        Tab1-1 content
      </ui-tabs-panel>
      <ui-tabs-panel value="tab2">
        Tab1-2 content
      </ui-tabs-panel>
    </ui-tabs>
  </ui-tabs-panel>
</ui-tabs>

CSS

スタイリングは任意の方法で行うことができます。以下の例では、基本的なスタイル(Tabs Base Style)と、カスタマイズ可能なスタイル(Tabs Custom Style)を分けて定義しています。

開いてTabsコンポーネントのCSS実装を確認する
/* Tabs Base Style */
:where(ui-tabs, ui-tabs-list, ui-tabs-trigger, ui-tabs-panel) {
  display: block;
}
ui-tabs-panel {
  display: none;
}
ui-tabs-panel[data-state="active"] {
  display: block;
}

/* Tabs Custom Style */
ui-tabs-list {
  display: flex;
  gap: 2px;
  margin-bottom: -1px;
}
ui-tabs-trigger button {
  border: 1px solid #ccc;
  background: #fff;
  border-radius: 4px 4px 0 0;
  padding: 8px 16px;
  font-size: 1rem;
  
  &[data-state="active"] {
    background: rgb(255,255,255);
    background: linear-gradient(0deg, rgba(255,255,255,1) 90%, rgba(119,119,119,1) 90%); 
  }
}
        
ui-tabs-panel {
  padding: 16px;
  border: 1px solid #ccc;
  border-radius: 0 0 4px 4px;
}

TypeScript

開いてTabsコンポーネントのTypeScript実装を確認する
import { createStore } from "https://esm.sh/zustand/vanilla";
import { subscribeWithSelector } from "https://esm.sh/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);
    }
  }
};

const removeAttrCloak = (element: HTMLElement | null): void => {
  element?.removeAttribute("cloak");
};

const activateModes = ["manual", "automatic"] as const;
type ActivationMode = (typeof activateModes)[number];
type TabsValue = string | string[] | null;
type TabsStoreState = {
  value: TabsValue;
  activationMode: ActivationMode;
  tabs: {
    value: string;
    tabId: string;
    panelId: string;
  }[];
};

export class UiTabs extends HTMLElement {
  private isReady = false;
  unsubscribe: (() => void) | undefined = undefined;
  useRootStore = createStore(
    subscribeWithSelector<TabsStoreState>((set) => ({
      value: "",
      activationMode: "automatic",
      tabs: []
    }))
  );

  static get observedAttributes() {
    return ["value", "activationMode"];
  }

  connectedCallback(): void {
    const getDefaultValue = () => {
      const attrValue = this.getAttribute("value");
      if (attrValue !== null) {
        return attrValue;
      }
      // デフォルト値がない場合、aria-selected="true" のタブを探す
      const selectedTrigger = this.querySelector(
        'ui-tabs-trinnger button[aria-selected="true"]'
      );
      if (selectedTrigger) {
        const value = (selectedTrigger as HTMLElement).dataset.uiValue;
        if (value) {
          return value;
        }
      }
      return "";
    };
    const getActivationMode = () => {
      const mode = this.getAttribute("activationMode") as ActivationMode | null;
      if (mode && activateModes.includes(mode)) {
        return mode;
      }
      return this.useRootStore.getState().activationMode;
    };

    // 初期状態を store に反映
    this.useRootStore.setState({
      value: getDefaultValue(),
      activationMode: getActivationMode()
    });

    this.unsubscribe = this.useRootStore.subscribe(
      (state) => ({
        value: state.value
      }),
      (state) => {
        // valueの更新時、onValueChangeイベントを発行
        this.dispatchEvent(
          new CustomEvent("onValueChange", {
            detail: {
              value: state.value
            }
          })
        );
      }
    );

    removeAttrCloak(this);
    this.isReady = true;
  }

  disconnectedCallback(): void {}
}

export class UiTabsList extends HTMLElement {
  private isReady = false;
  private $root: UiTabs | null = null;
  loop = false;

  static get observedAttributes() {
    return ["loop"];
  }

  connectedCallback(): void {
    this.$root = this.closest("ui-tabs");
    if (!this.$root) {
      console.error("ui-tabs-list must be child of ui-tabs");
      return;
    }

    this.loop = this.hasAttribute("loop");
    this.setAttribute("role", "tablist");
    this.addEventListener("keydown", this.handleButtonKeydown);

    removeAttrCloak(this);
    this.isReady = true;
  }

  disconnectedCallback(): void {
    this.removeEventListener("keydown", this.handleButtonKeydown);
  }

  attributeChangedCallback(
    property: string,
    oldValue: string | null,
    newValue: string | null
  ) {
    // loop属性の取得
    if (property === "loop" && newValue !== oldValue) {
      this.loop = this.hasAttribute("loop");
    }
  }

  private getTabTriggers = (value: TabsValue) => {
    // disabled以外のトリガーを取得
    const triggerElements = this.querySelectorAll(
      "ui-tabs-trigger:not(:scope ui-tabs *):not([disabled])"
    );
    const triggers = Array.from(triggerElements) as UiTabsTrigger[];
    const currentIndex = triggers.findIndex(
      (trigger) => trigger.value === value
    );
    let nextIndex: number | null = currentIndex + 1;
    let prevIndex: number | null = currentIndex - 1;

    // loop属性を有効にした場合、最後のタブを最初のタブに戻す
    if (this.loop) {
      nextIndex = nextIndex % triggers.length;
      prevIndex = (prevIndex + triggers.length) % triggers.length;
    } else {
      // 次、前タブがなければnullになりhandleButtonKeydownの処理でなにもしない
      if (nextIndex >= triggers.length) nextIndex = null; // currentIndex
      if (prevIndex < 0) prevIndex = null; // currentIndex
    }

    return {
      first: triggers[0],
      last: triggers[triggers.length - 1],
      next: nextIndex === null ? null : triggers[nextIndex],
      prev: prevIndex === null ? null : triggers[prevIndex]
    };
  };

  private handleButtonKeydown = (event: KeyboardEvent): void => {
    const target = event.target as HTMLButtonElement;
    if (!this.$root) return;

    const { value } = this.$root.useRootStore.getState();
    const { first, last, next, prev } = this.getTabTriggers(value);
    let nextTrigger: UiTabsTrigger | null = null;

    switch (event.key) {
      case "ArrowLeft":
      case "ArrowUp":
        nextTrigger = prev;
        break;
      case "ArrowRight":
      case "ArrowDown":
        nextTrigger = next;
        break;
      case "Home":
        nextTrigger = first;
        break;
      case "End":
        nextTrigger = last;
        break;
    }

    if (nextTrigger) {
      event.stopPropagation();
      event.preventDefault();
      nextTrigger.querySelector("button")?.focus();
      this.$root.useRootStore.setState({ value: nextTrigger.value });
    }
  };
}

export class UiTabsTrigger extends HTMLElement {
  private isReady = false;
  private $root: UiTabs | null = null;
  private $button: HTMLButtonElement | null = null;
  private unsubscribe: (() => void) | undefined = undefined;
  value = "";
  disabled = false;

  connectedCallback(): void {
    this.$root = this.closest("ui-tabs");
    if (!this.$root) {
      console.error("ui-tabs-list must be child of ui-tabs");
      return;
    }

    this.$button = this.querySelector("button:not(:scope ui-tabs *)");
    this.value = this.getAttribute("value") || "";
    this.disabled = this.hasAttribute("disabled");
    const tabId =
      this.$button?.id || `tabs-trigger-${Math.random().toString(36).slice(2)}`;
    const isSelected = this.$root.useRootStore.getState().value === this.value;

    // TODO: tabId, panelIdの処理は整理したい
    // tabsの更新(UiTabsTriggerとUiTabsPanelでどちらが先に動作するかわからないため連携して設定)
    const { tabs } = this.$root.useRootStore.getState();
    const tab = tabs.find((tab) => tab.value === this.value);
    if (!tab) {
      // stateがなければstateの新規作成(panelIdはUiTabsPanelで設定)
      const newTabs = [...tabs, { value: this.value, tabId, panelId: "" }];
      this.$root.useRootStore.setState({
        tabs: newTabs
      });
    } else {
      // stateがあればtabIdを設定してstateの更新
      const newTabs = tabs.map((tab) => {
        if (tab.value === this.value) {
          tab.tabId = tabId;
        }
        return tab;
      });
      this.$root.useRootStore.setState({
        tabs: newTabs
      });
    }

    // 初期設定時はtabIdは空(設定はsubscribe処理内で行われる)
    this.updateAttrs(isSelected, this.disabled, tabId, "");

    this.unsubscribe = this.$root.useRootStore.subscribe(
      (state) => ({
        value: state.value,
        tabs: state.tabs
      }),
      (state) => {
        const tab = state.tabs.find((tab) => tab.value === this.value);
        if (!tab) return;
        this.updateAttrs(
          state.value === this.value,
          this.disabled,
          tab.tabId,
          tab.panelId
        );
      }
    );

    this.$button?.addEventListener("click", this.handleClick);

    removeAttrCloak(this);
    this.isReady = true;
  }

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

  private updateAttrs(
    isSelected: boolean | undefined,
    isDisabled: boolean,
    triggerId: string,
    panelId: string
  ): void {
    setAttrsElement(this, {
      "data-state": isSelected ? "active" : "inactive",
      "data-disabled": isDisabled ? "" : undefined
    });
    setAttrsElement(this.$button, {
      "data-state": isSelected ? "active" : "inactive",
      "data-disabled": isDisabled ? "" : undefined,
      "aria-selected": isSelected ? "true" : "false",
      disabled: isDisabled ? "" : undefined,
      role: "tab",
      "aria-controls": panelId,
      id: triggerId,
      tabindex: isSelected ? "0" : " -1"
    });
  }

  private handleClick = (): void => {
    this.$root?.useRootStore.setState({ value: this.value });
  };
}

export class UiTabsPanel extends HTMLElement {
  private isReady = false;
  private $root: UiTabs | null = null;
  value = "";
  unsubscribe: (() => void) | undefined = undefined;

  connectedCallback(): void {
    this.$root = this.closest("ui-tabs");
    if (!this.$root) {
      console.error("ui-tabs-panel must be child of ui-tabs");
      return;
    }

    this.value = this.getAttribute("value") || "";
    const panelId =
      this.id || `tabs-panel-${Math.random().toString(36).slice(2)}`;

    setAttrsElement(this, {
      role: "tabpanel",
      tabindex: "0",
      "data-state": "inactive",
      id: panelId,
      "aria-labelledby": "" // aria-labelledby(triggerId)の設定はsubscribe処理内で行われる
    });

    // tabsの更新(UiTabsTriggerとUiTabsPanelでどちらが先に動作するかわからないため連携して設定)
    const { tabs } = this.$root.useRootStore.getState();
    const tab = tabs.find((tab) => tab.value === this.value);
    if (!tab) {
      // stateがなければstateの新規作成(tabIdはUiTabsTriggerで設定)
      const newTabs = [...tabs, { value: this.value, tabId: "", panelId }];
      this.$root.useRootStore.setState({
        tabs: newTabs
      });
    } else {
      // stateがあればpanelIdを設定してstateの更新
      const newTabs = tabs.map((tab) => {
        if (tab.value === this.value) {
          tab.panelId = panelId;
        }
        return tab;
      });
      this.$root.useRootStore.setState({
        tabs: newTabs
      });
    }

    this.unsubscribe = this.$root.useRootStore.subscribe(
      (state) => ({
        value: state.value,
        tabs: state.tabs
      }),
      (state) => {
        const tab = state.tabs.find((tab) => tab.value === this.value);
        if (!tab) return;
        setAttrsElement(this, {
          "data-state": state.value === this.value ? "active" : "inactive",
          "aria-labelledby": tab.tabId
        });
      }
    );

    removeAttrCloak(this);
    this.isReady = true;
  }
  disconnectedCallback(): void {
    if (this.unsubscribe) this.unsubscribe();
  }
}

customElements.define("ui-tabs", UiTabs);
customElements.define("ui-tabs-list", UiTabsList);
customElements.define("ui-tabs-trigger", UiTabsTrigger);
customElements.define("ui-tabs-panel", UiTabsPanel);

declare global {
  interface HTMLElementTagNameMap {
    "ui-tabs": UiTabs;
    "ui-tabs-list": UiTabsList;
    "ui-tabs-trigger": UiTabsTrigger;
    "ui-tabs-panel": UiTabsPanel;
  }
}

まとめ

この記事では、Web Componentsを使用してRadix UIライクなTabsコンポーネントを実装する方法を詳しく解説しました。Web Componentsを活用することで、特定のJavaScriptフレームワークに依存せず、再利用性と保守性に優れたコンポーネントを作成できます。

特に、Tabsコンポーネントは、HTML標準では提供されていない機能をJavaScriptの記述なしに実現できるため、Web Componentsのメリットを活かせる良い例です。HTMLベースのWeb制作において、効率性と安定性を向上させるために、ぜひ活用してみてください。

以上です。

Discussion