Every table footer, search-results strip and comment feed uses the same control: a long list cut into pages with a compact bar to move between them. The interesting part isn't the buttons — it's the 1 … 4 5 [6] 7 8 … 20 truncation that keeps the bar a fixed width even at ten thousand pages. Let me walk through building it from scratch, no framework.
The whole control is two numbers
The real state is page and pageSize. Everything else is derived, never stored, so the table and the button bar can never disagree.
const state = { page: 6, pageSize: 8 };
const totalPages = () => Math.max(1, Math.ceil(DATA.length / state.pageSize));
const clamp = p => Math.max(1, Math.min(totalPages(), p));
Rendering a page is one Array.slice: page 6 at size 8 is indices 40–47, i.e. (page-1)*size up to page*size. The "Showing 41–48 of 160" label falls out of the same offsets.
Why the naive bar fails
The obvious first cut draws one button per page. Fine at ten pages; a disaster at a thousand — the bar wraps line after line and stops being a control. That single failure — fixed width, unbounded data — is the entire reason the ellipsis pattern exists.
The window: first, last, and neighbours
Truncation keeps only three things: the boundaries (page 1 and page N, always), and a window of siblings pages on each side of the current page. The subtle part is the clamps — near the start or end the window is shifted inward so it never runs off an edge and the visible count stays steady.
const range = (a, b) => { const out = []; for (let i = a; i <= b; i++) out.push(i); return out; };
// window bounds, clamped so they never leave [2 .. total-1]
const startCut = Math.max(Math.min(current - siblings, total - siblings*2 - 3), 3);
const endCut = Math.min(Math.max(current + siblings, siblings*2 + 4), total - 2);
The ellipsis — and the single-gap trick
Between page 1 and the window there may be a gap; likewise between the window and page N. Where a gap exists, emit a …. But there's one polished rule: if the gap is exactly one page — the window starts at page 3, so only page 2 is hidden — print that number instead. An ellipsis hiding a single page wastes the same space it saves.
function buildRange(current, total, siblings) {
const totalNumbers = siblings * 2 + 5;
if (totalNumbers >= total) return range(1, total); // everything fits
const startCut = Math.max(Math.min(current - siblings, total - siblings*2 - 3), 3);
const endCut = Math.min(Math.max(current + siblings, siblings*2 + 4), total - 2);
const leftGap = startCut > 3; // more than one page hidden?
const rightGap = endCut < total - 2;
return [
1,
...(leftGap ? ["dots"] : [2]), // gap of 1 -> show "2"
...range(startCut, endCut), // the window
...(rightGap ? ["dots"] : [total - 1]), // gap of 1 -> show "N-1"
total,
];
}
// buildRange(6, 20, 2) -> [1,"dots",4,5,6,7,8,"dots",20]
Because it's a pure function with no DOM and no side effects, it's trivial to test: feed it page 1, page N, and a middle page and eyeball the arrays.
Rendering, and the two accessibility details that matter
Walk the sequence: a number becomes a real <button> that calls goTo(n); the string "dots" becomes an inert … span marked aria-hidden. The current page gets the .active highlight and aria-current="page" set together, so the visual state and the announced state can never drift apart.
const b = document.createElement("button");
b.textContent = tok;
b.setAttribute("aria-label", "Page " + tok);
if (tok === state.page){
b.classList.add("active");
b.setAttribute("aria-current", "page"); // announced state
}
b.onclick = () => goTo(tok);
Because every page is a real button, Tab and Enter work for free. Add ArrowLeft/ArrowRight to step and Home/End to jump on the nav container. The one catch: re-rendering destroys the button you were on, so after a keyboard move, put focus back on the freshly-drawn active button — never drop the user onto <body>.
Reach for pagination when users need to find a known item or feel oriented in a bounded set (search results, admin tables, archives). Prefer infinite scroll for open-ended discovery feeds. And if pages come from a server, keep the page in the URL (?page=6) so it's shareable and survives a refresh.
Try the live version — drag the row count, widen the window, and watch the ellipsis collapse into a plain number the instant it would hide only one page: https://dev48v.infy.uk/design/day53-pagination.html
Top comments (0)