ESC

Type to search the knowledge base.

Pagination Component

Machine-coding brief for pagination — page math, truncated ranges, controlled page, a11y navigation, and edge cases.

intermediate4 min read
  • machine-coding
  • interview
  • react
  • a11y

Problem statement

Build a pagination control: given total items and page size (or total pages), render page links with prev/next and ellipsis truncation. Interviewers score off-by-one math, controlled state, and accessibility of the nav landmark.

Requirements

Must have

  • Controlled page (1-based) + onChange
  • totalPages or compute from totalItems + pageSize
  • Prev / Next disabled at ends
  • Numbered page buttons
  • Truncation with ellipsis for large page counts (window around current)

Should have

  • First / last controls
  • aria-current="page" on active
  • Compact mobile variant (only prev/next + status)

Nice to have

  • Page size selector
  • Jump-to-page input
  • URL sync

Planning (5 minutes out loud)

  1. 1-based page in the API (human-friendly)
  2. Pure function getPageItems(current, total, siblingCount)
  3. MVP — prev/next + all pages if total ≤ 7; then ellipsis algorithm
  4. Empty totalPages edge case
  5. Don’t fetch data inside — pagination is presentational

Architecture

Pagination
├── PrevButton
├── PageButton[] / Ellipsis
└── NextButton

API

type PaginationProps = {
  page: number; // 1-based
  totalPages: number;
  onChange: (page: number) => void;
  siblingCount?: number; // default 1
  disabled?: boolean;
};

Implementation sketch

Range builder

type Item = number | "ellipsis";

export function getPaginationItems(
  page: number,
  totalPages: number,
  siblingCount = 1
): Item[] {
  if (totalPages <= 1) return totalPages === 1 ? [1] : [];

  const totalNumbers = siblingCount * 2 + 5; // first, last, current, 2 ellipsis
  if (totalPages <= totalNumbers) {
    return Array.from({ length: totalPages }, (_, i) => i + 1);
  }

  const left = Math.max(page - siblingCount, 1);
  const right = Math.min(page + siblingCount, totalPages);
  const showLeftEllipsis = left > 2;
  const showRightEllipsis = right < totalPages - 1;

  const items: Item[] = [1];

  if (showLeftEllipsis) items.push("ellipsis");
  else {
    for (let i = 2; i < left; i++) items.push(i);
  }

  for (let i = left; i <= right; i++) {
    if (i !== 1 && i !== totalPages) items.push(i);
  }

  if (showRightEllipsis) items.push("ellipsis");
  else {
    for (let i = right + 1; i < totalPages; i++) items.push(i);
  }

  if (totalPages > 1) items.push(totalPages);

  // Dedupe / fix edge overlaps for small windows near ends
  return uniquePreserve(items);
}

function uniquePreserve(items: Item[]): Item[] {
  const out: Item[] = [];
  for (const it of items) {
    if (it === "ellipsis") {
      if (out[out.length - 1] !== "ellipsis") out.push(it);
      continue;
    }
    if (!out.includes(it)) out.push(it);
  }
  return out;
}

Simpler interview variant: always show 1 … current-1 current current+1 … last with clamps — easier to get right under time pressure.

Component

function Pagination({
  page,
  totalPages,
  onChange,
  siblingCount = 1,
  disabled,
}: PaginationProps) {
  if (totalPages <= 0) return null;
  const items = getPaginationItems(page, totalPages, siblingCount);

  return (
    <nav aria-label="Pagination">
      <button
        type="button"
        aria-label="Previous page"
        disabled={disabled || page <= 1}
        onClick={() => onChange(page - 1)}
      >
        Prev
      </button>
      <ul>
        {items.map((item, i) =>
          item === "ellipsis" ? (
            <li key={`e-${i}`} aria-hidden>
              …
            </li>
          ) : (
            <li key={item}>
              <button
                type="button"
                aria-label={`Page ${item}`}
                aria-current={item === page ? "page" : undefined}
                disabled={disabled}
                onClick={() => onChange(item)}
              >
                {item}
              </button>
            </li>
          )
        )}
      </ul>
      <button
        type="button"
        aria-label="Next page"
        disabled={disabled || page >= totalPages}
        onClick={() => onChange(page + 1)}
      >
        Next
      </button>
    </nav>
  );
}

Deriving totalPages

const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));

When totalItems === 0, product choice: hide pagination or show page 1 empty state.

Accessibility essentials

  • Wrap in <nav aria-label="Pagination">
  • aria-current="page" on current
  • Buttons disabled at ends (not just visually grey)
  • Ellipsis not focusable
  • Announce page change in the results region (parent’s job): Showing 21–40 of 390

Performance notes

  • Pure function; memo if parent re-renders often
  • No virtualization concerns

Footguns

  1. 0-based vs 1-based mismatch with API
  2. Duplicate page numbers near edges in ellipsis math
  3. totalPages 0 division issues
  4. Stale page when filters shrink totalPages — parent should clamp
  5. Using links without href strategy when SEO needs real URLs — prefer <a href="?page="> for MPA

Interview out-loud answer

Pagination is controlled page state with a pure range builder that inserts ellipses. Prev/next disable at ends; current page gets aria-current. Data fetching stays in the parent with cursor or offset APIs. I’d clamp page when totalPages shrinks after filtering and keep the math unit-tested with table cases.

Further reading