DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

The ellipsis truncation algorithm: how a pagination bar stays a fixed width no matter the page count

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));
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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)