Reactで、タブをキーボードで切り替えられるように作る方法
タブの並びの中では ←→ キーでフォーカスを動かし、Tab キーで止まるタブは1つだけにすると、タブをキーボードで切り替えられるようになります。React では、タブの button を ref の配列で持っておき、キーを押したら次のタブの focus() を呼び、tabIndex を 0 と -1 で付け替えます。
W3C の WAI-ARIA Authoring Practices Guide(APG)にあるタブのパターンに合わせて作り、キー操作を1つずつブラウザで確かめました。
検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で Chromium を操作して確かめました。スクリーンリーダーでの読み上げ、Firefox と Safari、右から左に書く言語のページでは試していません。

作ったのは、猫のお世話の4つのタブです。Tab キーでタブの並びに入り、→ を押すたびに次のタブへ移って、表示も切り替わります。最後のタブの次は最初に戻ります。もう一度 Tab キーを押すと、ほかのタブには止まらずにパネルへ進みます。
どのキーで何をするか
APG のタブのパターンが示すキー操作のうち、次のものを作りました。
| キー | 動き |
|---|---|
| Tab | タブの並びに入るときは、選ばれているタブに止まる。タブの並びの中で押すと、並びの外(パネル)へ進む |
| → | 次のタブへ。最後のタブの次は最初に戻る |
| ← | 前のタブへ。最初のタブの前は最後に戻る |
| Home・End | 最初のタブ・最後のタブへ(パターンでは任意の操作) |
| Enter・Space | フォーカスを移しただけでは切り替わらない作りのときに、そのタブを表示する |
矢印キーでフォーカスを移したときに、そのタブをすぐ表示するかどうかは選べます。APG のパターンは、パネルがすぐに表示できるなら、すぐ表示する作りを勧めています。この記事のコンポーネントでは、すぐ表示するのを既定にし、activation="manual" を渡すと Enter か Space で表示するようにしました。
コンポーネントの全体
ラベルと、タブの見出しと中身の配列を受け取ります。
import { useId, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
export type TabItem = {
key: string;
label: string;
content: ReactNode;
};
type Props = {
label: string;
items: TabItem[];
// automatic:矢印キーでフォーカスを移したタブをすぐに表示する
// manual:矢印キーではフォーカスだけを移し、Enter か Space で表示する
activation?: 'automatic' | 'manual';
orientation?: 'horizontal' | 'vertical';
};
export function Tabs({ label, items, activation = 'automatic', orientation = 'horizontal' }: Props) {
const id = useId();
const tabId = (index: number) => `${id}-tab-${index}`;
const panelId = (index: number) => `${id}-panel-${index}`;
const [selectedIndex, setSelectedIndex] = useState(0);
// フォーカスのあるタブ。Tab キーで止まるタブ(tabIndex=0)はこれに合わせる
const [focusIndex, setFocusIndex] = useState(0);
const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
const prevKey = orientation === 'horizontal' ? 'ArrowLeft' : 'ArrowUp';
const nextKey = orientation === 'horizontal' ? 'ArrowRight' : 'ArrowDown';
function select(index: number) {
setSelectedIndex(index);
setFocusIndex(index);
}
function onKeyDown(event: KeyboardEvent<HTMLButtonElement>, index: number) {
const last = items.length - 1;
let next: number;
switch (event.key) {
case prevKey:
next = index === 0 ? last : index - 1;
break;
case nextKey:
next = index === last ? 0 : index + 1;
break;
case 'Home':
next = 0;
break;
case 'End':
next = last;
break;
default:
return; // ほかのキー(横並びのときの ↑↓ など)はブラウザに任せる
}
event.preventDefault(); // ページのスクロールを止める
tabRefs.current[next]?.focus();
if (activation === 'automatic') select(next);
else setFocusIndex(next);
}
return (
<div className="tabs">
<div
role="tablist"
aria-label={label}
aria-orientation={orientation}
onBlur={(event) => {
// タブの並びからフォーカスが出たら、次に Tab キーで入ったときは選ばれているタブに止まるよう戻す
if (!event.currentTarget.contains(event.relatedTarget)) setFocusIndex(selectedIndex);
}}
>
{items.map((item, index) => (
<button
key={item.key}
ref={(element) => {
tabRefs.current[index] = element;
}}
type="button"
role="tab"
id={tabId(index)}
aria-selected={index === selectedIndex}
aria-controls={panelId(index)}
// Tab キーで止まるのは、タブの並びの中で1つだけにする
tabIndex={index === focusIndex ? 0 : -1}
onClick={() => select(index)}
onKeyDown={(event) => onKeyDown(event, index)}
>
{item.label}
</button>
))}
</div>
{items.map((item, index) => (
<div
key={item.key}
role="tabpanel"
id={panelId(index)}
aria-labelledby={tabId(index)}
tabIndex={0}
hidden={index !== selectedIndex}
>
{item.content}
</div>
))}
</div>
);
}
役割(role)と属性は、APG のパターンのとおりに付けました。並びの div が tablist、各タブが tab、中身が tabpanel です。選ばれているタブだけ aria-selected が true になり、タブの aria-controls とパネルの aria-labelledby で、タブとパネルを互いに指します。この id は、useId のリファレンスにある、関係する複数の要素に id を付ける使い方と同じく、useId の値に -tab-0 や -panel-0 を足して作りました。
タブは button にしたので、クリックはもちろん、Enter と Space でも onClick が呼ばれます。パネルはすべて描画しておき、選ばれていないものを hidden で隠しています。パネルの中身はすべて文章で、フォーカスできる要素がありません。そのため、パネルに tabIndex={0} を付けて、Tab キーでパネルに進めるようにしました。APG のパターンは、パネルの中にフォーカスできる要素がないとき、または中身の最初の要素がフォーカスできないときに、パネルを tabindex="0" にするとしています。
検証では、このコンポーネントの前と後ろにボタンを1つずつ置きました。label には「猫のお世話」を渡しています。Playwright の ariaSnapshot でアクセシビリティツリーを取り出すと、次のようになりました(最後の「おやすみ」を選んだ状態)。
- main:
- button "前のボタン"
- tablist "猫のお世話":
- tab "ごはん"
- tab "あそび"
- tab "おふろ"
- tab "おやすみ" [selected]
- tabpanel "おやすみ":
- paragraph: 窓ぎわのベッドで寝ます。
- button "後のボタン"
パネルの名前は、aria-labelledby が指すタブの文字(「おやすみ」)になっています。
Tab キーで止まるタブを1つにする
タブの button は、何もしなければすべて Tab キーで止まります。tabIndex の行を消した版で、「前のボタン」から Tab キーを6回押しました。
Tab: フォーカス タブ「ごはん」 / aria-selected=true ごはん
Tab: フォーカス タブ「あそび」 / aria-selected=true ごはん
Tab: フォーカス タブ「おふろ」 / aria-selected=true ごはん
Tab: フォーカス タブ「おやすみ」 / aria-selected=true ごはん
Tab: フォーカス パネル(朝と夜の2回、決まった量をあげます。) / aria-selected=true ごはん
Tab: フォーカス 「後のボタン」 / aria-selected=true ごはん
4つのタブすべてに止まってから、パネルに進みました。タブが多いと、その先へ進むまでに何度も Tab キーを押すことになります。
APG のパターンでは、Tab キーはタブの並びに入るときに選ばれているタブへ止まり、並びの中で押せば並びの外へ出ます。並びの中の移動は矢印キーに任せます。そこで、Tab キーで止めるタブだけを tabIndex={0} にし、ほかを -1 にします。MDN の tabindex のページによると、負の値の要素は Tab キーでは止まりませんが、focus() を呼べばフォーカスできます。矢印キーで動かすたびに 0 を付け替えるこの書き方を、APG はロービング tabindex(roving tabindex)と呼んでいます。
End で「おやすみ」に移った状態から、Tab キーを2回押し、Shift + Tab を2回押しました。
Tab: フォーカス パネル(窓ぎわのベッドで寝ます。)
Tab: フォーカス 「後のボタン」
Shift+Tab: フォーカス パネル(窓ぎわのベッドで寝ます。)
Shift+Tab: フォーカス タブ「おやすみ」
タブの並びからはすぐにパネルへ進み、戻ってきたときは、選ばれている「おやすみ」に止まりました。
矢印キーで、次のタブにフォーカスを移す
tabIndex を付け替えるだけでは、フォーカスは動きません。キーを押したときに、移り先のタブの focus() を自分で呼びます。そのために、タブの button を ref の配列に入れておきます。map() の中では useRef を呼べないので、ref に関数を渡して、受け取った要素を配列に入れます。React のドキュメントのref コールバックで ref のリストを管理する方法にある書き方です。
ref コールバックを {} で囲んでいるのは、代入の値を返さないためです。React 19 では、ref コールバックが返した関数を、ref を外すときのクリーンアップとして呼びます。(element) => (tabRefs.current[index] = element) のように式で書くと、要素を返すことになります。検証環境の @types/react 19.3.0 では、ref コールバックが返してよいのはクリーンアップの関数だけなので、この書き方は tsc の型チェックでエラー(TS2322)になりました。
Tab キーでタブの並びに入り、→ を4回、← を1回、Home と End を1回ずつ押しました。
Tab: フォーカス タブ「ごはん」 / aria-selected=true ごはん / tabindex 0,-1,-1,-1
→: フォーカス タブ「あそび」 / aria-selected=true あそび / tabindex -1,0,-1,-1
→: フォーカス タブ「おふろ」 / aria-selected=true おふろ / tabindex -1,-1,0,-1
→: フォーカス タブ「おやすみ」 / aria-selected=true おやすみ / tabindex -1,-1,-1,0
→: フォーカス タブ「ごはん」 / aria-selected=true ごはん / tabindex 0,-1,-1,-1
←: フォーカス タブ「おやすみ」 / aria-selected=true おやすみ / tabindex -1,-1,-1,0
Home: フォーカス タブ「ごはん」 / aria-selected=true ごはん / tabindex 0,-1,-1,-1
End: フォーカス タブ「おやすみ」 / aria-selected=true おやすみ / tabindex -1,-1,-1,0
フォーカスと aria-selected と tabindex="0" が、いつも同じタブにそろって動きました。見えているパネルも、選ばれているタブのものに切り替わっています。「おやすみ」の次は「ごはん」に、「ごはん」の前は「おやすみ」に戻りました。
ページのスクロールを止めるのは、タブで使うキーだけ
onKeyDown では、タブで使うキーのときだけ event.preventDefault() を呼んでいます。高さ 3000px の要素を足した長いページで、タブにフォーカスがある状態から ↓ と End を押し、preventDefault() の行を消した版と比べました。
この記事のコンポーネントでは、次のようになりました。
タブで ↓: scrollY 40 / フォーカス タブ「ごはん」
タブで End: scrollY 0 / フォーカス タブ「おやすみ」
preventDefault() の行を消した版では、次のようになりました。
タブで ↓: scrollY 40 / フォーカス タブ「ごはん」
タブで End: scrollY 2870 / フォーカス タブ「おやすみ」
行を消した版では、End で最後のタブに移ると同時に、ページがいちばん下までスクロールしました。scrollY が 2870 なので、高さ 400px の画面からはタブが見えなくなります。
一方の ↓ は、どちらの版でもページを 40px スクロールしました。横並びのタブでは ↓↑ を使わず、switch の default でそのまま返しているからです。APG のパターンも、横並びのタブは ↓↑ を受け取らず、フォーカスがタブにあってもページをスクロールできるようにするとしています。
Enter・Space で切り替える場合は、tabindex をフォーカスに合わせる
パネルの表示に時間がかかる場合は、矢印キーでフォーカスを移すたびに切り替えると、移動のたびに待たされます。APG のフォーカスに合わせて選択を変えるかどうかの説明では、パネルを出すのにネットワークの通信が起きる場合を例に、選択がフォーカスについていくと移動のたびに大きく待たされることを説明し、選択がフォーカスについていかない場合は Enter か Space で選ぶとしています。
activation="manual" を渡すと、矢印キーはフォーカスだけを動かします。
Tab: フォーカス タブ「ごはん」 / aria-selected=true ごはん
→: フォーカス タブ「あそび」 / aria-selected=true ごはん
→: フォーカス タブ「おふろ」 / aria-selected=true ごはん
Enter: フォーカス タブ「おふろ」 / aria-selected=true おふろ
→: フォーカス タブ「おやすみ」 / aria-selected=true おふろ
Space: フォーカス タブ「おやすみ」 / aria-selected=true おやすみ
Home: フォーカス タブ「ごはん」 / aria-selected=true おやすみ
→ ではフォーカスだけが動き、Enter や Space を押したときに、そのタブが選ばれました。
この作りでは、フォーカスのあるタブと選ばれているタブが別になります。最初は tabIndex を選ばれているタブに合わせただけの版で書いたところ、Tab キーの動きがおかしくなりました。「おやすみ」を選んだまま Home で「ごはん」にフォーカスを移し、Tab キーと Shift + Tab を押した結果です。
Home: フォーカス タブ「ごはん」 / tabindex -1,-1,-1,0
Home のあと Tab: フォーカス タブ「おやすみ」 / tabindex -1,-1,-1,0
Tab: フォーカス パネル(窓ぎわのベッドで寝ます。) / tabindex -1,-1,-1,0
Shift+Tab: フォーカス タブ「おやすみ」 / tabindex -1,-1,-1,0
Shift+Tab: フォーカス 「前のボタン」 / tabindex -1,-1,-1,0
この版では、「ごはん」で Tab キーを押すと、パネルではなく、同じ並びの「おやすみ」に止まりました。ブラウザは Tab キーを押したとき、フォーカスのある要素より後ろにある、Tab キーで止まる最初の要素へフォーカスを移すようです。「ごはん」より後ろでは、tabindex="0" の「おやすみ」がその最初の要素だったと考えられます。APG のパターンでは、タブの並びの中で Tab キーを押したら、並びの外へ出ます。
そこでこの記事のコンポーネントでは、tabIndex を選ばれているタブ(selectedIndex)ではなく、フォーカスのあるタブ(focusIndex)に合わせました。矢印キーで動かしたら、focusIndex も動かします。また、タブの並びからフォーカスが出たら、focusIndex を selectedIndex に戻します。こうしないと、次に Tab キーで戻ってきたとき、選ばれていない「ごはん」に止まってしまいます。次に Tab キーで入ったときに止まる要素を、並びからフォーカスが出た時点で決めておく処理も、APG のロービング tabindex の手順に含まれています。
同じ操作を、この記事のコンポーネントで試しました。
Home: フォーカス タブ「ごはん」 / tabindex 0,-1,-1,-1
Home のあと Tab: フォーカス パネル(窓ぎわのベッドで寝ます。) / tabindex -1,-1,-1,0
Tab: フォーカス 「後のボタン」 / tabindex -1,-1,-1,0
Shift+Tab: フォーカス パネル(窓ぎわのベッドで寝ます。) / tabindex -1,-1,-1,0
Shift+Tab: フォーカス タブ「おやすみ」 / tabindex -1,-1,-1,0
Home のあとは「ごはん」が tabindex="0" になり、Tab キーでパネルへ進みました。並びから出た時点で tabindex="0" は「おやすみ」に戻り、Shift + Tab で戻ってきたときは「おやすみ」に止まっています。
タブの並びから出たかどうかは、並びの div の onBlur で調べます。React の onBlur は、ブラウザの blur イベントと違ってバブリングします。そのため、親の div 1つで、どのタブからフォーカスが外れたかを受け取れます。MDN の relatedTarget のページのとおり、relatedTarget は次にフォーカスを受け取る要素です。これが並びの中にあれば、タブどうしの移動なので何もしません。この currentTarget.contains(relatedTarget) の書き方は、React のドキュメントにある、フォーカスイベントの例と同じです。
なお、MDN の relatedTarget のページによると、ページの外へフォーカスが移るときなどには relatedTarget が null になることがあります。そのときも focusIndex は戻ります。ページに戻ったときにフォーカスが「ごはん」に残っていると、Tab キーを押したときに、tabIndex を選ばれているタブに合わせただけの版と同じく「おやすみ」に止まると考えられます。この場合は試していません。
すぐ表示する作り(既定の automatic)では、フォーカスと選択がいつも同じタブにあるので、focusIndex と selectedIndex は同じ値になります。
縦に並べるときは ↑↓ で動かす
タブを縦に並べるときは、APG のパターンでは tablist に aria-orientation="vertical" を付け、→← の代わりに ↓↑ で動かします。この記事のコンポーネントでは、orientation="vertical" を渡すと、aria-orientation を付けて、使う矢印キーを ArrowUp と ArrowDown に変えます。
Tab: フォーカス タブ「ごはん」
↓: フォーカス タブ「あそび」
↓: フォーカス タブ「おふろ」
↑: フォーカス タブ「あそび」
→: フォーカス タブ「あそび」
←: フォーカス タブ「あそび」
縦並びでは ↓↑ でタブが動き、→← では動きませんでした。tablist の aria-orientation は既定値が horizontal なので、横並びなら付けなくてもかまいません。この記事のコンポーネントでは、どちらの場合も値を付けています。





