AniUI Academy

Build pagination with ellipsis

hardUI Components+240 XP

Build pagination for a fixed 10 pages. Printing all 10 page buttons at once doesn't scale — collapse the middle into an ellipsis instead, always keeping the first page, the last page, and the pages immediately around the current one visible.

Prev/Next move one page at a time and disable themselves at the start and end.

Examples

// page = 1, total = 10
// Rendered: [Prev] 1 2 … 10 [Next]
//   Prev disabled, Next enabled.

// Click page-5
// Rendered: [Prev] 1 … 4 5 6 … 10 [Next]

// Click page-10
// Rendered: [Prev] 1 … 9 10 [Next]
//   Next disabled.

First + last + a 3-wide window around the current page; the gaps become ellipses.

function getPageItems(page, total) {
  const items = [];
  const window = new Set(
    [1, total, page - 1, page, page + 1]
      .filter((n) => n >= 1 && n <= total)
  );
  const sorted = [...window].sort((a, b) => a - b);
  let prev = null;
  for (const n of sorted) {
    if (prev !== null && n - prev > 1) items.push({ type: "ellipsis" });
    items.push({ type: "page", value: n });
    prev = n;
  }
  return items;
}

Build a sorted set of visible pages, then walk it and insert an ellipsis wherever adjacent values differ by more than 1.

What it has to do

  • A `data-testid="current-page"` element shows the active page number as plain text, starting at 1.
  • A `data-testid="prev"` and `data-testid="next"` button each; `prev` is `disabled` on page 1, `next` is `disabled` on page 10.
  • Each visible page number is a button `data-testid="page-N"` (e.g. `page-1`, `page-10`).
  • Skipped ranges render as `data-testid="ellipsis"` — with 10 total pages, not every page number can be on screen at once.
  • Clicking any visible page button jumps straight to that page.

Notes

  • Treat page as the single source of truth; derive the window during render. Storing "which buttons are visible" separately drifts out of sync the moment the window logic grows.
  • Clamp `setPage` with `Math.max(1, Math.min(total, next))` even though the buttons are disabled — keyboard, URL params, or external callers can set out-of-range.
  • Use `aria-current="page"` on the active page button so screen readers announce the current position without relying on color/weight.
  • For URL-driven pagination, read `page` from the query string (`useSearchParams`) and push updates back to it — bookmarkability trumps internal state purity.
Asked atAmazonUberShopify

Stuck? The react course covers the ideas this problem is built on.

import { useState } from "react";

const TOTAL_PAGES = 10;

export default function App() {
  const [page, setPage] = useState(1);

  // TODO: for 10 pages this renders every button — implement windowing
  // instead: always show page 1 and page 10, the current page and its
  // immediate neighbors, and a "..." for whatever is skipped in between.
  const pageNumbers = Array.from({ length: TOTAL_PAGES }, (_, i) => i + 1);

  return (
    <div style={{ fontFamily: "sans-serif", padding: 16 }}>
      <p>Page: <span data-testid="current-page">{page}</span></p>
      <nav data-testid="pagination" style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
        <button
          data-testid="prev"
          disabled={page === 1}
          onClick={() => setPage((p) => Math.max(1, p - 1))}
        >
          Prev
        </button>
        {pageNumbers.map((n) => (
          <button
            key={n}
            data-testid={`page-${n}`}
            aria-current={n === page ? "page" : undefined}
            onClick={() => setPage(n)}
            style={{ fontWeight: n === page ? "bold" : "normal" }}
          >
            {n}
          </button>
        ))}
        <button
          data-testid="next"
          disabled={page === TOTAL_PAGES}
          onClick={() => setPage((p) => Math.min(TOTAL_PAGES, p + 1))}
        >
          Next
        </button>
      </nav>
    </div>
  );
}

5 tests · +240 XP

Discussion

Sign in to post
Sign in to join the conversation.Sign in

No comments yet. Be the first to say something.