リストの項目が増えてくると、1つの画面に全部を出すのは見づらく、表示も重くなります。そこで使うのがページネーションです。この記事では、Vue 3(<script setup>)で、computedを使った基本の作り方から、ページ番号の省略、データが変わったときの対処、アクセシビリティ、URLとの連動までを整理します。
表示範囲を computed で計算する
基本の考え方は単純です。全件の配列から、現在のページ番号と1ページの件数をもとに、sliceで表示する範囲を切り出します。総ページ数はMath.ceil(件数 ÷ 1ページの件数)で求めます。切り上げるのは、端数のある最後のページを、1ページとして数えるためです。
- 表示の始点は、
(currentPage - 1) * pageSize - 表示する項目は、
items.slice(start, start + pageSize) - 総ページ数は、
Math.ceil(items.length / pageSize)(0件でも1ページとして扱う)
ページ番号が増えすぎないようにする
ページ番号のボタンを、全ページ分並べる書き方は、ページ数が少ないうちは動きます。しかし、100ページになれば、100個のボタンが並びます。実際のサイトは、現在のページの前後だけを表示し、間を「…」で省略します。ページ番号の並びを返す関数を作りましょう。この関数は、常に最大7個の要素(先頭、末尾、現在とその前後、省略記号)を返します。
// utils/getPageItems.js
// 表示するページ番号の並びを返す。常に最大7個になり、ボタンの数が増えすぎない
// getPageItems(1, 10) → [1, 2, 3, 4, 5, 'ellipsis-right', 10]
// getPageItems(5, 10) → [1, 'ellipsis-left', 4, 5, 6, 'ellipsis-right', 10]
// getPageItems(10, 10) → [1, 'ellipsis-left', 6, 7, 8, 9, 10]
export function getPageItems(current, total, siblings = 1) {
const range = (from, to) => Array.from({ length: to - from + 1 }, (_, i) => from + i)
// 先頭・末尾・現在・左右の省略記号・現在の前後 を並べても収まるなら、全部出す
const maxButtons = siblings * 2 + 5
if (total <= maxButtons) return range(1, total)
const left = Math.max(current - siblings, 1)
const right = Math.min(current + siblings, total)
const showLeftDots = left > 2
const showRightDots = right < total - 1
const edgeCount = 3 + siblings * 2
if (!showLeftDots && showRightDots) {
return [...range(1, edgeCount), 'ellipsis-right', total]
}
if (showLeftDots && !showRightDots) {
return [1, 'ellipsis-left', ...range(total - edgeCount + 1, total)]
}
return [1, 'ellipsis-left', ...range(left, right), 'ellipsis-right', total]
}
完成コード
ページサイズの切り替え、件数とページの表示、ページ番号の省略を含んだ完成版です。
<script setup>
import { ref, computed, watch } from 'vue'
import { getPageItems } from './utils/getPageItems'
// 各項目に、固有の id を持たせる
const items = ref(Array.from({ length: 95 }, (_, i) => ({ id: i + 1, title: `項目${i + 1}` })))
const pageSize = ref(10)
const currentPage = ref(1)
// 0件でも 1 ページとして扱う(ボタンが消えないように)
const totalPages = computed(() => Math.max(1, Math.ceil(items.value.length / pageSize.value)))
const pagedItems = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return items.value.slice(start, start + pageSize.value)
})
const pageItems = computed(() => getPageItems(currentPage.value, totalPages.value))
const rangeText = computed(() => {
if (items.value.length === 0) return '0件'
const start = (currentPage.value - 1) * pageSize.value + 1
const end = Math.min(currentPage.value * pageSize.value, items.value.length)
return `全${items.value.length}件中 ${start}〜${end}件`
})
function goToPage(page) {
if (page < 1 || page > totalPages.value || page === currentPage.value) return
currentPage.value = page
// 移動したら、リストの先頭へ戻す
document.getElementById('list-top')?.scrollIntoView()
}
// 件数が減って、今のページが範囲外になったら、最後のページに戻す
watch(totalPages, (total) => {
if (currentPage.value > total) currentPage.value = total
})
// 1ページの件数を変えたら、1ページ目に戻す
watch(pageSize, () => {
currentPage.value = 1
})
</script>
<template>
<div id="list-top">
<label for="page-size">1ページの件数</label>
<select id="page-size" v-model.number="pageSize">
<option :value="10">10件</option>
<option :value="20">20件</option>
<option :value="50">50件</option>
</select>
<!-- 件数とページを、読み上げソフトにも伝える -->
<p role="status">{{ rangeText }}({{ currentPage }} / {{ totalPages }}ページ)</p>
<ul v-if="pagedItems.length">
<li v-for="item in pagedItems" :key="item.id">{{ item.title }}</li>
</ul>
<p v-else>該当する項目がありません。</p>
<nav aria-label="ページネーション" class="pagination">
<button
type="button"
:aria-disabled="currentPage === 1"
@click="goToPage(currentPage - 1)"
>
前へ
</button>
<template v-for="item in pageItems" :key="item">
<span v-if="typeof item === 'string'" aria-hidden="true">…</span>
<button
v-else
type="button"
:aria-current="item === currentPage ? 'page' : undefined"
:aria-label="`${item}ページ目`"
:class="{ active: item === currentPage }"
@click="goToPage(item)"
>
{{ item }}
</button>
</template>
<button
type="button"
:aria-disabled="currentPage === totalPages"
@click="goToPage(currentPage + 1)"
>
次へ
</button>
</nav>
</div>
</template>
<style scoped>
.pagination { display: flex; flex-wrap: wrap; gap: 0.25rem; align-items: center; margin-top: 1rem; }
.pagination button { padding: 0.4rem 0.8rem; cursor: pointer; }
.pagination button.active { background: #007acc; color: #fff; }
/* 端のボタンは見た目を弱めるが、フォーカスはできる状態のままにする */
.pagination button[aria-disabled="true"] { opacity: 0.5; cursor: not-allowed; }
</style>
コードのポイント
データが変わったときの対処
項目数が減ると(検索で絞り込む、削除する、など)、currentPageがtotalPagesを超えたまま、空のリストが表示されることがあります。たとえば3ページ目にいる状態で絞り込むと、1ページに収まるのに、currentPageは3のままです。totalPagesをwatchして、範囲外になったら戻します。ページサイズを変えたときも、1ページ目に戻します。検索との組み合わせは検索フィルター付きリストの作成をご覧ください。
アクセシビリティ
- ページ移動のまとまりを
<nav aria-label="ページネーション">で囲みます。 - 現在のページに
aria-current="page"を付けます。MDNによると、セットの中の現在のページを示す正式な方法で、1つの要素にだけ付けます。色の違いだけでは、読み上げソフトに伝わりません。 - 端の「前へ」「次へ」は、
disabledではなくaria-disabledにしています。disabledのボタンはフォーカスできませんが、aria-disabledならフォーカスでき、押しても何も起きません(goToPageが範囲外を無視する)。 - 件数とページ番号を
role="status"で伝えます。ページを移動すると、読み上げソフトが新しい内容を知らせます。
keyはindexではなくid
リストのkeyには、項目のidを使います。ページを切り替えるとき、インデックスのkeyでは、同じ位置の要素が再利用され、状態が残る原因になります。理由はv-forで配列やオブジェクトをループして表示する方法で解説しています。computedの基本はcomputedプロパティで動的なデータ処理を行う方法をご覧ください。
現在のページをURLに持たせる
ページ番号をURLのクエリ(?page=3)に持たせると、リロードしても同じページが開き、特定のページのURLを共有できます。currentPageを、route.queryと同期するcomputedにします。タブでも同じ書き方を使っています。詳しくはタブ切り替えUIの作り方をご覧ください。
<script setup>
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
// URL の ?page=3 と currentPage を同期する
const currentPage = computed({
get: () => {
const n = Number(route.query.page)
return Number.isInteger(n) && n >= 1 ? n : 1 // 不正な値は 1 ページ目にする
},
// 履歴をページごとに残したいなら push、残したくなければ replace
set: (page) => router.push({ query: { ...route.query, page } })
})
</script>
クライアント側とサーバー側の使い分け
ここまでの方法は、全件をブラウザに持った上で、sliceで分ける「クライアント側」のページネーションです。数百件程度なら問題ありませんが、数万件になると、最初の取得と描画が重くなります。その場合は、表示するページ分だけをサーバーから取得する、サーバー側のページネーションにします。
// サーバー側でページを分ける場合:表示するページ分だけを取得する
const res = await fetch(`/api/items?page=${currentPage.value}&limit=${pageSize.value}`)
const { items, total } = await res.json() // total は、全件数
// totalPages は、取得した全件数 total から計算する
// const totalPages = Math.max(1, Math.ceil(total / pageSize.value))
ページが変わるたびに通信する場合は、古い応答が新しい結果を上書きしないよう注意が必要です。非同期APIの状態管理パターンをご覧ください。ページを区切らず、スクロールで追加するスタイルなら、無限スクロールを実装する方法が参考になります。
よくある質問(FAQ)
getPageItemsのような関数で、ページ番号の並びを作ります。currentPageが、絞り込み後の総ページ数を超えている可能性があります。totalPagesをwatchして、範囲外になったら、最後のページに戻してください。aria-current="page"を付けてください。1つの要素にだけ付けます。まとめ
ページネーションは、computedで表示範囲を切り出すだけなら簡単です。実用にするには、ページ番号の省略、データが減ったときのcurrentPageの補正、aria-currentなどのアクセシビリティ、そしてデータ量に応じたクライアント側とサーバー側の使い分けまで、考えておきましょう。

