本文へスキップ
UI Library

タブ

同じ場所で表示を切り替えるUI。矢印キーでタブ間を移動でき、Tabキーの移動回数はタブが増えても変わりません(roving tabindex)。

components/uilib/Tabs.tsx

'use client'

import { useId, useRef, useState, type ReactNode } from 'react'
import s from './Tabs.module.scss'

export type TabItem = { label: string; panel: ReactNode }

/**
 * タブ。
 *
 * a11y(WAI-ARIA Authoring Practices の tabs パターン):
 *   - role="tablist" / "tab" / "tabpanel" と aria-selected・aria-controls
 *   - roving tabindex: 選択中のタブだけが Tab 移動の対象。タブ間の移動は
 *     矢印キーで行う(タブが増えても Tab の回数が増えない)
 *   - Home / End で先頭・末尾へ
 *   - パネルは tabIndex={0} にして、中に操作要素がなくてもスクロールできるようにする
 */
export function Tabs({ items }: { items: TabItem[] }) {
  const baseId = useId()
  const [active, setActive] = useState(0)
  const tabRefs = useRef<(HTMLButtonElement | null)[]>([])

  const focusTab = (i: number) => {
    setActive(i)
    tabRefs.current[i]?.focus()
  }

  const onKeyDown = (e: React.KeyboardEvent) => {
    const last = items.length - 1
    switch (e.key) {
      case 'ArrowRight':
        focusTab(active === last ? 0 : active + 1)
        break
      case 'ArrowLeft':
        focusTab(active === 0 ? last : active - 1)
        break
      case 'Home':
        focusTab(0)
        break
      case 'End':
        focusTab(last)
        break
      default:
        return
    }
    e.preventDefault()
  }

  return (
    <div className={s.tabs}>
      <div className={s.list} role="tablist" aria-label="表示の切り替え" onKeyDown={onKeyDown}>
        {items.map((item, i) => (
          <button
            key={item.label}
            ref={(el) => {
              tabRefs.current[i] = el
            }}
            className={s.tab}
            type="button"
            role="tab"
            id={`${baseId}-tab-${i}`}
            aria-selected={active === i}
            aria-controls={`${baseId}-panel-${i}`}
            tabIndex={active === i ? 0 : -1}
            onClick={() => setActive(i)}
          >
            {item.label}
          </button>
        ))}
      </div>
      {items.map((item, i) => (
        <div
          key={item.label}
          className={s.panel}
          role="tabpanel"
          id={`${baseId}-panel-${i}`}
          aria-labelledby={`${baseId}-tab-${i}`}
          hidden={active !== i}
          tabIndex={0}
        >
          {item.panel}
        </div>
      ))}
    </div>
  )
}

このページに表示しているのは、実際にこのサイトで動いているソースそのものです。見本用に書き写したコードではありません。スタイルは同じ階層の Tabs.module.scss にあり、色・余白はすべてデザイントークンの CSS カスタムプロパティを参照しています。

States

状態変化もすべて実装済み

押したとき・入力待ちのとき・エラーのとき——実際の運用で必要になる表示は、あとから作り足す必要がないよう最初から含めてお渡しします。

default
hover
focus
active
disabled
loading
error
success