まずはタブ切り替えの動きを確認する

前回の「ハンバーガーメニューの記事」では、bodyのクラスを起点に、メニュー、アイコン、背景スクロールなど、複数の状態を連動させました。

今回は、クリックした項目に対応する内容だけを表示する、タブ切り替えを作ります。

タブ切り替えでは、複数のボタンと複数のパネルを用意し、「どのタブが、どのパネルに対応しているか」をJavaScriptで判断します。

その対応関係を分かりやすくするために、今回はdata属性を使います。表示するパネルはhidden属性で切り替え、選択中のタブはaria-selectedで表します。

タブ切り替え(タブパネル)UIは、以下のような動きをします。

  1. 並んでいるタブのいずれかをクリックする
  2. クリックしたタブが選択状態になる
  3. 他のタブの選択状態が解除される
  4. 対応するパネルだけが表示される

完成イメージ

商品の概要を表示します。

今回作る機能を確認する

実際のサイトで使うタブUIには、マウス操作だけでなくキーボード操作の配慮も必要ですが、最初からすべて盛り込むと複雑になります。

今回の基本コードでは、まず次の4つを実装します。

  • タブをクリックしたら、対応するパネルを表示する
  • 選択されていないパネルを非表示にする
  • 選択中のタブの見た目を変更する
  • aria-selectedhiddenを表示状態に合わせる

記事前半では、まずクリックによる切り替え処理を作ります。記事後半では左右の矢印キーによる操作を追加し、完成コードとCodePenではキーボードでも操作できる形に仕上げます。

タブとパネルの関係を整理する

初心者が一番引っかかりやすいのが、タブ(ボタン)とパネルの「対応関係」の書き方です。今回のコードでは、以下の3つの属性が連動しています。

data-tab-target="panel-overview"
aria-controls="panel-overview"
id="panel-overview"

data-tab-targetには、クリックしたときに表示するパネルのidを入れます。これはJavaScriptで処理を行うための目印です。

aria-controlsも同じパネルのidを指定しますが、こちらは「ボタンがどの要素を操作するか」を支援技術(スクリーンリーダーなど)に伝えるための属性です。

今回のコードでは、data-tab-targetの値をJavaScriptから読み取り、対応するidを持つパネルを探し出して表示を切り替えます。

ポイントdata-tab-targetaria-controls、パネルのidには、同じ値を設定します。

この記事で学べること

  • data-*属性(カスタムデータ属性)を使ってHTMLから情報を取得する方法
  • 複数の要素の状態を一度リセットしてから、特定の要素だけ状態を更新する考え方
  • アクセシビリティを向上させるrole属性の基本
  • aria-selectedhidden属性による状態管理
  • 左右矢印キーでのタブ移動とtabindexの制御

タブ切り替えのHTMLを書く

まずはベースとなるHTMLです。アクセシビリティを考慮し、全体をタブリスト、各ボタンをタブ、内容をタブパネルとして役割(role)を明示しています。

パネル内にフォーカスできる要素(リンクなど)がない場合を想定し、パネル自体にもtabindex="0"を指定しています。

<div class="tabs js-tabs">
  <!-- タブのリスト -->
  <div
    class="tabs__list"
    role="tablist"
    aria-label="商品情報"
  >
    <button
      id="tab-overview"
      class="tabs__tab js-tab"
      type="button"
      role="tab"
      aria-selected="true"
      aria-controls="panel-overview"
      data-tab-target="panel-overview"
      tabindex="0"
    >
      概要
    </button>

    <button
      id="tab-feature"
      class="tabs__tab js-tab"
      type="button"
      role="tab"
      aria-selected="false"
      aria-controls="panel-feature"
      data-tab-target="panel-feature"
      tabindex="-1"
    >
      特徴
    </button>

    <button
      id="tab-spec"
      class="tabs__tab js-tab"
      type="button"
      role="tab"
      aria-selected="false"
      aria-controls="panel-spec"
      data-tab-target="panel-spec"
      tabindex="-1"
    >
      仕様
    </button>
  </div>

  <!-- パネル部分 -->
  <div
    id="panel-overview"
    class="tabs__panel js-tab-panel"
    role="tabpanel"
    aria-labelledby="tab-overview"
    tabindex="0"
  >
    <p>商品の概要を表示します。</p>
  </div>

  <div
    id="panel-feature"
    class="tabs__panel js-tab-panel"
    role="tabpanel"
    aria-labelledby="tab-feature"
    tabindex="0"
    hidden
  >
    <p>商品の特徴を表示します。</p>
  </div>

  <div
    id="panel-spec"
    class="tabs__panel js-tab-panel"
    role="tabpanel"
    aria-labelledby="tab-spec"
    tabindex="0"
    hidden
  >
    <p>商品の仕様を表示します。</p>
  </div>
</div>

CSSで選択中のタブを分かりやすくする

JavaScriptがクラスを付け外ししなくても、aria-selectedの状態をCSSで見れば、選択中のタブの見た目を変えられます。

/* タブを横並びにする */
.tabs__list {
  display: flex;
  gap: 4px;
  border-bottom: 2px solid #ddd;
}

/* タブボタンの基本スタイル */
.tabs__tab {
  padding: 10px 20px;
  border: none;
  background-color: #f1f5f9;
  cursor: pointer;
  font-size: 1rem;
  font-weight: bold;
  color: #64748b;
  border-radius: 4px 4px 0 0;
  transition: background-color 0.2s, color 0.2s;
}

.tabs__tab:hover {
  background-color: #e2e8f0;
}

.tabs__tab:focus-visible {
  outline: 3px solid rgba(37, 99, 235, 0.45);
  outline-offset: 3px;
}

/* 選択中のタブのスタイル */
.tabs__tab[aria-selected="true"] {
  background-color: #3b82f6;
  color: #fff;
}

/* パネルのスタイル */
.tabs__panel {
  padding: 20px;
  background-color: #fff;
}

/* hidden属性による非表示を確実にする */
.tabs__panel[hidden] {
  display: none;
}

@media (prefers-reduced-motion: reduce) {
  .tabs__tab {
    transition: none;
  }
}

JavaScriptで対応するパネルを表示する

JavaScriptでは、「クリックされたタブと対応するパネルだけを有効にし、それ以外を無効にする」という処理をactivateTabという関数にまとめます。

また、ページ内に複数のタブUIが存在してもそれぞれ独立して動くように、.js-tabsごとに処理を登録します。

document.addEventListener('DOMContentLoaded', () => {
  const tabsContainers =
    document.querySelectorAll('.js-tabs');

  tabsContainers.forEach((tabsContainer) => {
    const tabs = Array.from(
      tabsContainer.querySelectorAll('.js-tab')
    );

    const panels =
      tabsContainer.querySelectorAll('.js-tab-panel');

    // 指定されたタブを有効化し、他を無効化する関数
    const activateTab = (tab) => {
      const targetId = tab.dataset.tabTarget;

      if (!targetId) {
        return;
      }

      const targetPanel =
        document.getElementById(targetId);

      if (!targetPanel) {
        return;
      }

      // タブの状態を更新
      tabs.forEach((currentTab) => {
        const isSelected = currentTab === tab;

        currentTab.setAttribute(
          'aria-selected',
          String(isSelected)
        );

        currentTab.setAttribute(
          'tabindex',
          isSelected ? '0' : '-1'
        );
      });

      // パネルの表示状態を更新
      panels.forEach((panel) => {
        panel.hidden = panel !== targetPanel;
      });
    };

    // 各タブにクリック処理を登録
    tabs.forEach((tab) => {
      tab.addEventListener('click', () => {
        activateTab(tab);
      });
    });
  });
});

data属性から表示するパネルを特定する

HTMLに書いたdata-tab-target="panel-overview"は、JavaScriptからdatasetプロパティを使って読み取ります。

JavaScript内ではdata-が除かれ、ハイフン区切りがキャメルケース(単語の先頭を大文字にする記法)に変換されるため、dataset.tabTargetという名前で取得できます。

const targetId = tab.dataset.tabTarget;

タブとパネルの状態をまとめて更新する

上のコードでは、クリックされるたびにすべてのタブとパネルをループ(forEach)で確認し、「自分が選ばれたタブ(またはパネル)かどうか」を判定しています。

個別に「元々開いていたものを探して閉じる」処理を書くより、このようにまとめて更新するアプローチのほうが、コードがシンプルになりバグも減ります。

JavaScriptは状態をまとめて更新し、選択中の見た目はaria-selectedを使ってCSSに任せます。

コードの意味を1行ずつ見る

datasetdata-tab-targetを取得する

const targetId = tab.dataset.tabTarget;

クリックされたタブが持っている情報(開くべきパネルのID)を引き出します。

getElementByIdで対応するパネルを取得する

const targetPanel = document.getElementById(targetId);

取得したIDをもとに、ページ内から該当するパネル要素そのものを探し出します。

hiddenでパネルの表示を切り替える

panel.hidden = panel !== targetPanel;

ループの中でパネルを1つずつ確認し、選ばれたパネル以外にはhidden = trueを設定して非表示にします。選ばれたパネルはfalseになり表示されます。

aria-selectedを選択状態に合わせる

currentTab.setAttribute(
  'aria-selected',
  String(isSelected)
);

クリックされたタブが「選択中」であることを示します。CSSもこれに反応して青色に変わります。

tabindexを現在のタブに合わせる

currentTab.setAttribute(
  'tabindex',
  isSelected ? '0' : '-1'
);

キーボードのTabキーでフォーカスできる要素を、現在選択されているタブだけに制限します。他のタブは-1にしてTab移動から外します。

矢印キーでもタブを切り替えられるようにする

今回、非選択のタブにはtabindex="-1"を設定しているため、Tabキーだけでは他のタブに移動できません。

WAI-ARIA APGのタブパターンでは、横並びのタブリストにおいて、キーボードユーザーが「左右の矢印キー」を押したときに前後のタブへ移動できることが示されています。

パネル内容がすぐ表示できる場合は、フォーカス移動と同時にタブを選択する「自動切り替え」が使いやすいです。先ほどのコードのループ内に、以下のkeydown処理を追加します。

// activateTab関数の下に追加
tabs.forEach((tab, index) => {
  tab.addEventListener('click', () => {
    activateTab(tab);
  });

  tab.addEventListener('keydown', (event) => {
    let nextIndex;

    if (event.key === 'ArrowRight') {
      nextIndex = (index + 1) % tabs.length;
    } else if (event.key === 'ArrowLeft') {
      nextIndex =
        (index - 1 + tabs.length) % tabs.length;
    } else {
      return;
    }

    event.preventDefault();

    const nextTab = tabs[nextIndex];

    activateTab(nextTab);
    nextTab.focus();
  });
});

ArrowRightで次のタブへ移動する

右矢印(ArrowRight)が押されたら、現在操作しているタブのindex+1します。% tabs.lengthを使うことで、最後のタブを超えたら自動的に最初へ戻ります。

ArrowLeftで前のタブへ移動する

左矢印(ArrowLeft)が押されたら-1します。マイナスの数になるのを防ぐため、一度タブの総数を足してから余りを計算しています。これにより、最初のタブより前に行こうとしたら最後にループします。

完成コード

ここまでのHTML、CSS、JavaScript(クリック処理と矢印キー処理)を合わせた完全なコードです。複数設置にも対応しています。

<link rel="stylesheet" href="./style.css">
<script src="./script.js" defer></script>

<div class="tabs js-tabs">
  <div
    class="tabs__list"
    role="tablist"
    aria-label="商品情報"
  >
    <button
      id="tab-overview"
      class="tabs__tab js-tab"
      type="button"
      role="tab"
      aria-selected="true"
      aria-controls="panel-overview"
      data-tab-target="panel-overview"
      tabindex="0"
    >
      概要
    </button>

    <button
      id="tab-feature"
      class="tabs__tab js-tab"
      type="button"
      role="tab"
      aria-selected="false"
      aria-controls="panel-feature"
      data-tab-target="panel-feature"
      tabindex="-1"
    >
      特徴
    </button>

    <button
      id="tab-spec"
      class="tabs__tab js-tab"
      type="button"
      role="tab"
      aria-selected="false"
      aria-controls="panel-spec"
      data-tab-target="panel-spec"
      tabindex="-1"
    >
      仕様
    </button>
  </div>

  <div
    id="panel-overview"
    class="tabs__panel js-tab-panel"
    role="tabpanel"
    aria-labelledby="tab-overview"
    tabindex="0"
  >
    <p>商品の概要を表示します。</p>
  </div>

  <div
    id="panel-feature"
    class="tabs__panel js-tab-panel"
    role="tabpanel"
    aria-labelledby="tab-feature"
    tabindex="0"
    hidden
  >
    <p>商品の特徴を表示します。</p>
  </div>

  <div
    id="panel-spec"
    class="tabs__panel js-tab-panel"
    role="tabpanel"
    aria-labelledby="tab-spec"
    tabindex="0"
    hidden
  >
    <p>商品の仕様を表示します。</p>
  </div>
</div>
.tabs__list {
  display: flex;
  gap: 4px;
  border-bottom: 2px solid #ddd;
}

.tabs__tab {
  padding: 10px 20px;
  border: none;
  border-radius: 4px 4px 0 0;
  background-color: #f1f5f9;
  color: #64748b;
  font-size: 1rem;
  font-weight: bold;
  cursor: pointer;
  transition: background-color 0.2s, color 0.2s;
}

.tabs__tab:hover {
  background-color: #e2e8f0;
}

.tabs__tab:focus-visible {
  outline: 3px solid rgba(37, 99, 235, 0.45);
  outline-offset: 3px;
}

.tabs__tab[aria-selected="true"] {
  background-color: #3b82f6;
  color: #fff;
}

.tabs__panel {
  padding: 20px;
  background-color: #fff;
}

.tabs__panel[hidden] {
  display: none;
}

@media (prefers-reduced-motion: reduce) {
  .tabs__tab {
    transition: none;
  }
}
document.addEventListener('DOMContentLoaded', () => {
  const tabsContainers =
    document.querySelectorAll('.js-tabs');

  tabsContainers.forEach((tabsContainer) => {
    const tabs = Array.from(
      tabsContainer.querySelectorAll('.js-tab')
    );

    const panels = Array.from(
      tabsContainer.querySelectorAll('.js-tab-panel')
    );

    if (tabs.length === 0 || panels.length === 0) {
      return;
    }

    const activateTab = (tab) => {
      if (!tabs.includes(tab)) {
        return false;
      }

      const targetId = tab.dataset.tabTarget;

      if (!targetId) {
        return false;
      }

      const targetPanel = panels.find(
        (panel) => panel.id === targetId
      );

      if (!targetPanel) {
        return false;
      }

      tabs.forEach((currentTab) => {
        const isSelected = currentTab === tab;

        currentTab.setAttribute(
          'aria-selected',
          String(isSelected)
        );

        currentTab.setAttribute(
          'tabindex',
          isSelected ? '0' : '-1'
        );
      });

      panels.forEach((panel) => {
        panel.hidden = panel !== targetPanel;
      });

      return true;
    };

    const initialTab =
      tabs.find(
        (tab) =>
          tab.getAttribute('aria-selected') === 'true'
      ) || tabs[0];

    activateTab(initialTab);

    tabs.forEach((tab, index) => {
      tab.addEventListener('click', () => {
        activateTab(tab);
      });

      tab.addEventListener('keydown', (event) => {
        let nextIndex;

        if (event.key === 'ArrowRight') {
          nextIndex = (index + 1) % tabs.length;
        } else if (event.key === 'ArrowLeft') {
          nextIndex =
            (index - 1 + tabs.length) % tabs.length;
        } else {
          return;
        }

        event.preventDefault();

        const nextTab = tabs[nextIndex];

        if (activateTab(nextTab)) {
          nextTab.focus();
        }
      });
    });
  });
});

複数設置する場合も、各タブUIのidはページ内で重複させないでください。

実際に触ってみよう

完成コードは、下のCodePenでも確認できます。タブをクリックしたときの切り替えに加え、左右の矢印キーによる移動、aria-selected、tabindex、hiddenの変化も確認してください。

See the Pen Web制作JS|タブ切り替え|基本サンプル by WebToolGen (@webtoolgen) on CodePen.

よくある失敗と解決法

タブを押してもパネルが変わらない

JavaScriptでエラーが出ていないか確認しましょう。とくにdata属性の綴り間違いがよく起こります。

// HTML
data-tab-target="panel-overview"

// JavaScript
tab.dataset.tabTarget

data属性とパネルのidが一致していない

data-tab-target="panel-overview"と書いているのに、パネル側がid="overview"では、JavaScriptがパネルを見つけられません。完全一致しているか確認してください。

data-tab-target="panel-overview"
id="panel-overview"

複数のパネルが同時に表示される

「すべてのパネルを一度非表示にする」というループ処理が抜けているか、対象要素が正しく取得できていない可能性があります。

panels.forEach((panel) => {
  panel.hidden = panel !== targetPanel;
});

hiddenを付けてもパネルが消えない

独自CSSやアニメーション用のスタイルによって、hidden時の表示が上書きされていないか確認します。必要に応じて非表示指定も明示します。

.tabs__panel[hidden] {
  display: none;
}

見た目は変わるがaria-selectedが変わらない

CSSを独自のクラスで切り替え、aria-selectedの更新を忘れているケースです。実務では属性をデザインと状態管理の両方に使うと間違いが減ります。

currentTab.setAttribute(
  'aria-selected',
  String(isSelected)
);

roleを付けたが矢印キーで操作できない

HTMLにrole="tab"を付けただけでは、ブラウザは矢印キーの操作まで自動で行いません。JavaScriptでkeydownイベントを記述する必要があります。

tab.addEventListener(
  'keydown',
  handleKeydown
);

カスタマイズして使ってみよう

縦並びのタブにする

CSSで.tabs__listflex-directioncolumnにすると、見た目を縦並びにできます。

アクセシビリティにも対応する場合は、tablistaria-orientation="vertical"を追加し、左右矢印ではなく上下矢印(ArrowDown / ArrowUp)でタブを移動できるようにJavaScriptも調整します。

最初に表示するタブを変更する

HTMLの段階で、2つ目のタブのaria-selectedtrueにし、2つ目のパネルのhidden属性を外しておきます。他のタブはfalse、他のパネルにはhiddenを付けます。

複数のタブUIを同じページに置く

完成コードでは.js-tabsごとに処理を登録しているため、同じページに複数のタブUIがあっても、それぞれのブロック内で独立して正しく動作します。

ただし、idはページ内で重複できません。2つ目以降のタブUIでは、tab-overview-2panel-overview-2のように、それぞれ異なるidを設定してください。aria-controlsaria-labelledbydata-tab-targetの値も、対応するidに合わせて変更します。

URLのハッシュから表示するタブを決める

ページ読み込み時(DOMContentLoaded内)にwindow.location.hashを取得し、それと一致するidのタブに対してactivateTab(tab)を実行すると、外部から特定のタブを開いた状態でリンクさせることができます。

アクセシビリティで確認したいこと

タブにはbutton要素を使う

この記事では、クリック操作やフォーカスを扱いやすいbutton要素を使用します。divspanをクリック可能にするよりも、基本的な操作を実装しやすくなります。

rolearia属性を正しく関連付ける

親要素にrole="tablist"、タブにrole="tab"、パネルにrole="tabpanel"を付け、aria-controlsaria-labelledbyで相互の関連性を支援技術に伝えます。

選択中のタブだけtabindexを0にする

キーボードのTabキーでページ内を移動する際、すべてのタブにフォーカスが当たると操作が煩雑になります。非選択のタブにはtabindex="-1"を設定し、選択中のタブ(tabindex="0")からのみ矢印キーで操作させるのが推奨パターンです。

パネルにもtabindex="0"を付ける

パネル内にリンクやボタンなどのフォーカス可能な要素がない場合、ユーザーがパネルの内容を読み飛ばしてしまう可能性があります。role="tabpanel"自身にtabindex="0"を設定すると、Tabキーでパネル内容にフォーカスを当てられます。

次回は、モーダルウィンドウへ

今回は、data属性を活用して要素同士を関連付け、状態をまとめてリセットしてから更新する「タブ切り替え」を実装しました。

次回はいよいよ、画面の上に重なって表示される「モーダルウィンドウ(ポップアップ)」の作成に挑戦します。

これまでに扱った開閉状態の管理や背景スクロールの固定を応用し、表示する要素、閉じる操作、フォーカスの移動について確認します。