タブ切り替えは、複数の内容を同じ場所で切り替えて見せるUIです。見た目は簡単に作れますが、<li>にクリックイベントを付けただけでは、キーボードや読み上げソフトで使えません。この記事では、Vue 3(<script setup>)で、キーボード操作にも対応した、実用的なタブUIを作ります。
完成コード:キーボード操作に対応したタブ
タブの情報を配列で持ち、現在選択中のタブのidをrefで管理します。クリックだけでなく、W3CのWAI-ARIAのタブのパターンに沿って、次の構造にしています。
- タブ全体を
role="tablist"、各タブをrole="tab"、内容をrole="tabpanel"にする - 選択中のタブに
aria-selected="true"、それ以外にfalseを付ける - 各タブの
aria-controlsで対応するパネルを指し、パネル側はaria-labelledbyでタブを指す - 左右の矢印キーでタブを移動し、Home / Endで最初と最後に移動する(Home / Endは任意)
- Tabキーでタブ一覧に入ると、選択中のタブにフォーカスが当たり、もう一度押すとパネルへ抜ける
<script setup>
import { ref } from 'vue'
// 各タブに固有の id を持たせる
const tabs = [
{ id: 'overview', label: '概要', content: 'これは概要の内容です。' },
{ id: 'features', label: '機能', content: 'ここでは機能について説明します。' },
{ id: 'contact', label: 'お問い合わせ', content: 'お問い合わせはこちらから。' }
]
const current = ref('overview')
// 矢印キー・Home・End でタブを移動する
function onKeydown(event, index) {
let next = null
if (event.key === 'ArrowRight') next = (index + 1) % tabs.length
else if (event.key === 'ArrowLeft') next = (index - 1 + tabs.length) % tabs.length
else if (event.key === 'Home') next = 0
else if (event.key === 'End') next = tabs.length - 1
if (next === null) return
event.preventDefault()
current.value = tabs[next].id
// v-for の ref は順序が保証されないので、id で要素を探してフォーカスを移す
document.getElementById(`tab-${tabs[next].id}`)?.focus()
}
</script>
<template>
<div class="tabs">
<div role="tablist" aria-label="商品の情報">
<button
v-for="(tab, index) in tabs"
:key="tab.id"
:id="`tab-${tab.id}`"
type="button"
role="tab"
:aria-selected="current === tab.id"
:aria-controls="`panel-${tab.id}`"
:tabindex="current === tab.id ? 0 : -1"
:class="{ active: current === tab.id }"
@click="current = tab.id"
@keydown="onKeydown($event, index)"
>
{{ tab.label }}
</button>
</div>
<!-- 全パネルを DOM に残し、現在のものだけを表示する -->
<div
v-for="tab in tabs"
v-show="current === tab.id"
:key="tab.id"
:id="`panel-${tab.id}`"
role="tabpanel"
:aria-labelledby="`tab-${tab.id}`"
class="tab-panel"
>
{{ tab.content }}
</div>
</div>
</template>
<style scoped>
.tabs {
max-width: 600px;
margin: 0 auto;
}
[role="tablist"] {
display: flex;
border-bottom: 2px solid #ddd;
}
/* button の既定の見た目を、タブとして整える */
[role="tab"] {
padding: 10px 20px;
font: inherit;
cursor: pointer;
background: none;
border: none;
border-bottom: 2px solid transparent;
}
[role="tab"].active {
font-weight: bold;
border-color: #007acc;
}
[role="tab"]:focus-visible {
outline: 2px solid #007acc;
outline-offset: -2px;
}
.tab-panel {
padding: 1rem;
background: #f9f9f9;
border: 1px solid #ddd;
}
</style>
コードのポイント
タブは<li>ではなく<button>にする
<button type="button">にすれば、フォーカスとクリックの基本の動きが標準で手に入ります。<li>では、Tabキーでフォーカスが当たらず、EnterやSpaceでも操作できません。アコーディオンでも同じ考え方です。詳しくはシンプルなアコーディオンを作成する方法をご覧ください。
選択中のタブだけがTabキーで止まる
選択中のタブにtabindex="0"、それ以外にtabindex="-1"を付けています。Tabキーが、タブ一覧の中で止まりすぎず、選択中のタブにフォーカスが当たります。タブ間の移動は、矢印キーで行います。
切り替えは「自動」にしている
矢印キーでタブにフォーカスを移すと、同時にそのタブが選択されます。これは自動アクティベーションと呼ばれ、内容がすぐに表示できる場合に向いています。内容の読み込みが重いときは、EnterやSpaceで決定する「手動」の方式も選べます。
keyはindexではなくid
:keyには、タブごとのidを使います。インデックスをkeyにしない理由はv-forで配列やオブジェクトをループして表示する方法で解説しています。
フォーカスは「id」で探す
矢印キーで次のタブにフォーカスを移すとき、v-forのrefの配列を使うこともできます。ただし、公式が、その配列は元の配列と同じ順序とは限らないと注意しているため、ここではidで要素を探しています。
パネルの表示方法:v-showとv-if
上のコードは、すべてのパネルをDOMに残して、v-showで表示を切り替えます。パネルの中の入力値などの状態が保たれ、aria-controlsが指す要素も常に存在します。パネルの数が多く、中身が重い場合は、v-ifで選択中のパネルだけを描画する方法もあります。違いはv-ifで条件分岐させる方法をご覧ください。
タブの中身を別コンポーネントにする:KeepAlive
タブごとの内容が大きくなったら、コンポーネントに分けます。<component :is="…">で切り替えますが、そのままでは、切り替えのたびにコンポーネントが破棄されて、入力途中のフォームなどの状態が消えます。<KeepAlive>で包むと、非アクティブなコンポーネントがキャッシュされ、状態が保たれます。
<script setup>
import { ref, computed } from 'vue'
import TabOverview from './TabOverview.vue'
import TabContact from './TabContact.vue'
const tabs = [
{ id: 'overview', label: '概要', component: TabOverview },
{ id: 'contact', label: 'お問い合わせ', component: TabContact }
]
const current = ref('overview')
const currentComponent = computed(() => tabs.find(t => t.id === current.value).component)
</script>
<template>
<!-- タブのボタンは、先ほどと同じ -->
<div role="tabpanel" :id="`panel-${current}`" :aria-labelledby="`tab-${current}`">
<!-- KeepAlive で包むと、切り替えても入力途中の状態が保たれる -->
<KeepAlive>
<component :is="currentComponent" />
</KeepAlive>
</div>
</template>
<KeepAlive>には、キャッシュの対象を絞るinclude / excludeと、キャッシュする数の上限maxがあります。キャッシュされたコンポーネントの表示と非表示には、onActivatedとonDeactivatedのフックが呼ばれます。
選択中のタブをURLに持たせる
タブの選択をURLのクエリ(?tab=features)に持たせると、リロードしても選択が消えず、特定のタブを開いたURLを共有できます。Vue Routerのroute.queryと同期するcomputedで、簡単に作れます。タブを切り替えるたびに履歴を増やしたくない場合は、pushではなくreplaceを使います。不正な値のときは、先頭のタブにします。
<script setup>
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
// URL の ?tab=features と同期する。不正な値のときは、先頭のタブにする
const current = computed({
get: () => (tabs.some(t => t.id === route.query.tab) ? route.query.tab : tabs[0].id),
set: (id) => router.replace({ query: { ...route.query, tab: id } })
})
</script>
Vue Routerの基本はルーティングによる画面切り替えをご覧ください。
アニメーションを付ける
パネルの切り替えにフェードなどを付けたい場合は、<Transition>が使えます。基礎はtransitionコンポーネントを使ってアニメーションを実装する方法をご覧ください。
よくある質問(FAQ)
<li>のクリックイベントだけでは、TabキーでもEnterキーでも操作できません。v-ifや<component :is>で切り替えていると、コンポーネントが破棄されるためです。v-showで全パネルを残すか、<KeepAlive>で包んでください。?tab=…)に持たせるのが簡単です。共有できるURLにもなります。保存先をlocalStorageにする方法もありますが、URLを共有したい場合には向きません。まとめ
タブUIは、<button role="tab">とaria-selected、aria-controlsを付け、矢印キーで移動できるようにすると、キーボードと読み上げソフトでも使えます。状態を保ちたいときはv-showか<KeepAlive>を使い、リロードや共有に備えるなら、選択中のタブをURLのクエリに持たせてください。

