React, Vue.js
投稿日:

“キーボードだけで使えるUI”を最小コードで ─ Roving TabindexとFocus Trapの実装法

キーボードだけで使えるUIを最小コードで ─ Roving TabindexとFocus Trapの実装法

こんにちは、株式会社ファストコーディングのフルスタックエンジニア、独身貴族Fireです。

先日、学生時代の仲間に誘われてライブハウスに行ったんです。ステージ上のスポットライトが、ボーカルからギタリスト、ドラマーへと滑らかに移っていく。観客は自然と照らされた場所に目が行く。もし照明がランダムに飛び回ったら、どこを見ればいいかわからなくて、演奏に集中できません。

……自分もギター練習中ですが、スポットライトを浴びる予定は当分ないですね。

WebのUI操作でも、まったく同じことが起きています。キーボードでメニューを操作しようとしたら、Tabキーを押すたびにフォーカスが画面のあちこちに飛ぶ。モーダルを開いたのに、Tabで裏側のコンテンツにフォーカスが移ってしまう。ダイアログを閉じたら、ページの先頭に戻される。

これらはすべて「フォーカス管理」の問題です。スクリーンリーダーを使っている方や、手の障害でマウスが使えない方にとって、フォーカスの動きはスポットライトそのもの。予測可能で滑らかでなければ、UIは使い物になりません。

「でも、アクセシビリティ対応って大変じゃないですか? WAI-ARIAの仕様は膨大だし、専用ライブラリを入れるとバンドルが増えるし……」

そう思う方も多いでしょう。実際、私もかつてはそう考えていました。しかしRoving TabindexFocus Trapという2つのパターンを押さえるだけで、メニュー・タブ・ダイアログの3大コンポーネントのフォーカス管理が、外部ライブラリなしで実現できます。今回はその実装方法を、ReactとVueの両方で解説します。

なぜフォーカス管理が壊れるのか

まず、よくあるBad Caseを見てみます。

<!-- Bad Case: メニューの全項目がTab停止点 -->
<nav>
  <a href="/" tabindex="0">ホーム</a>
  <a href="/about" tabindex="0">会社概要</a>
  <a href="/service" tabindex="0">サービス</a>
  <a href="/blog" tabindex="0">ブログ</a>
  <a href="/contact" tabindex="0">お問い合わせ</a>
</nav>

メニュー項目が5つあると、Tabキーを5回押さないとメニューを通過できません。ページ内にヘッダーメニュー、サイドメニュー、フッターメニューがあれば、メインコンテンツに到達するまでに20回以上のTab操作が必要になることもある。キーボードユーザーにとっては苦行です。

もうひとつのBad Case。

// Bad Case: モーダルなのにフォーカスが外に漏れる
function Modal({ isOpen, onClose, children }) {
  if (!isOpen) return null;
  
  return (
    <div className="modal-overlay">
      <div className="modal-content">
        {children}
        <button onClick={onClose}>閉じる</button>
      </div>
    </div>
  );
}

視覚的にはモーダルが表示されていますが、Tabキーを押すとモーダルの外にフォーカスが出ていきます。背後のリンクやボタンにフォーカスが当たるため、スクリーンリーダーのユーザーは「今どこにいるのか」がわからなくなります。

Roving Tabindex ─ Tab移動はコンテナ間、矢印で項目間

Roving Tabindexは、W3CのARIA Authoring Practices Guide(APG)で推奨されているナビゲーションパターンです。考え方はシンプルです。

  • コンテナ内で現在アクティブな項目だけtabindex="0"にする
  • それ以外の項目はtabindex="-1"にする
  • 矢印キー(↑↓ または ←→)で項目間を移動する
  • Tabキーを押すと、コンテナ全体をスキップして次のUI要素に移る

つまり、5項目のメニューをTabで通過するのに1回で済みます。メニュー内の移動は矢印キー。Tabキーでの大きな移動と、矢印キーでの細かい移動を分離するのが核心です。

APGでは、role="tablist"を持つ要素の中のタブ間移動や、role="menubar"を持つメニューバーのメニュー項目間移動で、このパターンの使用を推奨しています。

React実装

// hooks/useRovingTabindex.ts
import { useRef, useCallback, KeyboardEvent } from 'react';

export function useRovingTabindex(itemCount: number) {
  const activeIndex = useRef(0);
  const itemsRef = useRef<(HTMLElement | null)[]>([]);

  const setItemRef = useCallback(
    (index: number) => (el: HTMLElement | null) => {
      itemsRef.current[index] = el;
    },
    []
  );

  const handleKeyDown = useCallback(
    (e: KeyboardEvent) => {
      let nextIndex = activeIndex.current;

      switch (e.key) {
        case 'ArrowDown':
        case 'ArrowRight':
          e.preventDefault();
          nextIndex = (activeIndex.current + 1) % itemCount;
          break;
        case 'ArrowUp':
        case 'ArrowLeft':
          e.preventDefault();
          nextIndex =
            (activeIndex.current - 1 + itemCount) % itemCount;
          break;
        case 'Home':
          e.preventDefault();
          nextIndex = 0;
          break;
        case 'End':
          e.preventDefault();
          nextIndex = itemCount - 1;
          break;
        default:
          return;
      }

      activeIndex.current = nextIndex;
      itemsRef.current[nextIndex]?.focus();
    },
    [itemCount]
  );

  const getItemProps = useCallback(
    (index: number) => ({
      ref: setItemRef(index),
      tabIndex: index === activeIndex.current ? 0 : -1,
      onKeyDown: handleKeyDown,
    }),
    [setItemRef, handleKeyDown]
  );

  return { getItemProps };
}

使い方はシンプルです。タブUIの例を示します。

// components/TabMenu.tsx
import { useState } from 'react';
import { useRovingTabindex } from '../hooks/useRovingTabindex';

const tabs = ['概要', '料金', '事例', 'FAQ'];

export function TabMenu({
  onSelect,
}: {
  onSelect: (index: number) => void;
}) {
  const [selected, setSelected] = useState(0);
  const { getItemProps } = useRovingTabindex(tabs.length);

  const handleSelect = (index: number) => {
    setSelected(index);
    onSelect(index);
  };

  return (
    <div role="tablist" aria-label="サービス情報">
      {tabs.map((tab, i) => (
        <button
          key={tab}
          role="tab"
          aria-selected={i === selected}
          onClick={() => handleSelect(i)}
          {...getItemProps(i)}
        >
          {tab}
        </button>
      ))}
    </div>
  );
}

role="tablist"role="tab"でスクリーンリーダーに「これはタブUIである」と伝え、aria-selectedで現在選択中のタブを明示しています。キーボード操作はuseRovingTabindexフックが一括管理するため、コンポーネント側のコードは最小限です。

本記事のReactコード例はReact 18以降を前提としています。

Vue実装

// composables/useRovingTabindex.ts
import { ref, type Ref } from 'vue';

export function useRovingTabindex(itemCount: Ref<number>) {
  const activeIndex = ref(0);
  const items = ref<HTMLElement[]>([]);

  function setItemRef(el: HTMLElement | null, index: number) {
    if (el) items.value[index] = el;
  }

  function handleKeyDown(e: KeyboardEvent) {
    let nextIndex = activeIndex.value;

    switch (e.key) {
      case 'ArrowDown':
      case 'ArrowRight':
        e.preventDefault();
        nextIndex = (activeIndex.value + 1) % itemCount.value;
        break;
      case 'ArrowUp':
      case 'ArrowLeft':
        e.preventDefault();
        nextIndex =
          (activeIndex.value - 1 + itemCount.value) %
          itemCount.value;
        break;
      case 'Home':
        e.preventDefault();
        nextIndex = 0;
        break;
      case 'End':
        e.preventDefault();
        nextIndex = itemCount.value - 1;
        break;
      default:
        return;
    }

    activeIndex.value = nextIndex;
    items.value[nextIndex]?.focus();
  }

  return { activeIndex, setItemRef, handleKeyDown };
}
<!-- components/TabMenu.vue -->
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useRovingTabindex } from '~/composables/useRovingTabindex';

const tabs = ['概要', '料金', '事例', 'FAQ'];
const itemCount = computed(() => tabs.length);
const selected = ref(0);

const { activeIndex, setItemRef, handleKeyDown } =
  useRovingTabindex(itemCount);

const emit = defineEmits<{ select: [index: number] }>();

function handleSelect(index: number) {
  selected.value = index;
  emit('select', index);
}
</script>

<template>
  <div role="tablist" aria-label="サービス情報">
    <button
      v-for="(tab, i) in tabs"
      :key="tab"
      role="tab"
      :aria-selected="i === selected"
      :tabindex="i === activeIndex ? 0 : -1"
      :ref="(el) => setItemRef(el as HTMLElement, i)"
      @keydown="handleKeyDown"
      @click="handleSelect(i)"
    >
      {{ tab }}
    </button>
  </div>
</template>

ReactとVueで実装の差はありますが、考え方は同じです。「現在アクティブな項目だけTabで到達できる」という原則をフックまたはcomposableに閉じ込めて、コンポーネントはそれを使うだけ。

Focus Trap ─ モーダル内にフォーカスを閉じ込める

次に、モーダルやダイアログで必要になるFocus Trapです。モーダルが開いている間、フォーカスをモーダル内に閉じ込め、Tabキーで外に出ないようにします。

ARIA Authoring Practices Guide(APG)の「Dialog (Modal)」パターンでは、以下が要件です。

  • モーダルが開いたら、モーダル内の最初のフォーカス可能な要素にフォーカスを移す
  • Tab/Shift+Tabでモーダル内のフォーカス可能な要素を巡回する(外に出ない)
  • Escキーでモーダルを閉じる
  • モーダルを閉じたら、モーダルを開いたトリガー要素にフォーカスを戻す

React実装

// hooks/useFocusTrap.ts
import { useRef, useEffect } from 'react';

const FOCUSABLE_SELECTOR = [
  'a[href]',
  'button:not([disabled])',
  'input:not([disabled])',
  'select:not([disabled])',
  'textarea:not([disabled])',
  '[tabindex]:not([tabindex="-1"])',
].join(', ');

export function useFocusTrap(isActive: boolean) {
  const containerRef = useRef<HTMLDivElement>(null);
  const triggerRef = useRef<HTMLElement | null>(null);

  useEffect(() => {
    if (!isActive || !containerRef.current) return;

    triggerRef.current = document.activeElement as HTMLElement;

    const container = containerRef.current;
    const focusableElements =
      container.querySelectorAll(FOCUSABLE_SELECTOR);
    (focusableElements[0] as HTMLElement)?.focus();

    function handleKeyDown(e: KeyboardEvent) {
      if (e.key !== 'Tab') return;

      const currentFocusable =
        container.querySelectorAll(FOCUSABLE_SELECTOR);
      const first = currentFocusable[0] as HTMLElement;
      const last = currentFocusable[
        currentFocusable.length - 1
      ] as HTMLElement;

      if (e.shiftKey) {
        if (document.activeElement === first) {
          e.preventDefault();
          last?.focus();
        }
      } else {
        if (document.activeElement === last) {
          e.preventDefault();
          first?.focus();
        }
      }
    }

    container.addEventListener('keydown', handleKeyDown);

    return () => {
      container.removeEventListener('keydown', handleKeyDown);
      triggerRef.current?.focus();
    };
  }, [isActive]);

  return { containerRef };
}

モーダルコンポーネントでの使い方です。

// components/Modal.tsx
import { useFocusTrap } from '../hooks/useFocusTrap';

type ModalProps = {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: React.ReactNode;
};

export function Modal({
  isOpen,
  onClose,
  title,
  children,
}: ModalProps) {
  const { containerRef } = useFocusTrap(isOpen);

  if (!isOpen) return null;

  return (
    <div className="modal-overlay" onClick={onClose}>
      <div
        ref={containerRef}
        role="dialog"
        aria-modal="true"
        aria-label={title}
        className="modal-content"
        onClick={(e) => e.stopPropagation()}
        onKeyDown={(e) => {
          if (e.key === 'Escape') onClose();
        }}
      >
        <h2>{title}</h2>
        {children}
        <button onClick={onClose}>閉じる</button>
      </div>
    </div>
  );
}

role="dialog"aria-modal="true"で、スクリーンリーダーにモーダルダイアログであることを伝えます。aria-labelでダイアログのタイトルを提供し、Escキーで閉じられるようにしています。

Vue実装

// composables/useFocusTrap.ts
import {
  ref,
  watch,
  nextTick,
  onUnmounted,
  type Ref,
} from 'vue';

const FOCUSABLE_SELECTOR = [
  'a[href]',
  'button:not([disabled])',
  'input:not([disabled])',
  'select:not([disabled])',
  'textarea:not([disabled])',
  '[tabindex]:not([tabindex="-1"])',
].join(', ');

export function useFocusTrap(isActive: Ref<boolean>) {
  const containerRef = ref<HTMLElement | null>(null);
  let triggerElement: HTMLElement | null = null;
  let cleanup: (() => void) | null = null;

  watch(isActive, async (active) => {
    if (active) {
      triggerElement = document.activeElement as HTMLElement;
      await nextTick();

      const container = containerRef.value;
      if (!container) return;

      const focusable =
        container.querySelectorAll(FOCUSABLE_SELECTOR);
      const first = focusable[0] as HTMLElement;
      const last =
        focusable[focusable.length - 1] as HTMLElement;

      first?.focus();

      function handleKeyDown(e: KeyboardEvent) {
        if (e.key !== 'Tab' || !container) return;

        const currentFocusable =
          container.querySelectorAll(FOCUSABLE_SELECTOR);
        const f = currentFocusable[0] as HTMLElement;
        const l = currentFocusable[
          currentFocusable.length - 1
        ] as HTMLElement;

        if (e.shiftKey) {
          if (document.activeElement === f) {
            e.preventDefault();
            l?.focus();
          }
        } else {
          if (document.activeElement === l) {
            e.preventDefault();
            f?.focus();
          }
        }
      }

      container.addEventListener('keydown', handleKeyDown);
      cleanup = () => {
        container.removeEventListener(
          'keydown',
          handleKeyDown
        );
      };
    } else {
      cleanup?.();
      cleanup = null;
      triggerElement?.focus();
    }
  });

  onUnmounted(() => cleanup?.());

  return { containerRef };
}
<!-- components/AppModal.vue -->
<script setup lang="ts">
import { toRef } from 'vue';
import { useFocusTrap } from '~/composables/useFocusTrap';

const props = defineProps<{
  isOpen: boolean;
  title: string;
}>();

const emit = defineEmits<{ close: [] }>();
const isActive = toRef(props, 'isOpen');
const { containerRef } = useFocusTrap(isActive);
</script>

<template>
  <Teleport to="body">
    <div
      v-if="isOpen"
      class="modal-overlay"
      @click="emit('close')"
    >
      <div
        ref="containerRef"
        role="dialog"
        aria-modal="true"
        :aria-label="title"
        class="modal-content"
        @click.stop
        @keydown.esc="emit('close')"
      >
        <h2>{{ title }}</h2>
        <slot />
        <button @click="emit('close')">閉じる</button>
      </div>
    </div>
  </Teleport>
</template>

VueではTeleportを使ってモーダルをbody直下にレンダリングしています。DOM構造上の位置に関わらず正しいフォーカス管理が行えますが、Teleport越しのフォーカス管理にはひとつ注意が必要です。Teleportで移動した要素は、元のコンポーネントツリーとは異なるDOM位置にあるため、親コンポーネントのイベントリスナーでフォーカスイベントを捕捉できません。Focus TrapのイベントリスナーはTeleport先のコンテナ要素に直接設定する必要があります。上のコードではcontainerRefに対してaddEventListenerしているので、この問題を回避しています。

Roving TabindexとFocus Trapを組み合わせる

実際のプロダクトでは、サイドメニューのように両方が必要になるケースがあります。モバイルではメニューがオーバーレイ表示になり、デスクトップでは常時表示。モバイルのオーバーレイ時にはFocus Trapが必要です。

// components/SideMenu.tsx(React概略)
import { useRovingTabindex } from '../hooks/useRovingTabindex';
import { useFocusTrap } from '../hooks/useFocusTrap';

const menuItems = [
  { id: 'dashboard', label: 'ダッシュボード', href: '/dashboard' },
  { id: 'projects', label: 'プロジェクト', href: '/projects' },
  { id: 'reports', label: 'レポート', href: '/reports' },
  { id: 'settings', label: '設定', href: '/settings' },
];

export function SideMenu({ isOverlay }: { isOverlay: boolean }) {
  const { getItemProps } = useRovingTabindex(menuItems.length);
  const { containerRef } = useFocusTrap(isOverlay);

  return (
    <nav
      ref={isOverlay ? containerRef : undefined}
      role="navigation"
      aria-label="メインメニュー"
    >
      <ul role="menu">
        {menuItems.map((item, i) => (
          <li key={item.id} role="none">
            <a
              href={item.href}
              role="menuitem"
              {...getItemProps(i)}
            >
              {item.label}
            </a>
          </li>
        ))}
      </ul>
    </nav>
  );
}

isOverlayがtrueのとき(モバイル表示時)だけFocus Trapを有効にします。Roving Tabindexはどちらの表示でも常に動作し、メニュー項目間の矢印キーナビゲーションを提供します。

ブラウザ間の挙動差に注意

フォーカス管理の実装で、ブラウザ間の差異にハマりやすいポイントがあります。

SafariのTabキー動作

macOSのSafariは、デフォルト設定ではTabキーでリンクやボタンなどのインタラクティブ要素にフォーカスが移りません。「設定 → 詳細 → アクセシビリティ → Tabキーを押してWebページ上の各項目をハイライト」を有効にする必要があります。開発中は必ずこの設定をオンにして検証してください。

Firefoxのfocusイベントのタイミング

Firefoxでは、focusイベントのタイミングが他のブラウザと微妙に異なることがあります。とくにDOM変更直後のフォーカス移動で、意図した要素にフォーカスが当たらないケースが報告されています。requestAnimationFrameで1フレーム待ってからフォーカスを移す処理を入れると、安定します。

requestAnimationFrame(() => {
  targetElement.focus();
});

aria-modal=”true”のブラウザサポート

aria-modal="true"は、スクリーンリーダーに「このダイアログの外は操作不可」と伝える属性です。ただし、サポート状況はブラウザとスクリーンリーダーの組み合わせにより異なります。a11ysupport.ioなどで最新のサポート状況を確認した上で使用してください。Focus Trapによるキーボードフォーカスの閉じ込めはaria-modalとは独立して動作するため、両方を組み合わせることで幅広い環境をカバーできます。

実装品質の検証チェックリスト

フォーカス管理を実装したら、リリース前に以下のチェックを行います。自動テストだけでは不十分で、手動テストが不可欠です。

キーボード操作

操作期待される動作
Tab次のUI要素グループ(コンテナ)にフォーカス移動
Shift + Tab前のUI要素グループにフォーカス移動
↑↓ または ←→コンテナ内の項目間でフォーカス移動
Homeコンテナ内の最初の項目にフォーカス移動
Endコンテナ内の最後の項目にフォーカス移動
Escモーダル/メニューを閉じ、トリガー要素にフォーカス戻り
Enter / Space選択/実行

スクリーンリーダー確認

  • VoiceOver(macOS/iOS)またはNVDA(Windows)で各コンポーネントを操作する
  • ロール(tab、dialog、menuitemなど)が正しく読み上げられるか
  • 状態(selected、expandedなど)が変化時に通知されるか
  • フォーカス移動時に、移動先の要素の情報が読み上げられるか

モーダル特有の確認

  • モーダルを開いたら最初のフォーカス可能な要素にフォーカスが移るか
  • Tab/Shift+Tabでモーダル外にフォーカスが出ないか
  • Escで閉じた後、開いたボタンにフォーカスが戻るか
  • モーダルが多重に開いた場合、最前面のモーダル内にフォーカスが閉じ込められるか

私たちの開発現場では、このチェックリストをPull Requestのテンプレートに組み込んでいます。コードレビュー時にキーボード操作の確認を必須にしてから、アクセシビリティに関する指摘が減りました。

実際のプロジェクトでの効果

あるBtoB SaaSの管理画面で、Roving TabindexとFocus Trapを導入した結果です。

指標導入前導入後変化
アクセシビリティ監査の指摘件数(フォーカス関連)18件0件全件解消
キーボードのみでの主要操作の完了率64%98%+34pt
フォーカス管理の追加JSサイズ1.2KB(gzip)最小限
外部依存ライブラリ3個0個完全削除

以前はfocus-trapfocus-trap-react@headlessui/reactを使ってフォーカス管理をしていました。これらを自前のフック2つに置き換えた結果、依存関係が減り、バンドルサイズも微減しました。フック自体は合計で約100行なので、メンテナンスコストもほぼかかりません。

クライアントのアクセシビリティ担当者から「キーボードだけで一通り操作できるようになった」と評価をいただき、次フェーズのカラーコントラスト改善にもスムーズに進むことができました。

ARIAロールの誤用に注意

最後に、フォーカス管理の実装で見落としがちなポイントを補足します。ARIAロールを「とりあえず付ける」のは逆効果です。

<!-- Bad Case: divにbuttonロールを付けるなら、最初からbuttonを使う -->
<div role="button" tabindex="0" onclick="handleClick()">
  送信
</div>

<!-- Good Case -->
<button type="button" onclick="handleClick()">
  送信
</button>

ネイティブの<button>要素は、キーボード操作(Enter/Space)、フォーカス管理、スクリーンリーダーへの情報伝達をブラウザが自動で処理します。divrole="button"を付けると、これらすべてを自分で実装する必要があります。ネイティブ要素で代替できる場合は、ARIAロールを付けるよりもネイティブ要素を使うほうが確実です。

W3CのARIA Authoring Practices Guide(APG)にも「No ARIA is better than bad ARIA」(不正確なARIAは、ARIAなしより悪い)という原則が記されています。必要最小限のARIA属性を正確に使うことが、アクセシビリティ改善の近道です。

まとめ

今回は、Roving TabindexとFocus Trapという2つのパターンで、キーボード操作に対応したアクセシブルなUIを最小コードで実装する方法を解説しました。

押さえておきたいポイントは3つです。

  • Roving Tabindexで「Tab移動はコンテナ間、矢印で項目間」に分離する。Tabキーの打鍵回数が大幅に減り、キーボードユーザーの操作効率が上がる
  • Focus Trapでモーダル内にフォーカスを閉じ込め、閉じたらトリガー要素に戻す。「開く→操作→閉じる→元の場所」という予測可能な流れを作る
  • 両パターンとも、ReactではカスタムHook、VueではComposableとして約100行で実装できる。外部ライブラリは不要

アクセシビリティ改善は「大変でコストがかかる」と思われがちですが、フォーカス管理に限って言えば、少ないコードで大きな効果が得られます。実際のプロジェクトでは、フォーカス関連の監査指摘を全件解消し、追加JSはわずか1.2KBでした。まずはこの2つのパターンから始めてみてください。

株式会社ファストコーディングでは、こうしたアクセシビリティ改善やフロントエンドのUI実装に日々取り組んでいます。「キーボード操作に対応したい」「アクセシビリティ監査で指摘を受けた」というお悩みがあれば、お問い合わせフォームからお気軽にご相談ください。