Pagination With Ellipsis
by Pratik Rai
Read the full write-upBuild a <Pagination currPage totalPage onPageClick /> component.
The first page, the last page, the current page and its immediate neighbours are always visible. Anywhere numbers are missing between two visible ones, show a single … instead.
Requirements
Always show page 1, the last page, the current page, and the pages either side of the current one.
Collapse any run of missing numbers into one
….Never show a page number below 1 or above
totalPage, and never show a duplicate.Clicking a number calls
onPageClickwith it; the current page is styled with.page-current.The ellipsis is not clickable.
Notes
Expected output:
currPage 1, totalPage 10gives1 2 … 10.currPage 5gives1 … 4 5 6 … 10.currPage 9gives1 … 8 9 10.The control above the component lets you change
totalPage, so try small values — 1, 2 and 3 pages are where naive versions break.
Hints
Pagination With Ellipsis (reference solution)
One way to do it, not the only way. Yours passes if it renders.
Solution files
1import { useState } from 'react';
2
3export default function App() {
4 const [currPage, setCurrPage] = useState(1);
5 const [totalPage, setTotalPage] = useState(10);
6
7 return (
8 <div className="demo">
9 <h1>Pagination</h1>
10
11 <div className="controls">
12 <label>
13 Total pages
14 <input
15 type="number"
16 min="1"
17 value={totalPage}
18 onChange={(e) => {
19 const next = Math.max(1, Number(e.target.value) || 1);
20 setTotalPage(next);
21 setCurrPage((p) => Math.min(p, next));
22 }}
23 />
24 </label>
25 </div>
26
27 <Pagination currPage={currPage} totalPage={totalPage} onPageClick={setCurrPage} />
28
29 <p className="readout">
30 Page {currPage} of {totalPage}
31 </p>
32 </div>
33 );
34}
35
36/**
37 * Which page numbers are always shown, as a sorted list of unique values.
38 *
39 * Deriving the visible set first and inserting gaps afterwards is what keeps
40 * this readable. The alternative — reasoning about ranges and boundaries in one
41 * pass — is where the off-by-ones live.
42 */
43function visiblePages(currPage, totalPage) {
44 const wanted = new Set([1, totalPage, currPage, currPage - 1, currPage + 1]);
45 return [...wanted]
46 .filter((page) => page >= 1 && page <= totalPage)
47 .sort((a, b) => a - b);
48}
49
50function paginationItems(currPage, totalPage) {
51 const pages = visiblePages(currPage, totalPage);
52 const items = [];
53
54 pages.forEach((page, index) => {
55 const previous = pages[index - 1];
56 if (index > 0 && page - previous > 1) {
57 // A single missing number could be shown instead of an ellipsis, but a
58 // gap that reads '… 5 …' is more confusing than one that reads '4 5 6'.
59 items.push({ kind: 'gap', key: 'gap-' + previous });
60 }
61 items.push({ kind: 'page', page, key: 'page-' + page });
62 });
63
64 return items;
65}
66
67function Pagination({ currPage, totalPage, onPageClick }) {
68 const items = paginationItems(currPage, totalPage);
69
70 return (
71 <nav className="pagination" aria-label="Pagination">
72 {items.map((item) =>
73 item.kind === 'gap' ? (
74 <span className="gap" key={item.key} aria-hidden="true">
75 …
76 </span>
77 ) : (
78 <button
79 className={'page' + (item.page === currPage ? ' page-current' : '')}
80 key={item.key}
81 onClick={() => onPageClick(item.page)}
82 aria-current={item.page === currPage ? 'page' : undefined}
83 aria-label={'Page ' + item.page}
84 >
85 {item.page}
86 </button>
87 )
88 )}
89 </nav>
90 );
91}How it works
This looks like a layout problem and is really an off-by-one problem. Almost everyone who reasons about it as ranges — "if current is near the start, show these; near the end, show those; otherwise…" — ends up with three branches and a bug at the boundary between them.
Splitting it into two steps removes the branches. First derive the visible set: page 1, the last page, and the current page with its neighbours, thrown into a Set and clamped to range. Deduplication and clamping happen once, declaratively, and currPage 1 producing [1, 2, 10] needs no special handling. Then insert gaps by walking the sorted list and looking at consecutive differences.
The small totals are the test an interviewer will actually run. With three pages the visible set already covers everything, so no ellipsis appears — not because you handled that case, but because there is no gap to find.
On the markup: numbers are buttons because they are actions, the ellipsis is a span with aria-hidden because it carries no information a screen reader needs, and aria-current="page" is what communicates the current page beyond colour.