CSSだけで、タブを切り替える方法

​​CSS​HTML,Playwright,アクセシビリティ

タブの数だけラジオボタンを置き、それぞれの label をタブの見た目にすると、JavaScript を使わずにタブを切り替えられます。どのパネルを出すかは、:has() と :checked で「選ばれているラジオボタン」を見て決めます。ラジオボタンなので、マウスのクリックだけでなく、キーボードの矢印キーでも切り替わります。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Playwright 1.63.0、Chromium 153 です。HTML と CSS のページを Playwright で開き、マウスとキーボードで操作して、getComputedStyle() の値と、Chromium のアクセシビリティツリー(CDP の Accessibility.getFullAXTree)を読みました。Firefox と Safari、スマートフォンの実機、スクリーンリーダーでの実際の読み上げは試していません。

白いカードの上に「概要」「使い方」「よくある質問」の3つのタブが並び、その下にパネルの文章がある。マウスポインターが「使い方」「よくある質問」「概要」の順にタブへ動いてクリックする。クリックした瞬間に左上の文字が「クリック 1回目/3回」のように変わってピンクの地になり、クリックしたタブの文字が太い水色に、下線が水色に変わって、下のパネルの文章がそのタブのものに入れ替わる
3つのタブを順にクリックした様子。クリックしたタブに下線が移り、下のパネルが入れ替わる

コード全体

HTML は、ラジオボタンと label の組をタブの数だけ並べ、その後ろにパネルを置きます。ラジオボタンにはすべて同じ name を付けます。最初に出しておくタブのラジオボタンには checked を付けます。

html
<div class="tabs">
  <div class="tab-list">
    <input type="radio" name="tabs" id="tab-1" checked>
    <label for="tab-1">概要</label>
    <input type="radio" name="tabs" id="tab-2">
    <label for="tab-2">使い方</label>
    <input type="radio" name="tabs" id="tab-3">
    <label for="tab-3">よくある質問</label>
  </div>
  <div class="tab-panel" id="panel-1">概要の内容です。</div>
  <div class="tab-panel" id="panel-2">使い方の内容です。<a href="#steps">詳しい手順</a></div>
  <div class="tab-panel" id="panel-3">よくある質問の内容です。<a href="#faq">質問の一覧</a></div>
</div>

CSS は次のとおりです。

css
/* ラジオボタンは見えなくするが、キーボードでは選べるように残す */
.tab-list input {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: 0;
  opacity: 0;
}

.tab-list {
  display: flex;
  border-bottom: 2px solid #cfd8dc;
}

/* タブの見た目は label に付ける */
.tab-list label {
  padding: 10px 18px;
  margin-bottom: -2px;
  border-bottom: 3px solid transparent;
  color: #5f6b72;
  cursor: pointer;
}

/* 選ばれているタブ */
.tab-list input:checked + label {
  border-bottom-color: #00c8e8;
  color: #067a98;
  font-weight: bold;
}

/* キーボードで移ってきたときは、タブに枠を出す */
.tab-list input:focus-visible + label {
  outline: 2px solid #067a98;
  outline-offset: -2px;
}

/* パネルは、ふだんは隠す */
.tab-panel {
  display: none;
  padding: 16px 4px;
}

/* 選ばれているラジオボタンに合わせて、パネルを1つだけ出す */
.tabs:has(#tab-1:checked) #panel-1,
.tabs:has(#tab-2:checked) #panel-2,
.tabs:has(#tab-3:checked) #panel-3 {
  display: block;
}

ラジオボタンで「どのタブが選ばれているか」を持つ

CSS だけでは、クリックされたことを覚えておけません。そこで、選ばれているタブを、ラジオボタンのチェックの状態として持たせます。ラジオボタンの説明にあるとおり、同じ name のラジオボタンは1つのグループになり、1つを選ぶと、ほかの選択は自動で外れます。タブも、選べるのは1つだけなので、この動きがそのまま使えます。

ラジオボタンの丸は見せず、label をタブの見た目にします。label の説明にあるとおり、for 属性で結びつけた label をクリックすると、ラジオボタンそのものをクリックしたのと同じように選ばれます。

検証したページで「使い方」「よくある質問」「概要」の順に label をクリックすると、クリックするたびに、そのタブのラジオボタンが選ばれ、対応するパネルだけが display: block になりました。ほかの2つは display: none でした。

:has() と :checked で、選ばれたパネルだけを出す

パネルは、ふだんは display: none で隠しています。出すパネルを決めるのは、次のルールです。

css
.tabs:has(#tab-2:checked) #panel-2 {
  display: block;
}

:checked は、チェックされているラジオボタンやチェックボックスに当てはまる擬似クラスです。:has() は、かっこの中のセレクターに当てはまる要素を中に持つ要素を選びます。つまり、このルールは「#tab-2 が選ばれている .tabs の中の #panel-2」を選びます。

:has() を使うと、ラジオボタンとパネルが同じ親の中に並んでいなくてもかまいません。この記事の HTML では、ラジオボタンは .tab-list の中、パネルはその外にあります。タブの見た目を整えるために、ラジオボタンと label を1つの要素にまとめておけます。

選ばれているタブの見た目は、input:checked + label で変えます。+ は、同じ親の中で直後にある要素を選ぶ書き方です。HTML でラジオボタンのすぐ後ろに、そのタブの label を置いているのは、このためです。開いた直後の「概要」の label は、文字の色が rgb(6, 122, 152)(#067a98)、下線の色が rgb(0, 200, 232)(#00c8e8)、太さが700でした。選ばれていない「使い方」は、文字が灰色、下線は透明でした。

矢印キーでも切り替わる

キーボードでは、ラジオボタンのグループとして動きます。検証したページでは、.tabs の前と後ろにリンクを1つずつ置きました。前のリンクにフォーカスがある状態から操作して、選ばれているラジオボタンと、出ているパネルを読みました。

押したキー フォーカスのある場所 出ているパネル
Tab 「概要」のラジオボタン 概要
→ 「使い方」のラジオボタン 使い方
→ 「よくある質問」のラジオボタン よくある質問
→ 「概要」のラジオボタン 概要
← 「よくある質問」のラジオボタン よくある質問
↓ 「概要」のラジオボタン 概要
↑ 「よくある質問」のラジオボタン よくある質問
Home 「よくある質問」のラジオボタン よくある質問
End 「よくある質問」のラジオボタン よくある質問

Tab キーで入ると、フォーカスは選ばれているラジオボタンに移りました。「使い方」を選んだ状態で前のリンクから Tab キーを押したときも、フォーカスは先頭の「概要」ではなく「使い方」に移りました。←→キーと↑↓キーでは、フォーカスが移ると同時にそのラジオボタンが選ばれ、パネルも入れ替わりました。最後のタブで→キーを押すと、最初のタブに戻りました。一方、Home キーと End キーを押しても、何も変わりませんでした。

「使い方」を選んだ状態で Tab キーを押すと、フォーカスはほかのラジオボタンには止まらず、出ているパネルの中のリンク「詳しい手順」に移りました。もう一度 Tab キーを押すと、タブの後ろのリンクに移りました。Shift+Tab キーで戻ると、パネルの中のリンクを経て、「使い方」のラジオボタンに戻りました。隠れている「よくある質問」のパネルにもリンク「質問の一覧」がありますが、そこにはフォーカスが止まりませんでした。

キーボードで移ってきたことは、input:focus-visible + label で label に枠を出して示します。キーボードで操作したあとの label には、2px の水色の outline が付いていました。マウスで label をクリックしたときも、フォーカスはラジオボタンに移りましたが、:focus-visible は当てはまらず、枠は出ませんでした。

ラジオボタンを display: none で隠さない

ラジオボタンを隠すときに display: none を使うと、キーボードで操作できなくなります。ラジオボタンの隠し方だけを display: none に変えたページで、タブの前のリンクから Tab キーを押すと、フォーカスはタブを飛ばして、タブの後ろのリンクに移りました。

マウスで label をクリックすると、この場合もタブは切り替わりました。ただし、フォーカスはラジオボタンに移らず、body のままでした。マウスで試しただけでは、キーボードで使えないことに気づけません。

この記事のコードでは、ラジオボタンを opacity: 0 で透明にし、width と height を1pxにして、position: absolute でタブの並びから外しています。この隠し方なら、上の表のとおり、キーボードで選べます。

アクセシビリティツリーでは「タブ」ではなく「ラジオボタン」になる

Chromium のアクセシビリティツリーで、「使い方」を選んだときのページを読みました。3つのタブは、役割(role)が radio で、名前が「概要」「使い方」「よくある質問」、選ばれているのは「使い方」だけでした。パネルの文章は、出ている「使い方の内容です。」だけがツリーにあり、隠れている2つのパネルの文章はありませんでした。

Chromium では、支援技術に渡る役割は tab ではなく radio でした。WAI-ARIA には、タブを表す tab という役割があります。tab ロールの説明には、この役割と同じ機能を、HTML と CSS だけで用意する方法はないと書かれています。同じ説明の「キーボード操作」の表には、Home キーで最初のタブへ、End キーで最後のタブへ移る操作も挙がっています。この記事のタブでは、上の表のとおり、Home キーと End キーは効きませんでした。

ポイント

支援技術にタブとして伝えたいときは、role="tab" などの ARIA の役割と JavaScript で作ります。

:target で切り替える書き方との違い

CSS だけで切り替える方法には、リンクと :target を使う書き方もあります。タブを <a href="#panel-2"> のようなリンクにし、.tab-panel:target { display: block; } で、URL の # の後ろと同じ id のパネルを出します。比べるために、タブの上と下に高さ600pxの余白を置いたページを作って試しました。

css
.tab-panel { display: none; padding: 16px 4px; }
.tab-panel:target { display: block; }

開いた直後は、URL に # が付いていないので、パネルは3つとも display: none で、何も出ていませんでした。

タブが画面の中ほど(上から208px)に見える位置までスクロールしてから「使い方」をクリックすると、パネルは出ましたが、画面がスクロールしました。スクロールの位置は400pxから629pxに変わりました。このとき、パネルの上端は画面の上端(0px)にあり、タブの並びは上端が-21px、下端が0pxで、画面の外に出ていました。

クリックするたびに、URL が #panel-2、#panel-3 と変わり、ブラウザの履歴の数(history.length)も1つずつ増えました。ブラウザの「戻る」を押すと、ページを離れるのではなく、1つ前のタブ「使い方」に戻りました。

ラジオボタンを使う書き方でも、同じように上下に600pxの余白を置いたページで試しました。開いた直後から、checked を付けた「概要」のパネルが出ていました。スクロールの位置を400pxにしてから「使い方」「よくある質問」をクリックすると、パネルは入れ替わりましたが、スクロールの位置は400pxのまま、タブの並びの位置(画面の上から229px)も変わりませんでした。URL に # は付かず、history.length も増えませんでした。

対応しているブラウザ

MDN の互換性データ(@mdn/browser-compat-data 8.1.8)で調べると、この記事のコードのうち、使えるようになった版がいちばん新しいのは :has() で、Chrome 105、Firefox 121、Safari 15.4 からです。:focus-visible は Chrome 86、Firefox 85、Safari 15.4 から使えます。:checked は Chrome 1、Firefox 1、Safari 3.1 から、+ は Chrome 1、Firefox 1、Safari 1 から使えます。

​Posted by 86