How to Design a Website Pagination Component: UX Patterns, Numbering, and CSS Examples

by | Nov 2, 2026 | Uncategorized | 0 comments

Pagination design is one of those components everyone copies from a framework and nobody really thinks about. Yet it quietly decides how deep visitors go into your catalogue, how fast your pages load, and whether Google ever discovers product number 412 on page 9.

This guide compares the three dominant patterns (numbered pagination, Load More buttons and infinite scroll), explains which one fits blogs, e-commerce category pages and search results, and gives you accessible markup, keyboard behaviour, mobile sizing rules and clean CSS you can paste into a project today.

What does pagination mean?

Pagination is the design pattern that splits a large collection of items (posts, products, results, table rows) into smaller chunks served across several pages or batches, with controls that let the user move between them. In practice it covers two separate layers:

  • The interface layer: the visible pagination bar, the Previous / Next arrows, the page numbers, the Load More button.
  • The data layer: how your server fetches the right slice of records (offset-based or cursor-based queries).

Good pagination design keeps both layers aligned: what the user clicks must map to a real, shareable, crawlable state.

What are the two main types of pagination?

Depending on who is asking, “two main types” means two different things. Here is the short answer to both:

Layer Type 1 Type 2
Interface Explicit pagination (numbered pages, Prev/Next) Progressive loading (Load More button, infinite scroll)
Data / backend Offset pagination (LIMIT / OFFSET, needs a total count) Cursor or keyset pagination (“give me the 20 items after this key”)

Offset pagination is what lets you render “Page 7 of 42”. Cursor pagination is faster on huge datasets and never duplicates or skips rows when the list changes while the user browses, but it cannot easily show page numbers. The piece Tailwind CSS Pagination makes a good next read.

pagination buttons

Numbered pagination vs Load More vs infinite scroll

Before choosing, compare the three patterns against the criteria that actually matter in production.

Criterion Numbered pagination Load More button Infinite scroll
Sense of position / total volume Excellent Medium Poor
Shareable / bookmarkable URL Yes, by default Yes, if you push history states Hard to keep reliable
Return-to-list after a click-through Reliable Needs scroll restoration work Often broken, classic frustration
Access to the footer Always Always Footer becomes unreachable
Crawlability for SEO Best (real <a href> links) Good if the button is a link fallback Weak without paginated URLs behind it
Perceived browsing flow Interrupted Smooth, user stays in control Smoothest
Memory / performance risk Low Medium High on long sessions and old phones
Accessibility effort Low Medium (focus + live region) High, easy to get wrong

The hybrid that usually wins

The most robust pattern in 2026 is not a pure one. It is Load More on top of real paginated URLs: each batch corresponds to ?page=2, ?page=3, the button is progressively enhanced from a standard link, and the URL is updated with the History API as the user advances. You get the fluidity of progressive loading plus the crawlability and shareability of numbered pages.

Which pattern fits which page type?

Blog archives and news listings

Readers scan headlines, they rarely need item 300. Both numbered pagination and Load More work well.

  • Recommended: numbered pagination with a visible “Page 3 of 18” label, 10 to 15 posts per page.
  • Add Prev/Next arrows with the real post titles on desktop when you have few pages.
  • Keep the pagination bar above the footer and below the last excerpt, never floating.

E-commerce category pages

This is where pagination design directly touches revenue. Shoppers compare, leave, come back, filter, sort and share links.

  • Recommended: Load More button for the first batches, then a numbered pagination bar at the bottom of the list.
  • Always expose the total: “Showing 24 of 318 products”. Volume reassures and justifies filtering.
  • Offer a per-page control (24 / 48 / 96) and remember the choice in the session.
  • Never lose filters and sorting when changing page: they belong in the URL, not only in JavaScript state.
  • Restore scroll position when the user returns from a product page. This single fix often outperforms any redesign.

Search results

Relevance decays fast, so the first page carries most of the value.

  • Recommended: numbered pagination, because users want to judge how many results exist and sometimes jump deep.
  • Show the result count and the active query above the list.
  • Cap the number of reachable pages if your index is huge, and nudge toward refining the query instead.

Dashboards and data tables

Tables need precision, not flow. Numbered pagination plus a page-size selector is the only sane choice. If your dataset is massive and the count query is expensive, use cursor pagination with Previous / Next only and disable the “last page” shortcut. A pagination bar without counts is perfectly acceptable as long as the disabled states are obvious.

pagination buttons

Anatomy of a clean pagination bar

A complete component usually contains:

  1. Previous control, disabled (not hidden) on the first page.
  2. First page, always visible so users can reset quickly.
  3. A truncation ellipsis when the range is cut.
  4. A sliding window of pages around the current one, typically current minus 1 to current plus 1 on mobile, minus 2 to plus 2 on desktop.
  5. The current page, visually distinct and non-clickable.
  6. Last page, so the depth of the list is immediately readable.
  7. Next control, disabled on the last page.
  8. Optional: a context label such as “Page 4 of 18” or “49 to 72 of 318”.

A numbering formula that never breaks

Pick a fixed number of slots (7 on desktop, 5 on mobile) and always fill them. That way the bar never changes width while browsing, which avoids layout shift:

Page 1 of 42   : 1 2 3 ... 42
Page 5 of 42   : 1 ... 4 5 6 ... 42
Page 41 of 42  : 1 ... 40 41 42
Page 3 of 4    : 1 2 3 4

Rules of thumb: never render an ellipsis that hides a single page (show the page instead), and never let the component collapse to one element when the total is 1 page, just hide it.

Accessible pagination markup

Semantics do most of the accessibility work for you. Use a <nav> landmark, a list, and real links.

<nav class="pagination" aria-label="Blog pagination">
  <ul class="pagination__list">
    <li>
      <a class="pagination__link pagination__link--arrow"
         href="/blog/page/3/" rel="prev">
        <span aria-hidden="true">&lsaquo;</span>
        <span class="pagination__label">Previous</span>
      </a>
    </li>

    <li><a class="pagination__link" href="/blog/" aria-label="Page 1">1</a></li>
    <li><span class="pagination__dots" aria-hidden="true">&hellip;</span></li>
    <li><a class="pagination__link" href="/blog/page/3/" aria-label="Page 3">3</a></li>

    <li>
      <a class="pagination__link is-current" href="/blog/page/4/"
         aria-current="page" aria-label="Page 4, current page">4</a>
    </li>

    <li><a class="pagination__link" href="/blog/page/5/" aria-label="Page 5">5</a></li>
    <li><span class="pagination__dots" aria-hidden="true">&hellip;</span></li>
    <li><a class="pagination__link" href="/blog/page/18/" aria-label="Page 18">18</a></li>

    <li>
      <a class="pagination__link pagination__link--arrow"
         href="/blog/page/5/" rel="next">
        <span class="pagination__label">Next</span>
        <span aria-hidden="true">&rsaquo;</span>
      </a>
    </li>
  </ul>
</nav>

Key points:

  • aria-label on the nav distinguishes several pagination bars on the same page (top and bottom).
  • aria-current="page" is the only correct way to announce the active page. A CSS class is not enough.
  • Disabled arrows should be rendered as <span aria-disabled="true"> rather than a dead link, or simply omitted from the tab order.
  • Bare numbers are poor link text for screen readers, hence the aria-label="Page 3".
  • Use real href values even in a SPA so middle-click, ctrl-click and crawlers keep working.

Keyboard navigation checklist

  1. Every control is reachable with Tab in visual order, left to right.
  2. Enter activates a link, Space activates a button. Do not swap them.
  3. Focus is never invisible: give focus-visible a 2px outline with an offset, not just a colour change.
  4. After a Load More click, move focus to the first newly inserted item so keyboard users are not sent back to the top.
  5. If you add a “jump to page” input, it must submit on Enter and the button must be disabled while the field is empty or equal to the current page.

Mobile sizing rules

  • Minimum touch target of 44 x 44 px, 48 px is more comfortable.
  • At least 8 px of gap between targets to avoid mis-taps on adjacent page numbers.
  • Reduce the window to 5 slots and hide the “Previous / Next” text labels, keeping only the chevrons with an accessible name.
  • Centre the bar and allow wrapping instead of horizontal scrolling.
  • Keep the thumb zone in mind: on long category pages, the bottom bar is the one that gets used.
pagination buttons

CSS for a clean pagination bar

Flexbox plus custom properties, no framework required. Drop this into your stylesheet and adjust the tokens.

.pagination {
  --pg-size: 2.75rem;        /* 44px touch target */
  --pg-gap: 0.5rem;
  --pg-radius: 0.5rem;
  --pg-border: #d9dde3;
  --pg-text: #1f2933;
  --pg-accent: #c8102e;
  margin: 2.5rem 0;
}

.pagination__list {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  align-items: center;
  gap: var(--pg-gap);
  margin: 0;
  padding: 0;
  list-style: none;
}

.pagination__link {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: var(--pg-size);
  height: var(--pg-size);
  padding: 0 0.75rem;
  border: 1px solid var(--pg-border);
  border-radius: var(--pg-radius);
  color: var(--pg-text);
  font-variant-numeric: tabular-nums;
  text-decoration: none;
  line-height: 1;
  transition: background-color .15s ease, border-color .15s ease;
}

.pagination__link:hover {
  background: #f3f5f8;
  border-color: #b9c0c9;
}

.pagination__link:focus-visible {
  outline: 2px solid var(--pg-accent);
  outline-offset: 2px;
}

.pagination__link.is-current {
  background: var(--pg-accent);
  border-color: var(--pg-accent);
  color: #fff;
  font-weight: 700;
  pointer-events: none;
}

.pagination__link[aria-disabled="true"] {
  opacity: .45;
  pointer-events: none;
}

.pagination__dots {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: var(--pg-size);
  height: var(--pg-size);
  color: #78828d;
}

@media (max-width: 600px) {
  .pagination { --pg-size: 3rem; }
  .pagination__label { 
    position: absolute;
    width: 1px; height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }
}

@media (prefers-reduced-motion: reduce) {
  .pagination__link { transition: none; }
}

font-variant-numeric: tabular-nums is a small detail that makes a big difference: all digits take the same width, so the bar stops jittering between page 1 and page 11.

A compact “Prev / Next only” variant

Useful for cursor-based APIs, article footers and data tables without a total count:

.pagination--simple .pagination__list {
  justify-content: space-between;
  width: 100%;
}
.pagination--simple .pagination__link {
  padding: 0 1.25rem;
  font-weight: 600;
}

Building an accessible Load More button

The button must be a real <button>, it must announce progress, and it must disappear (or turn into an “end of list” message) when there is nothing left.

<ul id="product-grid" class="grid">...</ul>

<p class="load-more__status" role="status" aria-live="polite">
  Showing 24 of 318 products
</p>

<button type="button" class="load-more" data-next-page="2">
  Load 24 more products
</button>

<noscript>
  <a href="?page=2">Go to page 2</a>
</noscript>
const btn = document.querySelector('.load-more');
const grid = document.getElementById('product-grid');
const status = document.querySelector('.load-more__status');

btn.addEventListener('click', async () => {
  const page = Number(btn.dataset.nextPage);
  btn.disabled = true;
  btn.textContent = 'Loading...';

  const res = await fetch(`/api/products?page=${page}`);
  const data = await res.json();

  const firstNew = data.items.length ? grid.children.length : null;
  grid.insertAdjacentHTML('beforeend', data.html);

  history.replaceState(null, '', `?page=${page}`);
  status.textContent = `Showing ${grid.children.length} of ${data.total} products`;

  if (firstNew !== null) {
    const target = grid.children[firstNew];
    target.setAttribute('tabindex', '-1');
    target.focus({ preventScroll: true });
  }

  if (page >= data.totalPages) {
    btn.remove();
  } else {
    btn.disabled = false;
    btn.dataset.nextPage = page + 1;
    btn.textContent = 'Load 24 more products';
  }
});

The role="status" region is what makes this pattern accessible: screen reader users hear that new items arrived instead of silently wondering if the click did anything.

pagination buttons

If you really need infinite scroll

Infinite scroll is appropriate for feeds that have no natural end and no decision to make: social timelines, image walls, media galleries. For anything transactional, it costs more than it gives. If you deploy it anyway:

  • Use an IntersectionObserver sentinel, never a scroll event listener.
  • Keep a sticky footer link or move footer content into a side panel, otherwise key links become unreachable.
  • Update the URL as sections scroll into view so a refresh does not reset the session.
  • Stop automatic loading after 3 or 4 batches and switch to a manual button. This single rule removes most of the frustration.
  • Virtualise the list if you expect hundreds of rendered nodes, or memory use will tank on mid-range phones.
  • Provide a visible skip link to bypass the feed for keyboard users.

Pagination and SEO: what still matters

  1. Every page must have its own crawlable URL with a real <a href>. Buttons with a JavaScript click handler are not links.
  2. Self-referencing canonical on each paginated URL. Do not canonicalise page 5 to page 1, that tells Google to drop the content it contains.
  3. rel="next" and rel="prev" are no longer used by Google as an indexing signal, but they stay useful for other crawlers and assistive technology. Harmless to keep.
  4. Avoid “view all” pages above a few hundred items, they wreck Core Web Vitals.
  5. Keep internal linking shallow. If a product needs 9 clicks to reach, it is effectively invisible. Better facets and sorting beat deeper pagination.
  6. Do not index filtered combinations endlessly. Decide which facet URLs deserve indexing and block the rest.
  7. Titles and meta descriptions can include “Page 2” to avoid duplicates, but keep the primary keyword first.

Pagination in WordPress

If your site runs on WordPress, you rarely need to build the markup from scratch. paginate_links() with 'type' => 'list' outputs a <ul> you can wrap in a <nav aria-label="...">, and 'mid_size' plus 'end_size' control the sliding window described earlier. Set 'prev_text' and 'next_text' with an inline SVG plus a visually hidden label, then style it with the CSS above. For WooCommerce, filter woocommerce_pagination_args rather than overriding the whole template.

pagination buttons

Pagination vs lazy loading: not the same thing

These two terms get mixed up constantly.

Pagination Lazy loading
What it splits The dataset, into pages or batches The loading of assets already present in the markup
Triggered by A deliberate user action Proximity to the viewport
Typical use Product lists, archives, tables Images, iframes, below-the-fold widgets
Implementation Server query plus UI component loading="lazy" or an observer

They are complementary: paginate 24 products per page, and lazy load the images of those 24 products. Just remember to keep the first row of images eager with fetchpriority="high" so your Largest Contentful Paint does not suffer.

How to paginate a SQL query

The interface is only half the component. On the data side, two approaches:

Offset pagination, simple and compatible with page numbers:

SELECT id, title, published_at
FROM posts
WHERE status = 'published'
ORDER BY published_at DESC, id DESC
LIMIT 20 OFFSET 60;   -- page 4 with 20 per page

Offset grows slower as the number climbs, because the database still walks the skipped rows. Fine up to a few thousand records.

Keyset (cursor) pagination, fast and stable on large tables:

SELECT id, title, published_at
FROM posts
WHERE status = 'published'
  AND (published_at, id) < ('2026-04-12 10:00:00', 8421)
ORDER BY published_at DESC, id DESC
LIMIT 20;

Always add a tie-breaker column (the primary key) to the ORDER BY, otherwise rows with identical timestamps can appear twice or vanish between batches. Cursor pagination pairs naturally with a Load More button or a Prev/Next bar without counts.

Pagination design checklist before shipping

  • The current page is announced with aria-current="page", not only with colour.
  • Contrast ratio of at least 4.5:1 for numbers, and 3:1 for the borders of the controls.
  • Touch targets of 44 px minimum, with visible spacing.
  • Disabled Prev on page 1, disabled Next on the last page, no dead links.
  • The component is hidden entirely when there is a single page.
  • Filters, sorting and search terms persist through page changes.
  • Scroll position is restored when the user comes back from a detail page.
  • The bar has a fixed slot count so the layout does not shift.
  • URLs are clean, canonical and crawlable.
  • Tested with keyboard only, and with a screen reader at least once.

FAQ about pagination design

How many items should I show per page?

10 to 15 for blog archives, 24 to 48 for product grids (a multiple of your column count so the last row is never ragged), 25 to 50 for data tables. Let users change it on e-commerce and admin screens, and keep their choice. The reasoning is set out in this piece.

Should the pagination bar appear at the top too?

On long data tables, yes. On blogs and category pages, bottom only is usually enough. If you duplicate it, give each <nav> a distinct aria-label such as “Pagination, top” and “Pagination, bottom”.

Is infinite scroll bad for SEO?

Not automatically, but it is risky. Googlebot does not scroll the way a human does, so content that only appears after scroll events may never be crawled. Back the feed with genuine paginated URLs and you keep both the UX and the indexing.

Should I add a “jump to page” input?

Only when the list exceeds roughly 20 pages, which is mostly admin tables and large catalogues. On a 6 page blog archive it adds clutter. When you use one, validate the input range and give clear feedback on invalid values.

What is the ideal number of page links to display?

Seven slots on desktop and five on mobile, including first, last and the ellipsis placeholders. More than that and the bar becomes a wall of digits that nobody scans.

Can I build a pagination bar without a total count?

Yes. Fetch one extra record beyond the page size: if it exists, there is a next page. You then render Previous and Next only, which is exactly what cursor-based APIs expect, and you skip an expensive COUNT query. An in-depth look at it is worth the time.

Need help with your listing pages?

Pagination design looks trivial until it meets real filters, real catalogues and real analytics. If your category pages lose shoppers after the first screen, or your archives are not getting crawled in depth, our team at FatCow Web Design can audit your listing templates and rebuild them around accessible, fast and SEO-friendly components. Get in touch and tell us about your project.

Search Keywords

Recent Posts

Subscribe Now!