© TOOLS BOX — Next.js / React / TypeScript コードサンプル集

サンプルガイド
←サンプル一覧
nextjsui-component

Zustand Test の TabBar を role=tablist とキーボード操作で完成させる

uiStore の activeTab を起点に、role=tablist / aria-selected / 矢印キー操作 / フォーカス管理を満たした TabBar を組み立てる手順を解説する。

難易度: 中級·更新: 2026-05-07·
zustand

対応バージョン

nextjs 15react 19zustand 5

前提環境

uiStore の activeTab / setTab を使った TabBar の最小実装を理解していること

概要

Zustand Test の TabBar は最小実装でも動きますが、a11y を満たすにはもう一段 必要です。WAI-ARIA の Tabs パターンに沿って次の 4 点を整えます。

項目役割
role の三役tablist / tab / tabpanel を正しく当てる
aria-selected現在のタブを補助技術に伝える
aria-controls / idタブ ↔ パネルを id で紐付ける
キーボード操作←→ で移動、Home / End で端、Enter / Space で確定

state は uiStore 編 と同じ activeTab / setTab をそのまま使います。

インストール

npm install zustand

role の三役を当てる

<nav> に role="tablist"、<button> に role="tab"、表示エリアに role="tabpanel" を付けます。<button> は デフォルトでフォーカス可能 なので追加属性は要りません。

// src/components/tabs/TabBar.tsx
"use client";
import { useUiStore, type Tab } from "@/store/uiStore";

const tabs: ReadonlyArray<{ id: Tab; label: string }> = [
  { id: "form",   label: "フォーム"   },
  { id: "detail", label: "詳細"       },
  { id: "result", label: "結果表示"   },
];

export function TabBar() {
  const activeTab = useUiStore((s) => s.activeTab);
  const setTab    = useUiStore((s) => s.setTab);

  return (
    <div role="tablist" aria-label="設定タブ" className="flex gap-2 border-b">
      {tabs.map((t) => (
        <button
          key={t.id}
          role="tab"
          id={`tab-${t.id}`}
          aria-selected={activeTab === t.id}
          aria-controls={`panel-${t.id}`}
          tabIndex={activeTab === t.id ? 0 : -1}
          onClick={() => setTab(t.id)}
          className={activeTab === t.id ? "font-bold" : ""}
        >
          {t.label}
        </button>
      ))}
    </div>
  );
}

ポイントは tabIndex={activeTab === t.id ? 0 : -1}。Tabs パターンでは tab 一覧の中で「フォーカス可能なのは現在のタブだけ」 にし、矢印キーで明示的に移動する仕様です。

tabpanel 側の id 紐付け

role="tabpanel" の側にも id と aria-labelledby を付けて、対応するタブと結びつけます。

// src/components/tabs/TabPanels.tsx
"use client";
import { useUiStore } from "@/store/uiStore";
import { FormTab }    from "./FormTab";
import { DetailTab }  from "./DetailTab";
import { ResultTab }  from "./ResultTab";

export function TabPanels() {
  const activeTab = useUiStore((s) => s.activeTab);
  return (
    <>
      <section
        role="tabpanel"
        id="panel-form"
        aria-labelledby="tab-form"
        hidden={activeTab !== "form"}
      >
        <FormTab />
      </section>
      <section
        role="tabpanel"
        id="panel-detail"
        aria-labelledby="tab-detail"
        hidden={activeTab !== "detail"}
      >
        <DetailTab />
      </section>
      <section
        role="tabpanel"
        id="panel-result"
        aria-labelledby="tab-result"
        hidden={activeTab !== "result"}
      >
        <ResultTab />
      </section>
    </>
  );
}

非表示タブは DOM から削除する か hidden 属性で隠す かを選べます。hidden を使うと、フォーム入力中などに状態が保持されたまま切り替えられます。

キーボード操作を実装する

矢印キーでアクティブを移動、Home / End で両端、Enter / Space で確定する挙動を onKeyDown で書きます。

"use client";
import { type KeyboardEvent, useRef } from "react";
import { useUiStore, type Tab } from "@/store/uiStore";

const order: Tab[] = ["form", "detail", "result"];

export function TabBar() {
  const activeTab = useUiStore((s) => s.activeTab);
  const setTab    = useUiStore((s) => s.setTab);
  const refs      = useRef<Record<Tab, HTMLButtonElement | null>>({
    form: null, detail: null, result: null,
  });

  const handleKey = (e: KeyboardEvent<HTMLButtonElement>) => {
    const idx = order.indexOf(activeTab);
    let next: Tab | null = null;

    if (e.key === "ArrowRight") next = order[(idx + 1) % order.length];
    if (e.key === "ArrowLeft")  next = order[(idx - 1 + order.length) % order.length];
    if (e.key === "Home")       next = order[0];
    if (e.key === "End")        next = order[order.length - 1];
    if (e.key === "Enter" || e.key === " ") {
      // クリック扱い。aria-selected はすでに activeTab に追従済み
      return;
    }
    if (next) {
      e.preventDefault();
      setTab(next);
      refs.current[next]?.focus();
    }
  };

  return (
    <div role="tablist" aria-label="設定タブ" className="flex gap-2 border-b">
      {order.map((id) => (
        <button
          key={id}
          ref={(el) => { refs.current[id] = el; }}
          role="tab"
          id={`tab-${id}`}
          aria-selected={activeTab === id}
          aria-controls={`panel-${id}`}
          tabIndex={activeTab === id ? 0 : -1}
          onClick={() => setTab(id)}
          onKeyDown={handleKey}
        >
          {/* label は省略 */}
        </button>
      ))}
    </div>
  );
}

矢印キーで移動したあと、focus() も明示的に当てる のが要点です。tabIndex は setTab 経由で activeTab が変わると自動で追従しますが、フォーカスは React が動かしてくれません。

hidden と display:none の違い

タブパネルを hidden 属性で隠した場合、DOM に残ったまま CSS の display: none になります。子コンポーネントの state は保持されます。 完全に破棄したい ケース(重い再レンダリングを避けたい・初期化からやり直したい)は条件分岐で出し分けます。

{activeTab === "form" && <FormTab />}

Playground の場合、フォーム入力中の値を保持したいので hidden を選んでいます。

自動切り替え (manual / automatic)

WAI-ARIA Tabs には 「フォーカスが当たったら自動でアクティブを切り替える」 モード (automatic activation) もあります。Playground ではタブごとに副作用があり得るため、Enter / Space で確定する manual モード を採用しています。 矢印キー単体では setTab を呼ばず、Enter / Space で確定する形に変えるなら、上のコードの Enter / Space 分岐で setTab(focusedTab) を呼ぶだけで切り替えられます。

ポイント

  • role は tablist / tab / tabpanel の三役。<nav> ではなく <div role="tablist"> でも構わない
  • 現在のタブだけ tabIndex={0}、他は tabIndex={-1} にし、矢印キーで明示的に移動 させる
  • aria-selected を boolean で当て、id ↔ aria-controls ↔ aria-labelledby で タブとパネルを紐付け る
  • 矢印キー移動後は setTab だけでなく ref.focus() を 明示的に呼ぶ
  • パネルの非表示は hidden 属性で state 保持、条件分岐で 破棄。要件で使い分ける

関連記事

セットで読むと文脈がつながる関連記事は次のとおりです。

  • uiStore 編 — activeTab / setTab の最小設計
  • エラー UI / aria 編 — フォーム側の a11y 対応
  • アクション受け渡し編 — setTab を子コンポーネントに渡すパターン
  • Zustand Test (Build) — 完成例と外部デモ

注意点

Playground 追加記事。WAI-ARIA の Tabs パターンに沿って、role 指定・属性・矢印キー操作・フォーカス管理を 1 通りまとめる。

リポジトリを見る

関連サンプル

同じテーマや技術スタックを使った実装例

  • Zustand Test のフォームエラーを aria-invalid と aria-describedby で読み上げ可能にする

    RHF の errors.<field>.message を、aria-invalid と aria-describedby で input と紐付けてスクリーンリーダーに伝える書き方を解説する。

  • Zustand Test の uiStore でタブ切り替えだけを切り出す

    アクティブなタブの状態だけを抱える uiStore を独立させ、settings / profile / detail と疎結合に保つ最小設計を解説する。

  • React で検索入力欄にキーボードショートカットでフォーカスする

    / や Ctrl+K などのキーボードショートカットで検索入力欄へフォーカスを移動するパターン。useEffect と keydown イベントで実装し、入力中の誤作動を防ぐ制御も示す。

  • Zustand Test のアクションを selector → props で安全に受け渡す

    Zustand のアクションは store 生成時から参照が安定している性質を活かし、selector で取り出して props で子に渡す書き方を解説する。

  • Zustand Test の Store Monitor サイドパネルを selector だけで作る

    全 store の値をリアルタイムに監視するサイドパネルを selector だけで構成し、再レンダリング範囲を局所化するパターンを解説する。

関連仕様

このサンプルを理解するのに役立つ仕様や概念

  • FrameworkNext.jsReact ベースのフルスタックフレームワーク。SSR・SSG・App Router・API Routes を提供する。
←サンプル一覧に戻る