概要
ページネーション UI で「どのページ番号を表示するか」を決めるロジック。全ページ番号を並べると画面に入らないため、省略(ellipsis ...)を使って必要な部分だけ表示する。毎回使う時になったら調べて毎回忘れてなんだっけとなるので。いろいろあると思うけど、1例として捉えていただけるとありがたい。
解決したい問題
ページが 100 ページあるとき、ボタンを 100 個並べるわけにはいかない。
NG: [1] [2] [3] [4] [5] ... [98] [99] [100] ← 100個は無理
ユーザーが知りたいのは 3 つだけ:
- 今どこにいるか → currentPage とその前後
- 最初に戻れるか → 1 ページ目
- 最後まであるか → 最終ページ
設計: 3 グループ + 省略記号
表示するページ番号を 3 グループに分ける:
| グループ | 内容 | 常に表示 |
|---|---|---|
| A: 先頭 | 1 |
○ |
| B: 周辺 |
currentPage - 1 〜 currentPage + 1
|
○ |
| C: 末尾 | totalPages |
○ |
グループ間が離れていたら ...(ellipsis)を挟み、くっついていたら挟まない。
具体例(totalPages = 10)
currentPage = 1: [1] [2] ... [10]
currentPage = 3: [1] [2] [3] [4] ... [10]
currentPage = 5: [1] ... [4] [5] [6] ... [10]
currentPage = 8: [1] ... [7] [8] [9] [10]
currentPage = 10: [1] ... [9] [10]
なぜ start / end が必要か
グループ B(周辺)を単純に currentPage - 1 〜 currentPage + 1 で計算すると、グループ A(先頭)やグループ C(末尾)と重複するケースがある。
currentPage = 1 の場合:
グループ A: [1]
グループ B: [0, 1, 2] ← 0 は存在しない、1 は A と被る
currentPage = 10 の場合(totalPages = 10):
グループ B: [9, 10, 11] ← 11 は存在しない、10 は C と被る
グループ C: [10]
だから境界をガードする:
-
start = Math.max(2, currentPage - 1)→ A(1)と被らないよう 2 以上 -
end = Math.min(totalPages - 1, currentPage + 1)→ C(totalPages)と被らないよう totalPages - 1 以下
ellipsis の出し分け
- A と B の間:
currentPage > 3なら ellipsis(1 と周辺の間にページが抜ける) - B と C の間:
currentPage < totalPages - 2なら ellipsis(周辺と末尾の間にページが抜ける)
なぜ currentPage > 3 か
currentPage = 3 → 周辺は [2, 3, 4]、先頭 [1] と合わせると [1, 2, 3, 4] → 隙間なし
currentPage = 4 → 周辺は [3, 4, 5]、先頭 [1] と合わせると [1, ..., 3, 4, 5] → 2 が抜ける → ellipsis 必要
つまり ...が出るのは currentPageが3より大きい時。
なぜ currentPage < totalPages - 2か
currentPage = 8 → 周辺は [7, 8, 9]、先頭 [1, ...] と合わせると [1, ..., 7, 8, 9, 10] → 隙間なし
currentPage = 7 → 周辺は [6, 7, 8]、先頭 [1, ...] と合わせると [1, ..., 6, 7, 8, ..., 10] → 9 が抜ける → ellipsis 必要
つまり...が出るのは currentPageが8より小さい時であり、この場合8を一般化して
currentPage < 8 = totalPages - 2 ということになる。
totalPages <= 5 のとき
全部表示しても 5 個以下なので省略不要。そのまま [1, 2, 3, 4, 5] を返す。
実装
function generatePageNumbers(
currentPage: number,
totalPages: number
): (number | "ellipsis")[] {
// 5ページ以下なら全部表示
if (totalPages <= 5) {
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
const pages: (number | "ellipsis")[] = [];
// グループ A: 先頭
pages.push(1);
// A-B 間の ellipsis
if (currentPage > 3) {
pages.push("ellipsis");
}
// グループ B: 周辺(A, C と被らないようにクランプ)
const start = Math.max(2, currentPage - 1);
const end = Math.min(totalPages - 1, currentPage + 1);
for (let i = start; i <= end; i++) {
pages.push(i);
}
// B-C 間の ellipsis
if (currentPage < totalPages - 2) {
pages.push("ellipsis");
}
// グループ C: 末尾
pages.push(totalPages);
return pages;
}
まとめ
- ellipsis が必要な理由: 全ページ番号は表示しきれないから
- 3 グループに分ける理由: ユーザーが知りたい情報(現在地・先頭・末尾)を過不足なく見せるため
- start / end が必要な理由: 先頭・末尾グループと周辺グループの重複を防ぐため
今回のパターンだと最低限のpagenationを作るには
- 今のページ
- 合計ページ数
だけあればいけるってことになる。
あとはこの値をbackendから API レスポンスとしてもらうなりすればいける。
自分の場合だと、Railsのkaminari使っているので、 そこからの情報を元にいけた。
おまけ
先ほどのRailsのkaminariからのレスポンスと shadcn/uiの Pagenationを使った例はこんな。
export function PostsPagination({ meta }: PostsPaginationProps) {
const { currentPage, totalPages, nextPage, prevPage } = meta;
if (totalPages <= 1) return null;
const pageNumbers = generatePageNumbers(currentPage, totalPages);
return (
<Pagination>
<PaginationContent>
<PaginationItem>
{prevPage ? (
<PaginationPrevious href={`?page=${prevPage}`} />
) : (
<PaginationPrevious
className="pointer-events-none opacity-50"
aria-disabled="true"
/>
)}
</PaginationItem>
{pageNumbers.map((page, index) =>
page === "ellipsis" ? (
<PaginationItem key={`ellipsis-${index}`}>
<PaginationEllipsis />
</PaginationItem>
) : (
<PaginationItem key={page}>
<PaginationLink
href={`?page=${page}`}
isActive={page === currentPage}
>
{page}
</PaginationLink>
</PaginationItem>
)
)}
<PaginationItem>
{nextPage ? (
<PaginationNext href={`?page=${nextPage}`} />
) : (
<PaginationNext
className="pointer-events-none opacity-50"
aria-disabled="true"
/>
)}
</PaginationItem>
</PaginationContent>
</Pagination>
);
}
見栄えはこんな。
