Reactで、タブをキーボードで切り替えられるように作る方法

​​React​Playwright,TypeScript,useRef,WAI-ARIA,アクセシビリティ

タブの並びの中では ←→ キーでフォーカスを動かし、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、右から左に書く言語のページでは試していません。

「前のボタン」から Tab キーでタブの並びに入り、→ を4回押すとフォーカスと選ばれているタブが「あそび」「おふろ」「おやすみ」と動き、最後の次は「ごはん」に戻る。← で「おやすみ」、Home で「ごはん」に移り、Tab キーでパネルに進む
キー操作8回の様子。矢印キーと Home でタブが切り替わり、Tab キーはタブの並びから次のパネルへ進む(操作と状態の表示を足した検証用の画面)

作ったのは、猫のお世話の4つのタブです。Tab キーでタブの並びに入り、→ を押すたびに次のタブへ移って、表示も切り替わります。最後のタブの次は最初に戻ります。もう一度 Tab キーを押すと、ほかのタブには止まらずにパネルへ進みます。

どのキーで何をするか

APG のタブのパターンが示すキー操作のうち、次のものを作りました。

キー 動き
Tab タブの並びに入るときは、選ばれているタブに止まる。タブの並びの中で押すと、並びの外(パネル)へ進む
→ 次のタブへ。最後のタブの次は最初に戻る
← 前のタブへ。最初のタブの前は最後に戻る
Home・End 最初のタブ・最後のタブへ(パターンでは任意の操作)
Enter・Space フォーカスを移しただけでは切り替わらない作りのときに、そのタブを表示する

矢印キーでフォーカスを移したときに、そのタブをすぐ表示するかどうかは選べます。APG のパターンは、パネルがすぐに表示できるなら、すぐ表示する作りを勧めています。この記事のコンポーネントでは、すぐ表示するのを既定にし、activation="manual" を渡すと Enter か Space で表示するようにしました。

コンポーネントの全体

ラベルと、タブの見出しと中身の配列を受け取ります。

tsx
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 でアクセシビリティツリーを取り出すと、次のようになりました(最後の「おやすみ」を選んだ状態)。

text
- main:
  - button "前のボタン"
  - tablist "猫のお世話":
    - tab "ごはん"
    - tab "あそび"
    - tab "おふろ"
    - tab "おやすみ" [selected]
  - tabpanel "おやすみ":
    - paragraph: 窓ぎわのベッドで寝ます。
  - button "後のボタン"

パネルの名前は、aria-labelledby が指すタブの文字(「おやすみ」)になっています。

Tab キーで止まるタブを1つにする

タブの button は、何もしなければすべて Tab キーで止まります。tabIndex の行を消した版で、「前のボタン」から Tab キーを6回押しました。

text
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回押しました。

text
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回ずつ押しました。

text
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() の行を消した版と比べました。

この記事のコンポーネントでは、次のようになりました。

text
タブで ↓: scrollY 40 / フォーカス タブ「ごはん」
タブで End: scrollY 0 / フォーカス タブ「おやすみ」

preventDefault() の行を消した版では、次のようになりました。

text
タブで ↓: scrollY 40 / フォーカス タブ「ごはん」
タブで End: scrollY 2870 / フォーカス タブ「おやすみ」

行を消した版では、End で最後のタブに移ると同時に、ページがいちばん下までスクロールしました。scrollY が 2870 なので、高さ 400px の画面からはタブが見えなくなります。

一方の ↓ は、どちらの版でもページを 40px スクロールしました。横並びのタブでは ↓↑ を使わず、switch の default でそのまま返しているからです。APG のパターンも、横並びのタブは ↓↑ を受け取らず、フォーカスがタブにあってもページをスクロールできるようにするとしています。

Enter・Space で切り替える場合は、tabindex をフォーカスに合わせる

パネルの表示に時間がかかる場合は、矢印キーでフォーカスを移すたびに切り替えると、移動のたびに待たされます。APG のフォーカスに合わせて選択を変えるかどうかの説明では、パネルを出すのにネットワークの通信が起きる場合を例に、選択がフォーカスについていくと移動のたびに大きく待たされることを説明し、選択がフォーカスについていかない場合は Enter か Space で選ぶとしています。

activation="manual" を渡すと、矢印キーはフォーカスだけを動かします。

text
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 を押した結果です。

text
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 の手順に含まれています。

同じ操作を、この記事のコンポーネントで試しました。

text
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 に変えます。

text
Tab: フォーカス タブ「ごはん」
↓: フォーカス タブ「あそび」
↓: フォーカス タブ「おふろ」
↑: フォーカス タブ「あそび」
→: フォーカス タブ「あそび」
←: フォーカス タブ「あそび」

縦並びでは ↓↑ でタブが動き、→← では動きませんでした。tablist の aria-orientation は既定値が horizontal なので、横並びなら付けなくてもかまいません。この記事のコンポーネントでは、どちらの場合も値を付けています。

​Posted by 86