テクニック

タブUIの実装とアクセシビリティ|キーボード操作まで対応する手順

この記事の対象: タブ切り替えUIを自分で組む制作者(HTML/CSS/JSの基礎がある方)
読了時間: 約12分

タブUIは、制作の現場で頻繁に依頼される部品のひとつです。商品ページのスペック表、サービス紹介の料金プラン、FAQのカテゴリ分け。要件としては「クリックしたら中身が切り替わる」だけなので、display: none の付け外しで短時間で形になります。

ところがその短時間で作ったタブは、キーボードだけで操作する人にとっては使えないことが多い。Tabキーを押しても止まらない、止まっても選択状態が読み上げられない、矢印キーが効かない。加えて「このタブを開いた状態でURLを共有したい」という後出しの要望で作り直しになる。

この記事では、素のHTML/CSS/JavaScriptだけで、role と aria-selected を使った正しいタブUIを作ります。矢印キーでの移動、URLハッシュとの連動まで含めて、コピーすればそのまま動くコードを載せます。外部ライブラリは使いません。


前提とする環境

前提とする環境

この記事のコードは、以下の環境を想定しています。

項目 前提
HTML 静的HTML、WordPressテーマ、Shopifyテーマ(Online Store 2.0)いずれでも動く
JavaScript ES2015以降の構文(const、アロー関数、テンプレートリテラル)を使用
外部依存 なし。jQuery不要、フレームワーク不要
対応ブラウザ Chrome / Edge / Firefox / Safari の各現行版。IE11は対象外

CMSに載せる場合の設置場所は、記事の後半で個別に書きます。まずは素のHTMLで組んで、動いてから移植するのが安全です。

WordPressのテーマ内にJavaScriptを追加する手順は、下の記事で扱っている wp_enqueue_script の考え方がそのまま使えます。


「見た目だけのタブ」は何が足りないのか

まず、よく書かれる実装を見ておきます。

<div class="tabs">
  <div class="tab active" onclick="show(0)">概要</div>
  <div class="tab" onclick="show(1)">仕様</div>
  <div class="tab" onclick="show(2)">配送</div>
</div>
<div class="panel active">概要の中身</div>
<div class="panel">仕様の中身</div>
<div class="panel">配送の中身</div>

マウスでは問題なく動きます。動くので、そのまま納品されます。足りないのは次の4点です。

1. キーボードでフォーカスできない

<div> はフォーカスを受け取りません。Tabキーを押しても素通りするので、マウスを使わない人はタブを切り替える手段がありません。tabindex="0" を付ければ止まりますが、今度はEnterキーでの発火を自前で書く必要が出ます。

2. 「これはタブである」と伝わらない

スクリーンリーダーには「概要」「仕様」「配送」という3つの文字が並んでいるようにしか見えません。それがタブの列で、今どれが選ばれていて、全部で何個あるのか、という情報がありません。

3. 選択状態がクラス名でしか表現されていない

class="active" は見た目のための情報です。支援技術はクラス名を読みません。選択状態は aria-selected で伝える必要があります。

4. URLに状態が残らない

「仕様タブを見てほしい」と伝えたいとき、URLを送っても相手には概要タブが開きます。問い合わせ対応やチーム内共有で地味に効いてくる欠点です。

見た目だけのタブと、正しく組んだタブの差

role と aria を持つタブ

  • Tabキーでタブ列に入れる
  • 矢印キーで隣へ移動
  • 選択状態が読み上げられる
  • URLで開いた状態を共有できる

divとclassだけのタブ

  • キーボードで到達できない
  • 矢印キーは無反応
  • ただの文字列に見える
  • 常に1枚目から開く

差は見た目には出ません。だからレビューをすり抜けて、公開後に指摘されます。作る時点で正しい形にしておくのが一番安上がりです。


タブUIの正しいHTML構造

W3CのARIA Authoring Practices Guide(APG)が示すタブパターンに沿って組みます。必要な役割は3つです。

タブUIを構成する3つのrole

role=”tablist”(タブの入れ物)

  • role=”tab” × タブの数だけ

role=”tabpanel”(中身)

  • タブと1対1で対応する

tab と tabpanel は id と aria-controls / aria-labelledby で相互に紐づける。

タブとパネルは、id を使って双方向に参照し合います。この紐づけを省くと、パネルに移動したときに「どのタブの中身なのか」が分からなくなります。

コピーして使うHTML

<div class="tabs" data-tabs>
  <div class="tabs__list" role="tablist" aria-label="商品情報">
    <button type="button"
            class="tabs__tab"
            role="tab"
            id="tab-overview"
            aria-controls="panel-overview"
            aria-selected="true"
            tabindex="0">概要</button>
    <button type="button"
            class="tabs__tab"
            role="tab"
            id="tab-spec"
            aria-controls="panel-spec"
            aria-selected="false"
            tabindex="-1">仕様</button>
    <button type="button"
            class="tabs__tab"
            role="tab"
            id="tab-shipping"
            aria-controls="panel-shipping"
            aria-selected="false"
            tabindex="-1">配送について</button>
  </div>

  <div class="tabs__panel"
       role="tabpanel"
       id="panel-overview"
       aria-labelledby="tab-overview"
       tabindex="0">
    <h3>概要</h3>
    <p>ここに概要の内容が入ります。</p>
  </div>

  <div class="tabs__panel"
       role="tabpanel"
       id="panel-spec"
       aria-labelledby="tab-spec"
       tabindex="0"
       hidden>
    <h3>仕様</h3>
    <p>ここに仕様の内容が入ります。</p>
  </div>

  <div class="tabs__panel"
       role="tabpanel"
       id="panel-shipping"
       aria-labelledby="tab-shipping"
       tabindex="0"
       hidden>
    <h3>配送について</h3>
    <p>ここに配送情報の内容が入ります。</p>
  </div>
</div>

変更すべき箇所

箇所 何に変えるか
id="tab-overview" / id="panel-overview" ページ内で重複しない名前に。1ページに複数のタブUIを置くなら tab-faq-1 のように接頭辞で分ける
aria-controls / aria-labelledby 対応する id と必ず一致させる。ここがずれると紐づけが切れる
aria-label="商品情報" このタブ列が何のグループなのかを日本語で。「タブ」という語は入れない(roleで既に伝わるため二重になる)
ボタンのテキスト 表示したいラベルへ
class="tabs" 系 既存のCSS設計に合わせて。ただしJSは data-tabs 属性で拾うので、クラス名を変えてもJSは壊れない

なぜこう書くのか

<button> を使う理由: フォーカス可能で、Enterキーとスペースキーでの発火がブラウザ標準で効きます。<div tabindex="0"> にすると、この2つを自分で実装する羽目になります。type="button" を付けるのは、フォーム内に置かれたときに送信ボタンとして誤動作するのを防ぐためです。

tabindex が0と-1に分かれている理由: これがタブUIの肝です。選択中のタブだけ tabindex="0"、それ以外は -1。こうするとTabキーでタブ列に入ったとき、フォーカスは選択中の1つにだけ止まり、次のTabで中身(tabpanel)へ抜けます。タブが10個あっても、Tabキーを10回押させることになりません。タブ間の移動は矢印キーが担当します。この方式をroving tabindexと呼びます。

パネルに tabindex="0" を付ける理由: パネル内にリンクやボタンがない場合、キーボード操作ではパネルの中身に到達できません。パネル自体をフォーカス可能にしておくと、Tabキーで内容へ移動でき、スクリーンリーダーが本文を読み上げられます。逆にパネル内にリンクやフォーム部品が必ず入る設計なら、tabindex="0" は外してもかまいません。フォーカスの止まる箇所が1つ減り、キーボード操作の手数が短くなります。

hidden を使う理由: display: none をCSSで当てるのと結果は同じですが、hidden はHTMLの属性なのでJS側での状態管理が素直になります。ただしCSSでの上書きに注意が必要で、これは次章で扱います。


CSSを書く

CSSを書く

見た目は自由に組んでかまいませんが、2つだけ守る必要のあるルールがあります。

/* ── 必須:hidden 属性を確実に効かせる ── */
.tabs__panel[hidden] {
  display: none;
}

/* ── タブ列 ── */
.tabs__list {
  display: flex;
  gap: 4px;
  border-bottom: 1px solid #d8d8d8;
}

.tabs__tab {
  appearance: none;
  border: none;
  background: transparent;
  padding: 12px 20px;
  font-size: 15px;
  font-family: inherit;
  color: #666;
  cursor: pointer;
  border-bottom: 2px solid transparent;
  margin-bottom: -1px;
  transition: color .2s, border-color .2s;
}

.tabs__tab:hover {
  color: #111;
}

/* ── 選択状態は aria-selected で当てる ── */
.tabs__tab[aria-selected="true"] {
  color: #111;
  font-weight: 700;
  border-bottom-color: #111;
}

/* ── 必須:フォーカスリングを消さない ── */
.tabs__tab:focus-visible,
.tabs__panel:focus-visible {
  outline: 2px solid #1a73e8;
  outline-offset: 2px;
}

/* ── パネル ── */
.tabs__panel {
  padding: 24px 4px;
}

変更すべき箇所

  • #111 #666 #d8d8d8:サイトの配色に合わせる
  • #1a73e8:フォーカスリングの色。背景とのコントラストが取れる色を選ぶ
  • padding font-size:デザインに合わせる
  • border-bottom 方式のタブが不要なら、その3行を消して背景色での表現に変えても問題ない

押さえておきたい3点

1つ目。.tabs__panel[hidden] { display: none; } を書く。

hidden 属性はブラウザのデフォルトスタイルで display: none になりますが、そのスタイルの詳細度は非常に低いため、.tabs__panel { display: block; } のような指定が別の場所にあると、あっさり負けて全パネルが表示されます。ユーティリティ系のCSSフレームワークやリセットCSSを入れている環境で起きやすい事故です。属性セレクタで明示的に書いておけば防げます。

2つ目。outline: none を書かない。

タブの見た目を整える過程で *:focus { outline: none; } を書いてしまうと、キーボード操作している人は今どこにいるのか分からなくなります。せっかくroving tabindexを組んでも、目印が消えていては使えません。デフォルトのリングが好みに合わないなら、:focus-visible で自前のスタイルを当てる。消すのではなく置き換えます。

:focus-visible はマウスクリック時にはリングを出さず、キーボード操作時にだけ出す擬似クラスです。現行のChrome / Edge / Firefox / Safari はいずれも対応済みなので、単独で書いて問題ありません。古い環境も拾いたい場合は :focus を併記して段階的に対応します。

3つ目。タブ切り替えのアニメーションは控えめにする。

パネルにフェードインやスライドを付けたくなりますが、切り替えのたびに待たされる時間が発生します。付けるなら .2s 以下に収め、prefers-reduced-motion で止める逃げ道を用意します。

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

動きを減らす設定をOSでオンにしている人には、トランジションが一切かからなくなります。1ブロック書くだけなので、入れておいて損はありません。


JavaScript:矢印キーとURLハッシュに対応する

JavaScript:矢印キーとURLハッシュに対応する

ここが本題です。以下のコードをそのまま貼れば、キーボード操作とURL連動の両方が動きます。

(function () {
  'use strict';

  /**
   * タブUI
   * - 矢印キー(左右)でタブ移動、Home / End で端へ移動
   * - URLハッシュと連動(#panel-spec で開いた状態を共有できる)
   * - ページ内に複数設置しても干渉しない
   */
  function initTabs(root) {
    var tablist = root.querySelector('[role="tablist"]');
    if (!tablist) return;

    var tabs = Array.prototype.slice.call(
      tablist.querySelectorAll('[role="tab"]')
    );
    if (tabs.length === 0) return;

    // 各タブに対応するパネルを取得
    function panelOf(tab) {
      return document.getElementById(tab.getAttribute('aria-controls'));
    }

    /**
     * タブを選択する
     * @param {Element} tab      選択するタブ
     * @param {boolean} setFocus フォーカスを移すか(キー操作時は true)
     * @param {boolean} pushHash URLハッシュを書き換えるか
     */
    function selectTab(tab, setFocus, pushHash) {
      tabs.forEach(function (t) {
        var isTarget = (t === tab);
        var panel = panelOf(t);

        t.setAttribute('aria-selected', isTarget ? 'true' : 'false');
        t.setAttribute('tabindex', isTarget ? '0' : '-1');

        if (panel) {
          if (isTarget) {
            panel.removeAttribute('hidden');
          } else {
            panel.setAttribute('hidden', '');
          }
        }
      });

      if (setFocus) tab.focus();

      if (pushHash) {
        var id = tab.getAttribute('aria-controls');
        // スクロールを起こさずURLだけ書き換える
        history.replaceState(null, '', '#' + id);
      }
    }

    // ── クリック ──
    tabs.forEach(function (tab) {
      tab.addEventListener('click', function () {
        selectTab(tab, false, true);
      });
    });

    // ── キーボード ──
    tablist.addEventListener('keydown', function (e) {
      var current = tabs.indexOf(document.activeElement);
      if (current === -1) return;

      var next = null;

      switch (e.key) {
        case 'ArrowRight':
          next = (current + 1) % tabs.length;
          break;
        case 'ArrowLeft':
          next = (current - 1 + tabs.length) % tabs.length;
          break;
        case 'Home':
          next = 0;
          break;
        case 'End':
          next = tabs.length - 1;
          break;
        default:
          return;
      }

      e.preventDefault();
      selectTab(tabs[next], true, true);
    });

    // ── 読み込み時:URLハッシュに一致するタブを開く ──
    var hash = window.location.hash.replace('#', '');
    if (hash) {
      var matched = tabs.filter(function (t) {
        return t.getAttribute('aria-controls') === hash || t.id === hash;
      })[0];
      if (matched) {
        selectTab(matched, false, false);
        // ハッシュ指定で来たときはタブの位置まで送る
        root.scrollIntoView({ behavior: 'auto', block: 'start' });
      }
    }

    // ── 戻る/進むボタンへの対応 ──
    window.addEventListener('hashchange', function () {
      var h = window.location.hash.replace('#', '');
      if (!h) return;
      var t = tabs.filter(function (tab) {
        return tab.getAttribute('aria-controls') === h || tab.id === h;
      })[0];
      if (t) selectTab(t, false, false);
    });
  }

  function boot() {
    var roots = document.querySelectorAll('[data-tabs]');
    Array.prototype.forEach.call(roots, initTabs);
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', boot);
  } else {
    boot();
  }
})();

変更すべき箇所

このコードは基本的にそのままで動きます。触るとしたら次の2箇所です。

箇所 変更の目安
[data-tabs] 初期化の対象を拾うセレクタ。既存の命名規則があれば .js-tabs などに変えてよい。HTML側の属性も合わせて変える
history.replaceState 「戻る」ボタンでタブを1つずつ戻したいなら history.pushState に変える。ただし戻る操作が煩雑になるので、通常は replaceState を推奨

何をしているのか

タブが切り替わるときに更新される4つの状態
aria-selected選択中を true、他を false に
tabindex選択中を 0、他を -1 に(roving)
hidden対応パネルだけ属性を外す
URLハッシュreplaceState で書き換え

この4つが常に揃って動くよう、selectTab() という1つの関数にまとめてあります。クリックもキー操作もハッシュ読み込みも、すべて同じ関数を通るので、状態がずれません。逆に言うと、後から「切り替え時にアナリティクスへ送りたい」「パネルの中身を遅延取得したい」といった要件が来ても、足す場所はこの関数の中1箇所で済みます。

e.preventDefault() を呼ぶ理由: 矢印キーの標準動作はページのスクロールです。これを止めないと、タブが切り替わると同時にページが上下に動きます。ただし default: で早期returnしているので、Tabキーやその他のキーの動作は妨げません。ここを雑に「常にpreventDefault」と書くと、Tabキーでタブ列から抜けられなくなります。

% tabs.length で循環させる理由: APGのタブパターンでは、右端で右矢印を押すと左端へ戻る循環動作が推奨されています。端で止まる実装も許容されますが、循環のほうが「行き止まりに当たった」感覚がなく操作しやすい。

replaceState を使う理由: location.hash = '#panel-spec' と書くと、ブラウザがその要素へ自動スクロールします。パネルの位置まで画面が飛ぶので、タブを切り替えるたびに視点が動いて不快です。history.replaceState はURLだけを書き換え、スクロールを起こしません。

hashchange を拾う理由: ユーザーがブラウザの戻るボタンを押したとき、URLは変わるのにタブの状態が変わらない、という食い違いを防ぎます。

aria-controls と id の両方を照合している理由: 共有されるURLは #panel-spec とは限りません。既存サイトからの移行で #tab-spec の形が出回っていることもあります。どちらで来ても開くようにしておくと、過去に配られたURLを壊さずに済みます。


選択と同時に開くか、選択してから開くか

APGには2つの方式が定義されています。

方式 動作 向いている場面
自動アクティベーション 矢印キーでフォーカスが移った瞬間にパネルも切り替わる パネルの中身が軽い。この記事のコードはこちら
手動アクティベーション 矢印キーではフォーカスだけ移動し、EnterまたはSpaceで初めてパネルが開く パネル内で重い処理が走る(地図の描画、外部データの取得など)

パネルの中身が静的なHTMLなら、自動アクティベーションで問題ありません。ただし、タブを切り替えるたびにAPIを叩くような実装では、矢印キーを押しっぱなしにされたときにリクエストが連射されます。その場合は手動に切り替えます。

手動アクティベーションにするには、上のJSの keydown 部分を次のように差し替えます。

    // ── キーボード(手動アクティベーション版)──
    tablist.addEventListener('keydown', function (e) {
      var current = tabs.indexOf(document.activeElement);
      if (current === -1) return;

      var next = null;

      switch (e.key) {
        case 'ArrowRight':
          next = (current + 1) % tabs.length;
          break;
        case 'ArrowLeft':
          next = (current - 1 + tabs.length) % tabs.length;
          break;
        case 'Home':
          next = 0;
          break;
        case 'End':
          next = tabs.length - 1;
          break;
        case 'Enter':
        case ' ':
          e.preventDefault();
          selectTab(tabs[current], false, true);
          return;
        default:
          return;
      }

      e.preventDefault();
      tabs[next].focus();   // フォーカスだけ移す。パネルは切り替えない
    });

手動方式にする場合、tabindex の付け替えを選択時ではなくフォーカス時に行う必要が出てきます。フォーカスだけ移した状態でTabキーを押すと、tabindex="-1" のままの要素から抜けようとして挙動が読みにくくなるためです。具体的には、tabs[next].focus() の直前で全タブの tabindex を -1 にし、tabs[next] だけ 0 に戻す処理を挟みます。細かい調整が必要になるので、要件が「重い処理を避けたい」以外にないなら、自動アクティベーションのまま進めるのが無難です。

判断の目安はシンプルで、パネルを開く処理が通信やレンダリングを伴うかどうかの1点です。静的なテキストと画像だけなら自動、地図・グラフ・外部埋め込み・APIフェッチが入るなら手動。迷ったら、矢印キーを押しっぱなしにして開発者ツールのネットワークタブを見てください。リクエストが並ぶようなら手動へ切り替える合図です。


CMSのテーマに組み込む

CMSのテーマに組み込む

WordPressの場合

ブロックテーマ・クラシックテーマ共通で、子テーマに置くのが安全です。有料テーマを直接編集すると、テーマ更新で消えます。

配置するファイル:

wp-content/themes/(子テーマ名)/
├── assets/js/tabs.js      ← 上のJavaScriptをそのまま保存
├── assets/css/tabs.css    ← 上のCSSをそのまま保存
└── functions.php          ← 下のコードを追記

functions.php に追記するコード:

<?php
add_action( 'wp_enqueue_scripts', function () {
    $dir = get_stylesheet_directory();
    $uri = get_stylesheet_directory_uri();

    wp_enqueue_style(
        'tabs-style',
        $uri . '/assets/css/tabs.css',
        array(),
        filemtime( $dir . '/assets/css/tabs.css' )
    );

    wp_enqueue_script(
        'tabs-script',
        $uri . '/assets/js/tabs.js',
        array(),
        filemtime( $dir . '/assets/js/tabs.js' ),
        true   // </body> 直前に出力する
    );
} );

filemtime() をバージョン番号に使っているのは、ファイルを更新したときにブラウザキャッシュを確実に切るためです。手で '1.0.1' と書き換える運用は、必ずどこかで忘れます。

HTMLは投稿画面のカスタムHTMLブロック、またはテンプレートファイルに直接書きます。エディタで貼る場合、ビジュアルエディタが aria-* 属性を削ってしまうことがあるので、カスタムHTMLブロックを使ってください。

タブUIを置くページが限られているなら、wp_enqueue_scripts の中で条件分岐して読み込みを絞ります。

    if ( ! is_page( array( 'faq', 'service' ) ) ) {
        return;
    }

is_page() の引数はスラッグでもIDでも渡せます。全ページで読み込む必要のないファイルを絞るだけで、他のページの表示は素直に軽くなります。

Shopify(Online Store 2.0テーマ)の場合

セクションファイル1枚にまとめてしまうのが手軽です。

配置するファイル: sections/custom-tabs.liquid

<div class="tabs" data-tabs>
  <div class="tabs__list" role="tablist" aria-label="{{ section.settings.tablist_label | escape }}">
    {%- for block in section.blocks -%}
      <button type="button"
              class="tabs__tab"
              role="tab"
              id="tab-{{ section.id }}-{{ forloop.index }}"
              aria-controls="panel-{{ section.id }}-{{ forloop.index }}"
              aria-selected="{% if forloop.first %}true{% else %}false{% endif %}"
              tabindex="{% if forloop.first %}0{% else %}-1{% endif %}"
              {{ block.shopify_attributes }}>
        {{ block.settings.title | escape }}
      </button>
    {%- endfor -%}
  </div>

  {%- for block in section.blocks -%}
    <div class="tabs__panel"
         role="tabpanel"
         id="panel-{{ section.id }}-{{ forloop.index }}"
         aria-labelledby="tab-{{ section.id }}-{{ forloop.index }}"
         tabindex="0"
         {% unless forloop.first %}hidden{% endunless %}>
      {{ block.settings.body }}
    </div>
  {%- endfor -%}
</div>

{% schema %}
{
  "name": "タブ",
  "settings": [
    {
      "type": "text",
      "id": "tablist_label",
      "label": "タブ列の説明(読み上げ用)",
      "default": "商品情報"
    }
  ],
  "blocks": [
    {
      "type": "tab",
      "name": "タブ",
      "settings": [
        { "type": "text", "id": "title", "label": "タブ名", "default": "タブ" },
        { "type": "richtext", "id": "body", "label": "内容" }
      ]
    }
  ],
  "presets": [
    {
      "name": "タブ",
      "blocks": [ { "type": "tab" }, { "type": "tab" } ]
    }
  ]
}
{% endschema %}

CSSとJSは assets/custom-tabs.css / assets/custom-tabs.js に保存し、layout/theme.liquid の </head> の直前で読み込みます。

{{ 'custom-tabs.css' | asset_url | stylesheet_tag }}
<script src="{{ 'custom-tabs.js' | asset_url }}" defer="defer"></script>

Shopifyで変更すべき箇所

  • セクション名 custom-tabs:テーマ内で重複しない名前に。既存テーマのファイルを上書きしないよう、独自の接頭辞を付けるのが安全です
  • {{ section.id }} を id に混ぜている理由:同じセクションを1ページに2つ置いても id が衝突しないようにするため。ここを固定文字列にすると、2つ目のタブUIが1つ目のパネルを操作してしまいます
  • {{ block.shopify_attributes }}:テーマエディタでブロックを選んだときにハイライトさせるために必要。消さないでください

商品ページのタブとして使う場合、block.settings.body の代わりに product.description や商品メタフィールドを流し込む形にもできます。その場合はセクションではなく sections/main-product.liquid 側のブロックとして組み込むほうが、テーマエディタでの並び替えが自然になります。

なお、テーマエディタでブロックを追加・削除すると forloop.index がずれるため、URLハッシュで共有していたリンクは切れます。ハッシュを長期的に固定したいなら、forloop.index ではなく block.id を使ってください。{{ block.id }} はブロックごとに固有で、並び替えても変わりません。

セクションを新規で作る際の全体的な流れは、下の記事で扱っているスライダーセクションの構成と同じです。

ブロック単位でコンテンツを増やせるセクションの作り方については、Shopifyブログ記事一覧のカスタマイズ|カード表示と絞り込み でも section.blocks の扱いを解説しています。


動作確認のやり方

実装したら、マウスを机の脇へどけて、キーボードだけで次の順に確認します。

  1. Tabキーを押していく — タブ列に入ったとき、選択中のタブ1つにだけフォーカスが止まる。すべてのタブを順に通過するなら tabindex の付け替えが効いていません
  2. 右矢印キーを押す — 隣のタブへ移り、パネルも切り替わる。ページがスクロールしたら preventDefault() が抜けています
  3. 右端で右矢印を押す — 左端へ戻る
  4. HomeキーとEndキーを押す — 端へ飛ぶ
  5. もう一度Tabキーを押す — パネルの中身へフォーカスが移る
  6. URLの末尾を見る — タブを切り替えるたびに #panel-spec のようなハッシュが付く。付いたURLをコピーして新しいタブで開き、そのタブが開いた状態で表示される

読み上げの確認は、macOSならVoiceOver(Command + F5)、WindowsならナレーターまたはNVDAを使います。選択中のタブにフォーカスしたとき、「概要 タブ 選択済み 1/3」のように、名前・役割・状態・位置が読み上げられれば正しく組めています。読み上げの文言はスクリーンリーダーと言語設定によって変わるので、一言一句が同じである必要はありません。名前・役割・選択状態の3つが揃って読まれるかだけを見てください。

ブラウザの開発者ツールでも確認できます。Chromeの要素タブで対象を選び、「アクセシビリティ」ペインを開くと、そのノードのroleと状態が一覧できます。aria-selected が意図どおり切り替わっているかは、ここを見るのが一番速いです。Lighthouseのアクセシビリティ監査も併用できますが、自動チェックが拾えるのは属性の欠落や参照切れまでで、「矢印キーで動くか」は判定できません。最後は手で触る工程が必ず要ります。

サイト全体の状態を数字で追う仕組みについては、下の記事も参考になります。


うまくいかないとき

全部のパネルが同時に表示される

hidden 属性がCSSに負けています。.tabs__panel[hidden] { display: none; } を追加してください。それでも表示されるなら、display: flex や display: grid が別のセレクタから当たっている可能性があります。開発者ツールでパネル要素を選び、display プロパティに何が効いているかを確認します。

Tabキーですべてのタブを順に通過してしまう

tabindex="-1" が付いていません。HTMLの初期状態で、1つ目以外に tabindex="-1" が書かれているか確認します。JSが動いていない場合もこうなるので、コンソールにエラーが出ていないかも見てください。

矢印キーでページがスクロールする

e.preventDefault() が呼ばれていません。keydown のリスナーが tablist に付いているかを確認します。個々のタブボタンに付けている場合、キーイベントは拾えますが、実装によっては取りこぼしが起きます。

2つ目のタブUIが1つ目を操作してしまう

id が重複しています。1ページに複数のタブUIを置く場合、id はページ内で一意でなければなりません。Shopifyなら {{ section.id }}、WordPressなら投稿IDやループのインデックスを混ぜて生成します。

URLハッシュを付けて開くと画面が変な位置で止まる

ヘッダーが固定配置されている場合、scrollIntoView でタブがヘッダーの下に隠れます。CSSで調整できます。

.tabs {
  scroll-margin-top: 80px;   /* 固定ヘッダーの高さに合わせる */
}

80px を、実際のヘッダー高さに変えてください。scroll-margin-top は現行の主要ブラウザで対応しています。ヘッダーの高さがスクロール量で変わる(縮む)実装なら、CSS変数に逃がして JS 側から更新するほうが確実です。

スクリーンリーダーが「選択済み」と読まない

aria-selected の値が文字列 "true" / "false" になっているか確認します。JSで tab.ariaSelected = true のように真偽値を渡すと、環境によっては期待どおりに反映されないことがあります。上のコードのように setAttribute('aria-selected', 'true') と文字列で指定するのが確実です。

また、role="tab" の要素が role="tablist" の直接の子になっていないと、関係が認識されません。装飾のために <div> でくるむと壊れます。どうしても囲みたい場合は、囲む側に role="presentation" を付けます。

タブの中に画像があり、切り替え時に高さが飛ぶ

hidden の間はレイアウト計算されないため、画像が読み込まれるまで高さが確定しません。画像に width と height 属性を書いておけば、ブラウザが領域を先に確保します。

<img src="/spec.jpg" alt="仕様の一覧表" width="800" height="600" loading="lazy">

loading="lazy" を付ける場合、hidden のパネル内の画像は表示されるまで読み込まれません。切り替えた瞬間に一拍空くのが気になるなら、最初のタブ以外は loading="lazy"、というように使い分けます。

パネル内のフォームやiframeが切り替えるたびにリセットされる

hidden の付け外しでは要素はDOMに残るので、入力値は保持されます。リセットされるなら、JSでパネルの中身を再生成しているか、innerHTML を書き換えている実装が別にあります。この記事のコードは属性の付け外ししかしないので、その挙動にはなりません。

一方、hidden のパネル内にある <iframe>(YouTube埋め込みなど)は、非表示でも読み込まれ、動画によっては音声が鳴り続けることがあります。タブを閉じたときに止めたいなら、selectTab() の中で非選択パネル内の <iframe> の src を退避・復帰させる処理を足します。

タブが多すぎて何が入っているか分からない

これは実装ではなく設計の問題です。タブは「並列で、どれか1つを見る」情報に向いた部品で、数が増えるほど機能しなくなります。5つを超えたら、アコーディオン、ページ分割、あるいは絞り込みフィルタのほうが適していないかを検討してください。

モバイルでタブが横にはみ出る

タブ列を横スクロールさせるのが現実的です。

@media (max-width: 640px) {
  .tabs__list {
    overflow-x: auto;
    flex-wrap: nowrap;
    scrollbar-width: none;          /* Firefox */
    -webkit-overflow-scrolling: touch;
  }
  .tabs__list::-webkit-scrollbar {
    display: none;                  /* Chrome / Safari */
  }
  .tabs__tab {
    flex: 0 0 auto;
    white-space: nowrap;
  }
}

ただしスクロールバーを隠すと「まだ右にタブがある」ことが伝わりません。右端にグラデーションをかける、あるいはタブを2段に折り返す(flex-wrap: wrap のまま横スクロールしない)ほうが親切な場合もあります。

横スクロールにする場合、選択中のタブが画面外にあると迷子になります。selectTab() の中で、選択したタブをタブ列の中で見える位置へ寄せておくと親切です。

      if (setFocus) {
        tab.focus();
        tab.scrollIntoView({ block: 'nearest', inline: 'nearest' });
      }

block: 'nearest' を指定しているのは、縦方向にページを動かさないためです。ここを省くと、タブを切り替えるたびに画面全体が上下に飛びます。


よくある質問

タブUIにアクセシビリティ対応は本当に必要ですか

必要です。キーボードだけで操作する人はスクリーンリーダー利用者だけではなく、マウス操作が難しい人、単に効率のためにキーボードを使う人も含まれます。加えて、role と aria-selected を正しく書いておくと、支援技術だけでなくクローラーにも構造が伝わりやすくなります。追加のコストは属性を数個書くだけで、後から直すほうがはるかに高くつきます。

JavaScriptを使わずにタブUIを作れますか

<input type="radio"> と :checked を使ったCSSのみの実装は可能ですが、矢印キーの挙動がラジオボタンのものになり、aria-selected の付け替えもできません。<details> を並べる方法はタブではなくアコーディオンの構造になります。タブとして正しく動かすなら、この記事の分量のJavaScriptが最小構成です。ファイルサイズも小さく、ライブラリを読み込むより軽く済みます。

矢印キーは上下でも動くようにすべきですか

タブ列が横並びなら左右キー、縦並びなら上下キーが基本です。縦並びにする場合は role="tablist" に aria-orientation="vertical" を付け、JSの ArrowRight / ArrowLeft を ArrowDown / ArrowUp に置き換えます。横並びのタブで上下キーも効かせると、ページスクロールを奪うことになるので避けてください。

タブの中身は検索エンジンに認識されますか

hidden で隠れているパネルの中身もHTMLには存在するため、クローラーは読み取れます。ただし表示されていないコンテンツの評価は表示されているものより低く扱われる可能性があるため、最も重要な情報は1枚目のタブか、タブの外に置くのが安全です。JavaScriptでコンテンツを後から取得して差し込む実装にすると、この前提が崩れます。

既存サイトのタブを直すとき、どこから手を付けますか

順番は、①<div> を <button type="button"> に変える、②role と id の紐づけを入れる、③aria-selected を追加してCSSのセレクタを .active から [aria-selected="true"] に置き換える、④tabindex の付け替えと矢印キー、の4段階です。①〜③までで見た目は変わらないまま大半の問題が解消するので、改修の範囲が限られている場合はここまでを優先します。


まとめ

タブUIを正しく作るために必要なのは、次の4つだけです。

  • role="tablist" / role="tab" / role="tabpanel" で構造を宣言する
  • 選択状態を class ではなく aria-selected で表現し、CSSもそれを見る
  • tabindex を 0 と -1 で付け替え、タブ間の移動は矢印キーに任せる
  • history.replaceState でURLハッシュを更新し、状態を共有できるようにする

外部ライブラリは要りません。読み込むファイルも増えないので、表示速度を落とさずにアクセシビリティを満たせます。テンプレートとして手元に置いておくと、次の案件からは貼るだけで済みます。

見た目だけのタブと正しいタブの差は、最初に作るときならわずかな手間です。公開後に直すとなると、HTMLの構造から書き換えることになり、影響範囲の確認も含めて桁が変わります。作る時点で正しい形にしておくのが、結局いちばん早い。

制作全体の判断軸については、下の記事もあわせてどうぞ。