【JavaScript】タブ切り替え機能の実装方法|data属性・WAI-ARIA・キーボード操作・URLハッシュ連動・アニメーションまで解説

タブ切り替えUIはWebサイトで頻繁に使われるコンポーネントですが、「見た目だけ動く」実装と「アクセシブルで使いやすい」実装には大きな差があります。スクリーンリーダー対応・キーボード操作・URLの共有可能性まで考慮した実装が実務では求められます。

この記事ではdata属性を使ったシンプルな基本実装から、WAI-ARIAによるアクセシビリティ対応、キーボードナビゲーション、URLハッシュ連動、フェードアニメーション、動的タブ追加まで体系的に解説します。

スポンサーリンク

基本実装:data属性で紐付ける

onclick 属性にインラインJavaScriptを書く方法はHTMLとJSが混在して保守しにくくなります。data-tab 属性でタブとパネルを紐付けるパターンが現代的な書き方です。

HTML(data属性方式)
<div class="tabs">
  <!-- タブボタン -->
  <div class="tab-list">
    <button class="tab-btn active" data-tab="panel-1">タブ1</button>
    <button class="tab-btn"        data-tab="panel-2">タブ2</button>
    <button class="tab-btn"        data-tab="panel-3">タブ3</button>
  </div>

  <!-- タブパネル -->
  <div id="panel-1" class="tab-panel active">パネル1のコンテンツ</div>
  <div id="panel-2" class="tab-panel">パネル2のコンテンツ</div>
  <div id="panel-3" class="tab-panel">パネル3のコンテンツ</div>
</div>
CSS(基本スタイル)
.tab-panel {
  display: none;
}
.tab-panel.active {
  display: block;
}

.tab-btn {
  padding: 8px 20px;
  border: 1px solid #ccc;
  background: #f5f5f5;
  cursor: pointer;
  border-bottom: none;
}
.tab-btn.active {
  background: #fff;
  border-bottom: 2px solid #0284c7;
  color: #0284c7;
  font-weight: bold;
}
JavaScript(基本実装)
document.querySelectorAll('.tab-btn').forEach(btn => {
  btn.addEventListener('click', () => {
    const targetId = btn.dataset.tab;

    // すべてのボタン・パネルから active を外す
    document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
    document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));

    // クリックされたボタンと対応パネルに active を付ける
    btn.classList.add('active');
    document.getElementById(targetId).classList.add('active');
  });
});
data属性方式のメリット:

  • HTMLとJavaScriptが分離できる(onclick インライン不要)
  • タブの数が増えても JavaScript を修正しなくてよい
  • セレクターで一括処理できる

WAI-ARIA でアクセシビリティ対応

スクリーンリーダーが「これはタブUIである」と認識するには、WAI-ARIAのロール(role="tablist"role="tab"role="tabpanel")と状態属性(aria-selectedaria-controlsaria-labelledby)を適切に設定する必要があります。

HTML(ARIA対応)
<div class="tabs">
  <div role="tablist" aria-label="コンテンツタブ" class="tab-list">
    <button
      role="tab"
      aria-selected="true"
      aria-controls="panel-1"
      id="tab-1"
      class="tab-btn"
      tabindex="0"
    >タブ1</button>
    <button
      role="tab"
      aria-selected="false"
      aria-controls="panel-2"
      id="tab-2"
      class="tab-btn"
      tabindex="-1"
    >タブ2</button>
    <button
      role="tab"
      aria-selected="false"
      aria-controls="panel-3"
      id="tab-3"
      class="tab-btn"
      tabindex="-1"
    >タブ3</button>
  </div>

  <div
    role="tabpanel"
    id="panel-1"
    aria-labelledby="tab-1"
    class="tab-panel"
  >パネル1のコンテンツ</div>
  <div
    role="tabpanel"
    id="panel-2"
    aria-labelledby="tab-2"
    class="tab-panel"
    hidden
  >パネル2のコンテンツ</div>
  <div
    role="tabpanel"
    id="panel-3"
    aria-labelledby="tab-3"
    class="tab-panel"
    hidden
  >パネル3のコンテンツ</div>
</div>
JavaScript(ARIA + キーボードナビゲーション対応)
class TabComponent {
  #tabs;
  #panels;

  constructor(container) {
    this.#tabs   = Array.from(container.querySelectorAll('[role="tab"]'));
    this.#panels = Array.from(container.querySelectorAll('[role="tabpanel"]'));

    this.#tabs.forEach(tab => {
      tab.addEventListener('click', () => this.#activate(tab));
      tab.addEventListener('keydown', (e) => this.#onKeyDown(e));
    });
  }

  /** タブをアクティブにする */
  #activate(targetTab) {
    this.#tabs.forEach(tab => {
      const isTarget = tab === targetTab;
      tab.setAttribute('aria-selected', isTarget ? 'true' : 'false');
      tab.tabIndex = isTarget ? 0 : -1;
    });

    this.#panels.forEach(panel => {
      const isTarget = panel.id === targetTab.getAttribute('aria-controls');
      panel.hidden = !isTarget;
    });

    targetTab.focus();
  }

  /** 矢印キーでタブを移動 */
  #onKeyDown(e) {
    const current = this.#tabs.indexOf(e.currentTarget);
    let next = current;

    if (e.key === 'ArrowRight') {
      next = (current + 1) % this.#tabs.length;
    } else if (e.key === 'ArrowLeft') {
      next = (current - 1 + this.#tabs.length) % this.#tabs.length;
    } else if (e.key === 'Home') {
      next = 0;
    } else if (e.key === 'End') {
      next = this.#tabs.length - 1;
    } else {
      return;
    }

    e.preventDefault();
    this.#activate(this.#tabs[next]);
  }
}

// 使用例
document.querySelectorAll('.tabs').forEach(container => {
  new TabComponent(container);
});
WAI-ARIA タブUIの要件(WCAG 2.1):

  • role="tablist":タブのリスト全体
  • role="tab":個々のタブボタン。aria-selected でアクティブ状態を示す
  • role="tabpanel":タブに対応するパネル
  • aria-controls:タブが制御するパネルのID
  • aria-labelledby:パネルにラベルを付けるタブのID
  • 非アクティブタブは tabindex="-1"、アクティブタブは tabindex="0"
  • 非表示パネルは display:none ではなく hidden 属性推奨

URLハッシュと連動させる

URLのハッシュ(#panel-2)とタブを連動させると、特定のタブが開いた状態のURLを共有できるようになります。ページリロード後も同じタブが表示されます。

URLハッシュ連動の実装
class TabComponentWithHash extends TabComponent {
  constructor(container) {
    super(container);

    // ページ読み込み時にハッシュに対応するタブを開く
    this._applyHash();

    // ブラウザの「戻る/進む」でハッシュが変わったとき
    window.addEventListener('hashchange', () => this._applyHash());
  }

  #activate(targetTab) {
    super.#activate(targetTab); // 親クラスの処理
    // ハッシュをURLに反映
    const panelId = targetTab.getAttribute('aria-controls');
    history.replaceState(null, '', `#${panelId}`);
  }

  _applyHash() {
    const hash = location.hash.slice(1); // # を除去
    if (!hash) return;
    const targetTab = this._tabs.find(
      t => t.getAttribute('aria-controls') === hash
    );
    if (targetTab) this.#activate(targetTab);
  }
}
ハッシュ変更と自動スクロールの問題:ブラウザはハッシュが変わると対応するIDの要素にスクロールしようとします。これを防ぐには history.replaceState を使う(ハッシュを変えずに履歴を更新)か、scroll-margin-top を調整します。

フェードアニメーションを付ける

hidden 属性の代わりに CSS トランジションを使って、タブ切り替え時にフェードイン効果を加えます。

フェードアニメーション CSS
.tab-panel {
  display: none;
  opacity: 0;
  transition: opacity 0.2s ease;
}

/* active になったとき表示 + フェードイン */
.tab-panel.active {
  display: block;
  animation: fadeIn 0.2s ease forwards;
}

@keyframes fadeIn {
  from { opacity: 0; transform: translateY(4px); }
  to   { opacity: 1; transform: translateY(0); }
}
フェードアニメーション対応の JS
function switchTabWithFade(clickedBtn, allBtns, allPanels) {
  const targetId = clickedBtn.dataset.tab;

  allBtns.forEach(b => {
    b.classList.remove('active');
    b.setAttribute('aria-selected', 'false');
    b.tabIndex = -1;
  });
  allPanels.forEach(p => p.classList.remove('active'));

  clickedBtn.classList.add('active');
  clickedBtn.setAttribute('aria-selected', 'true');
  clickedBtn.tabIndex = 0;

  const targetPanel = document.getElementById(targetId);
  targetPanel.classList.add('active');
}
prefers-reduced-motion への配慮:前庭障害を持つユーザーにはアニメーションが体調不良を引き起こす場合があります。CSS側で @media (prefers-reduced-motion: reduce) { .tab-panel { animation: none; } } を追加しておきましょう。

動的にタブを追加・削除する

管理画面や設定UIで、ユーザー操作によってタブを追加・削除する実装です。

動的タブの追加・削除
class DynamicTabs {
  #tabList;
  #container;
  #counter = 0;

  constructor(container) {
    this.#container = container;
    this.#tabList   = container.querySelector('[role="tablist"]');
  }

  /** タブを追加する */
  addTab(label, content) {
    this.#counter++;
    const id      = `dynamic-panel-${this.#counter}`;
    const tabId   = `dynamic-tab-${this.#counter}`;

    // タブボタンを作成
    const btn = document.createElement('button');
    btn.role = 'tab';
    btn.id   = tabId;
    btn.setAttribute('aria-controls', id);
    btn.setAttribute('aria-selected', 'false');
    btn.tabIndex = -1;
    btn.className = 'tab-btn';
    btn.textContent = label;

    // 閉じるボタン(削除用)
    const closeBtn = document.createElement('span');
    closeBtn.textContent = ' ×';
    closeBtn.style.cursor = 'pointer';
    closeBtn.addEventListener('click', (e) => {
      e.stopPropagation();
      this.removeTab(id);
    });
    btn.appendChild(closeBtn);

    // パネルを作成
    const panel = document.createElement('div');
    panel.role  = 'tabpanel';
    panel.id    = id;
    panel.setAttribute('aria-labelledby', tabId);
    panel.className = 'tab-panel';
    panel.hidden    = true;
    panel.innerHTML = content;

    this.#tabList.appendChild(btn);
    this.#container.appendChild(panel);

    btn.addEventListener('click', () => this.activateTab(id));

    // 追加後すぐにアクティブにする
    this.activateTab(id);
  }

  /** タブを削除する */
  removeTab(panelId) {
    const tab   = this.#container.querySelector(`[aria-controls="${panelId}"]`);
    const panel = document.getElementById(panelId);

    const wasActive = tab?.getAttribute('aria-selected') === 'true';
    tab?.remove();
    panel?.remove();

    // 削除したタブがアクティブだった場合、最初のタブをアクティブにする
    if (wasActive) {
      const firstTab = this.#container.querySelector('[role="tab"]');
      if (firstTab) this.activateTab(firstTab.getAttribute('aria-controls'));
    }
  }

  /** 指定パネルIDのタブをアクティブにする */
  activateTab(panelId) {
    this.#container.querySelectorAll('[role="tab"]').forEach(t => {
      const isTarget = t.getAttribute('aria-controls') === panelId;
      t.setAttribute('aria-selected', isTarget ? 'true' : 'false');
      t.tabIndex = isTarget ? 0 : -1;
      t.classList.toggle('active', isTarget);
    });
    this.#container.querySelectorAll('[role="tabpanel"]').forEach(p => {
      p.hidden = p.id !== panelId;
      p.classList.toggle('active', p.id === panelId);
    });
  }
}

// 使用例
const dynTabs = new DynamicTabs(document.querySelector('.tabs'));
dynTabs.addTab('新しいタブ', '<p>動的に追加されたコンテンツ</p>');

ページ内に複数のタブを共存させる

1ページに複数のタブコンポーネントがある場合、セレクター document.querySelectorAll で全体を取得してしまうと互いに干渉します。コンテナ要素を起点にすることで独立させられます。

複数タブの独立管理
// TabComponent クラスはコンテナを受け取る設計なので、
// 各コンテナに対してインスタンスを作るだけで独立して動作する

document.querySelectorAll('.tabs').forEach(container => {
  new TabComponent(container); // それぞれ独立したインスタンス
});

// ページ内に .tabs が3つあれば、3つの独立したタブUIが動作する

よくある質問(FAQ)

Qdisplay:none と hidden 属性の違いは何ですか?
Aどちらも要素を非表示にしますが、hidden 属性はHTMLの意味論的な「このコンテンツは現在関係ない」を表します。スクリーンリーダーも hidden の要素は読み上げません。WAI-ARIAのタブパターンでは hidden 属性が推奨されています。display:none はCSSで制御するため、アニメーションを付けたいときに使います。
Qタブの切り替えで高さが変わってページが揺れます。対策は?
Aパネルのコンテナに min-height を指定するか、すべてのパネルを position: absolute で重ねてコンテナの高さを固定する方法があります。または要素の高さを揃える方法でJavaScriptを使ってコンテナ高さを最大値に固定する方法も有効です。
Qキーボード操作でタブを切り替えるにはどうすればいいですか?
AWAI-ARIAのタブパターンでは ArrowRightArrowLeft キーでタブを移動し、HomeEnd で最初・最後に移動します。Tab キーはタブリストを抜けてパネルに移動します。本記事の TabComponent クラスがこのキーボード操作を実装しています。
Qタブの状態をページリロード後も保持するには?
A2つの方法があります。①URLハッシュ連動(本記事で解説):URLにタブの状態が含まれるのでブックマークも可能。②sessionStorage / localStorage:最後に開いたタブのIDを保存しておき、ページ読み込み時に復元します。共有可能にするならURLハッシュ、ユーザーごとに保持するならlocalStorageが適しています。
Qタブ内のコンテンツを遅延読み込み(lazy load)するには?
Aタブが初めてアクティブになったタイミングで fetch() を呼んでコンテンツを取得します。「一度読み込んだら再取得しない」ようにフラグ(data-loaded="true")を付けておくと効率的です。

まとめ

タブ切り替えUIの実装を段階別に整理します。

要件 実装
基本的なタブ切り替え data-tab 属性 + classList.toggle
スクリーンリーダー対応 role="tablist/tab/tabpanel" + aria-selected + hidden
キーボードナビゲーション ArrowRight/Left/Home/End の keydown 処理
URLで状態を共有 history.replaceState + hashchange イベント
切り替えアニメーション CSS @keyframes fadeIn + prefers-reduced-motion 対応
動的なタブ追加・削除 DOM生成 + DynamicTabs クラス
複数タブの共存 コンテナ要素を起点にクラスをインスタンス化

タブ切り替え後に高さを再計算したい場合は要素の高さを揃える方法(ResizeObserver対応)も参照してください。