CSS Pagination: 16 Examples + Accessible Markup

Most CSS pagination is a nav full of anchors, with CSS handling layout, states and small screens. A compact bar can still fail WCAG 2.2 on size alone, since SC 2.5.8 sets a 24 by 24 CSS pixel minimum at Level AA.

Below are 16 CSS pagination examples to try and copy, then a guide to markup, accessibility and crawlable URLs.

By Bogdan Sandu · Updated October 28, 2026

Numbered pagination styles

Six looks for the classic bar of page numbers. Every one is a nav labelled Pagination around a list of links, with aria-current="page" on the current link and hidden text that turns a bare 4 into Page 4 for screen readers.

Numbered pages with previous and next

  • aria-current
  • container query
  • ellipsis window

Click a page number or an arrow. The demo script only moves aria-current; on a real site every link loads its own URL.

The default pattern: first page, the current page with one neighbour on each side, last page, and a plain-text ellipsis for the skipped runs. The script rebuilds that window around the page you pick. A container query hides the Previous and Next words in narrow spaces but keeps them as hidden text, so the links never lose their names.

View code
.pg-num {
  container-type: inline-size;
  width: min(100%, 560px);
}
.pg-num__list {
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 4px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.pg-num__list > li {
  margin: 0;
  padding: 0;
}
.pg-num__link {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  box-sizing: border-box;
  min-width: 40px;
  height: 40px;
  padding: 0 10px;
  border: 1px solid #d5dae3;
  border-radius: 8px;
  background: #fff;
  color: #1b2433;
  font: inherit;
  font-size: 15px;
  font-weight: 600;
  line-height: 1;
  text-decoration: none;
  font-variant-numeric: tabular-nums;
  transition: background-color .15s ease, border-color .15s ease, color .15s ease;
}
@media (hover: hover) {
  .pg-num__link[href]:hover {
    border-color: #9fb1df;
    background: #eef2fb;
    color: #1b2433;
  }
}
.pg-num__link:focus-visible {
  outline: 3px solid #2f5bd8;
  outline-offset: 2px;
}
.pg-num__link[aria-current="page"],
.pg-num__link[aria-current="page"]:hover {
  border-color: #2f5bd8;
  background: #2f5bd8;
  color: #fff;
}
.pg-num__link:not([href]) {
  border-style: dashed;
  background: #f4f5f8;
  color: #6f7888;
  cursor: default;
}
.pg-num__gap {
  display: grid;
  place-items: center;
  min-width: 24px;
  height: 40px;
  color: #4b5465;
  font-weight: 600;
}
.pg-num__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}
@container (max-width: 520px) {
  .pg-num__list {
    gap: 3px;
  }
  .pg-num__link {
    min-width: 34px;
    padding: 0 4px;
  }
  .pg-num__label {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    overflow: hidden;
    clip: rect(0 0 0 0);
    white-space: nowrap;
  }
}

Pill track with icon arrows

  • border-radius: 999px
  • hidden link text
  • link without href

Click a page number or an arrow. The demo script only moves aria-current; on a real site every link loads its own URL.

All controls sit inside one floating pill, and the current page is a filled teal pill with white text at a 7:1 contrast ratio. The arrows are icons only, so their names come from visually hidden text. On the first and last page the script removes the href, which takes the arrow out of the tab order the same way a span would.

View code
.pg-pill {
  display: flex;
  justify-content: center;
  max-width: 100%;
}
.pg-pill__list {
  display: flex;
  align-items: center;
  gap: 2px;
  margin: 0;
  padding: 5px;
  list-style: none;
  border-radius: 999px;
  background: #fff;
  box-shadow: 0 1px 2px rgba(16, 24, 40, .08), 0 10px 28px rgba(16, 24, 40, .1);
}
.pg-pill__list > li {
  margin: 0;
  padding: 0;
}
.pg-pill__link {
  display: grid;
  place-items: center;
  box-sizing: border-box;
  min-width: 38px;
  height: 40px;
  padding: 0 6px;
  border-radius: 999px;
  color: #344054;
  font: inherit;
  font-size: 15px;
  font-weight: 600;
  line-height: 1;
  text-decoration: none;
  transition: background-color .2s ease, color .2s ease;
}
@media (hover: hover) {
  .pg-pill__link[href]:hover {
    background: #e7f1f0;
    color: #0f5e5a;
  }
}
.pg-pill__link:focus-visible {
  outline: 3px solid #0f5e5a;
  outline-offset: 1px;
}
.pg-pill__link[aria-current="page"],
.pg-pill__link[aria-current="page"]:hover {
  background: #0f5e5a;
  color: #fff;
}
.pg-pill__link:not([href]) {
  color: #8a94a6;
  cursor: default;
}
.pg-pill__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}

Outlined squares with first and last

  • border-width state
  • dashed disabled
  • @container

Click a page number or an arrow. The demo script only moves aria-current; on a real site every link loads its own URL.

An outlined bar with no fills at all. The current page swaps its thin grey border for a 2px ink border and bold text, and disabled arrows switch to a dashed border, so no state relies on color alone. First and last jumps drop out through a container query when the bar runs short of room.

View code
.pg-out {
  container-type: inline-size;
  width: min(100%, 460px);
}
.pg-out__list {
  display: flex;
  justify-content: center;
  gap: 6px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.pg-out__list > li {
  margin: 0;
  padding: 0;
}
.pg-out__link {
  display: grid;
  place-items: center;
  box-sizing: border-box;
  width: 40px;
  height: 40px;
  border: 1.5px solid #b9c1ce;
  border-radius: 6px;
  background: transparent;
  color: #1d2939;
  font: inherit;
  font-size: 15px;
  font-weight: 500;
  line-height: 1;
  text-decoration: none;
  transition: border-color .15s ease, background-color .15s ease;
}
@media (hover: hover) {
  .pg-out__link[href]:hover {
    border-color: #475467;
    background: #fff;
    color: #1d2939;
  }
}
.pg-out__link:focus-visible {
  outline: 3px solid #c2410c;
  outline-offset: 2px;
}
.pg-out__link[aria-current="page"] {
  border: 2px solid #1d2939;
  background: #fff;
  font-weight: 800;
}
.pg-out__link:not([href]) {
  border-style: dashed;
  border-color: #c9cfd9;
  color: #7c8697;
  cursor: default;
}
.pg-out__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}
@container (max-width: 420px) {
  .pg-out__jump {
    display: none;
  }
}

Minimal text links

  • text-decoration-thickness
  • text-underline-offset
  • min-width: 24px

Click a page number or an arrow. The demo script only moves aria-current; on a real site every link loads its own URL.

No boxes, just words and numbers for an editorial layout. The current page gets a thick offset underline and bolder ink, and every link keeps at least a 24 by 24 pixel hit area through padding and min-width, even though nothing visible shows its edges.

View code
.pg-min {
  max-width: 100%;
}
.pg-min__list {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  align-items: center;
  gap: 4px 6px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.pg-min__list > li {
  margin: 0;
  padding: 0;
}
.pg-min__link {
  display: inline-block;
  box-sizing: border-box;
  min-width: 28px;
  padding: 8px 6px 10px;
  border-radius: 4px;
  color: #4a4f57;
  font-family: "Fraunces", Georgia, serif;
  font-size: 19px;
  font-weight: 400;
  line-height: 1.2;
  text-align: center;
  text-decoration: none;
  transition: color .15s ease;
}
.pg-min__word {
  font-family: inherit;
  font-size: 15px;
  font-weight: 600;
  letter-spacing: .01em;
}
@media (hover: hover) {
  .pg-min__link[href]:hover {
    color: #111418;
    text-decoration: underline;
    text-decoration-thickness: 1px;
    text-underline-offset: 6px;
  }
}
.pg-min__link:focus-visible {
  outline: 2px solid #b4410f;
  outline-offset: 2px;
}
.pg-min__link[aria-current="page"],
.pg-min__link[aria-current="page"]:hover {
  color: #111418;
  font-weight: 700;
  text-decoration: underline;
  text-decoration-color: #b4410f;
  text-decoration-thickness: 3px;
  text-underline-offset: 7px;
}
.pg-min__link:not([href]) {
  color: #737a85;
  cursor: default;
}
.pg-min__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}

Dark theme slab with page count

  • dark palette
  • outline ring
  • tabular-nums

Click a page number or an arrow. The demo script only moves aria-current; on a real site every link loads its own URL.

A dark dashboard bar. The current page is a lime square with near-black text, the idle numbers are light grey at more than 10:1 against the slab, and the focus ring is lime as well, so it clears the 3:1 contrast that WCAG asks of focus indicators. The page count line sits outside the list as plain text.

View code
.pg-dark {
  display: grid;
  justify-items: center;
  gap: 12px;
  max-width: 100%;
}
.pg-dark__count {
  margin: 0;
  color: #aab3c2;
  font-size: 14px;
  font-variant-numeric: tabular-nums;
}
.pg-dark__count span {
  color: #f2f5f9;
  font-weight: 700;
}
.pg-dark__list {
  display: flex;
  align-items: center;
  gap: 4px;
  margin: 0;
  padding: 6px;
  list-style: none;
  border: 1px solid #2a3240;
  border-radius: 14px;
  background: #141922;
}
.pg-dark__list > li {
  margin: 0;
  padding: 0;
}
.pg-dark__link {
  display: grid;
  place-items: center;
  box-sizing: border-box;
  width: 36px;
  height: 40px;
  border-radius: 9px;
  color: #d0d7e2;
  font: inherit;
  font-size: 15px;
  font-weight: 600;
  line-height: 1;
  text-decoration: none;
  font-variant-numeric: tabular-nums;
  transition: background-color .15s ease, color .15s ease;
}
@media (hover: hover) {
  .pg-dark__link[href]:hover {
    background: #232b38;
    color: #fff;
  }
}
.pg-dark__link:focus-visible {
  outline: 2px solid #c6f432;
  outline-offset: 2px;
}
@media (max-width: 420px) {
  .pg-dark__list {
    gap: 3px;
    padding: 5px;
  }
  .pg-dark__link {
    width: 33px;
  }
}
.pg-dark__link[aria-current="page"],
.pg-dark__link[aria-current="page"]:hover {
  background: #c6f432;
  color: #0b0f05;
}
.pg-dark__link:not([href]) {
  color: #6f7a8c;
  cursor: default;
}
.pg-dark__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}

Large touch targets with a size overlay

  • :has(:checked)
  • 48px targets
  • WCAG 2.5.8

Targets are 48 by 48 CSS pixels, 44 by 44 on narrow screens.

Click a page number or an arrow. The demo script only moves aria-current; on a real site every link loads its own URL.

Big 48 pixel buttons for touch-first layouts, which drop to 44 on narrow screens. Tick the checkbox to overlay the 24 by 24 square that WCAG 2.5.8 sets as the AA minimum inside each target; 44 by 44 is the stricter AAA figure from 2.5.5. The overlay is pure CSS through :has() on the wrapper.

View code
.pg-big {
  display: grid;
  justify-items: center;
  gap: 14px;
  max-width: 100%;
}
.pg-big__list {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 6px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.pg-big__list > li {
  margin: 0;
  padding: 0;
}
.pg-big__link {
  position: relative;
  display: grid;
  place-items: center;
  box-sizing: border-box;
  width: 48px;
  height: 48px;
  border: 1px solid #cfd5df;
  border-radius: 12px;
  background: #fff;
  color: #1c2534;
  font: inherit;
  font-size: 17px;
  font-weight: 600;
  line-height: 1;
  text-decoration: none;
  transition: background-color .15s ease, border-color .15s ease;
}
@media (hover: hover) {
  .pg-big__link[href]:hover {
    border-color: #1849a9;
    background: #f0f4ff;
    color: #1c2534;
  }
}
.pg-big__link:focus-visible {
  outline: 3px solid #1849a9;
  outline-offset: 3px;
}
.pg-big__link[aria-current="page"],
.pg-big__link[aria-current="page"]:hover {
  border-color: #1849a9;
  background: #1849a9;
  color: #fff;
}
.pg-big__link:not([href]) {
  background: #f2f4f7;
  color: #7a8394;
  cursor: default;
}
@media (max-width: 420px) {
  .pg-big__list {
    gap: 4px;
  }
  .pg-big__link {
    width: 44px;
    height: 44px;
  }
}
.pg-big__link::after {
  content: "";
  position: absolute;
  top: 50%;
  left: 50%;
  width: 24px;
  height: 24px;
  margin: -12px 0 0 -12px;
  box-sizing: border-box;
  border: 1.5px dashed #c01048;
  border-radius: 2px;
  opacity: 0;
  transition: opacity .2s ease;
  pointer-events: none;
}
.pg-big:has(.pg-big__check:checked) .pg-big__link {
  box-shadow: 0 0 0 1px #c01048;
}
.pg-big:has(.pg-big__check:checked) .pg-big__link::after {
  opacity: 1;
}
.pg-big__toggle {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  min-height: 32px;
  color: #1c2534;
  font-size: 15px;
  font-weight: 500;
  cursor: pointer;
}
.pg-big__check {
  width: 20px;
  height: 20px;
  margin: 0;
  accent-color: #c01048;
}
.pg-big__check:focus-visible {
  outline: 3px solid #1849a9;
  outline-offset: 2px;
}
.pg-big__legend {
  margin: 0;
  color: #475467;
  font-size: 13.5px;
  text-align: center;
}
.pg-big__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}

Compact and data pagination

Pagination for small screens and data-heavy views: a phone bar that says Page 3 of 12, a jump-to-page field, a rows-per-page select with range text and a full table footer.

Compact mobile bar: Page 3 of 12

  • flex: 1 buttons
  • position text
  • phone layout

Trail running shoes

143 results, sorted by rating

  • Ridgeline Pro 3Rock plate, 8mm drop$139
  • Fellwalker LiteWide fit, 4mm drop$118
  • Scree Runner GTXWaterproof, 6mm drop$164
  • Moss Trail 2Soft ground, 5mm lugs$97

Use the arrows, the page field, the select or the page numbers.

On a phone, nine numbers do not fit, so the bar keeps two wide Previous and Next links and states the position in words. Both links are 44 pixels tall and stretch to fill the row, which makes them easy thumb targets, and the position text is plain content rather than another control.

View code
.pg-cmp {
  position: relative;
  display: flex;
  flex-direction: column;
  height: 100%;
  overflow: hidden;
  background: #f6f7f4;
  color: #1f2a1f;
}
.pg-cmp__head {
  padding: 22px 18px 12px;
  background: #fff;
  border-bottom: 1px solid #e3e6df;
}
.pg-cmp__title {
  margin: 0;
  color: #1f2a1f;
  font-family: "Fraunces", Georgia, serif;
  font-size: 22px;
  font-weight: 600;
  letter-spacing: -.01em;
}
.pg-cmp__sub {
  margin: 4px 0 0;
  color: #596356;
  font-size: 13.5px;
}
.pg-cmp__results {
  flex: 1;
  display: grid;
  align-content: start;
  gap: 10px;
  margin: 0;
  padding: 14px;
  overflow: hidden;
  list-style: none;
}
.pg-cmp__row {
  display: grid;
  grid-template-columns: 56px 1fr auto;
  align-items: center;
  gap: 12px;
  margin: 0;
  padding: 10px;
  border-radius: 12px;
  background: #fff;
  box-shadow: 0 1px 2px rgba(31, 42, 31, .08);
}
.pg-cmp__thumb {
  width: 56px;
  height: 56px;
  border-radius: 10px;
  background: #dfe8d6;
}
.pg-cmp__thumb--b {
  background: #f2dfcf;
}
.pg-cmp__thumb--c {
  background: #d8e0ec;
}
.pg-cmp__thumb--d {
  background: #ece6c9;
}
.pg-cmp__info {
  display: grid;
  gap: 2px;
  min-width: 0;
}
.pg-cmp__name {
  overflow: hidden;
  color: #1f2a1f;
  font-size: 15px;
  font-weight: 600;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.pg-cmp__meta {
  color: #596356;
  font-size: 13px;
}
.pg-cmp__price {
  color: #1f2a1f;
  font-weight: 700;
}
.pg-cmp__nav {
  padding: 12px 14px 18px;
  border-top: 1px solid #e3e6df;
  background: #fff;
}
.pg-cmp__list {
  display: flex;
  align-items: center;
  gap: 10px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.pg-cmp__list > li {
  margin: 0;
  padding: 0;
}
.pg-cmp__list > li:not(.pg-cmp__status) {
  flex: 1;
}
.pg-cmp__step {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 4px;
  box-sizing: border-box;
  min-height: 44px;
  padding: 0 10px;
  border-radius: 12px;
  background: #2d5a27;
  color: #fff;
  font: inherit;
  font-size: 15px;
  font-weight: 600;
  text-decoration: none;
  transition: background-color .15s ease;
}
@media (hover: hover) {
  .pg-cmp__step[href]:hover {
    background: #1f4419;
    color: #fff;
  }
}
.pg-cmp__step:focus-visible {
  outline: 3px solid #2d5a27;
  outline-offset: 2px;
}
.pg-cmp__step:not([href]) {
  background: #eceee9;
  color: #6d7569;
  cursor: default;
}
.pg-cmp__status {
  color: #3f4a3c;
  font-size: 14px;
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}
.pg-cmp__status strong {
  color: #1f2a1f;
}
.pg-cmp__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}

Jump to page field

  • form + preventDefault
  • aria-invalid
  • role="status"

Use the arrows, the page field, the select or the page numbers.

For long result sets, a number field lets people type the page they want. The form never submits here: the script validates the value, flags an out-of-range entry with aria-invalid and a message tied to the field through aria-describedby, and reports the new page in a status region. On a live site the same form can submit a GET request to the page URL.

View code
.pg-jump {
  display: grid;
  justify-items: center;
  gap: 8px;
  max-width: 100%;
}
.pg-jump__list {
  display: flex;
  align-items: center;
  gap: 8px;
  margin: 0;
  padding: 8px;
  list-style: none;
  border: 1px solid #e4ddd0;
  border-radius: 14px;
  background: #fffdf8;
}
.pg-jump__list > li {
  margin: 0;
  padding: 0;
}
.pg-jump__step {
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  border-radius: 10px;
  background: #f1ebe0;
  color: #3a2f20;
  text-decoration: none;
  transition: background-color .15s ease;
}
@media (hover: hover) {
  .pg-jump__step[href]:hover {
    background: #e6dccb;
    color: #3a2f20;
  }
}
.pg-jump__step:focus-visible,
.pg-jump__go:focus-visible {
  outline: 3px solid #8a4b08;
  outline-offset: 2px;
}
.pg-jump__step:not([href]) {
  background: transparent;
  color: #8f8678;
  cursor: default;
}
.pg-jump__form {
  display: flex;
  align-items: center;
  gap: 6px;
  margin: 0;
}
.pg-jump__label,
.pg-jump__total {
  color: #4d4233;
  font-size: 15px;
  white-space: nowrap;
}
.pg-jump__input {
  box-sizing: border-box;
  width: 58px;
  height: 44px;
  margin: 0;
  padding: 0 6px;
  border: 1.5px solid #b9ad99;
  border-radius: 10px;
  background: #fff;
  color: #2a2218;
  font: inherit;
  font-size: 16px;
  font-weight: 700;
  text-align: center;
  font-variant-numeric: tabular-nums;
  -moz-appearance: textfield;
  appearance: textfield;
}
.pg-jump__input::-webkit-inner-spin-button,
.pg-jump__input::-webkit-outer-spin-button {
  margin: 0;
  -webkit-appearance: none;
}
.pg-jump__input:focus-visible {
  border-color: #8a4b08;
  outline: 3px solid rgba(138, 75, 8, .35);
  outline-offset: 0;
}
.pg-jump--error .pg-jump__input {
  border-color: #b42318;
}
.pg-jump__go {
  height: 44px;
  margin: 0;
  padding: 0 14px;
  border: 0;
  border-radius: 10px;
  background: #8a4b08;
  color: #fff;
  font: inherit;
  font-size: 15px;
  font-weight: 700;
  cursor: pointer;
}
@media (hover: hover) {
  .pg-jump__go:hover {
    background: #6f3c06;
  }
}
.pg-jump__msg {
  min-height: 1.4em;
  margin: 0;
  color: #4d4233;
  font-size: 14px;
  text-align: center;
}
.pg-jump--error .pg-jump__msg {
  color: #b42318;
  font-weight: 600;
}
.pg-jump__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}
@media (max-width: 400px) {
  .pg-jump__list {
    gap: 4px;
    padding: 6px;
  }
  .pg-jump__step {
    width: 40px;
  }
  .pg-jump__go {
    padding: 0 10px;
  }
}

Rows per page with range text

  • select
  • aria-live range
  • tabular-nums

26–50 of 236

Use the arrows, the page field, the select or the page numbers.

The data grid pattern: a page size select, a range such as 26 to 50 of 236, and two arrows. Changing the page size keeps the first visible row on screen instead of jumping back to page 1, and the range sits in a polite live region so screen readers hear the new numbers.

View code
.pg-rows {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: flex-end;
  gap: 10px 20px;
  box-sizing: border-box;
  width: min(100%, 560px);
  padding: 12px 14px;
  border: 1px solid #dfe3ea;
  border-radius: 10px;
  background: #fff;
  color: #2b3442;
  font-size: 14px;
}
.pg-rows__size {
  display: flex;
  align-items: center;
  gap: 8px;
}
.pg-rows__label {
  color: #4a5464;
}
.pg-rows__select {
  box-sizing: border-box;
  height: 36px;
  margin: 0;
  padding: 0 28px 0 10px;
  border: 1px solid #c4cbd6;
  border-radius: 8px;
  background: #fff;
  color: #1d2532;
  font: inherit;
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
}
.pg-rows__select:focus-visible {
  outline: 3px solid #5b3fd6;
  outline-offset: 2px;
}
.pg-rows__range {
  margin: 0;
  color: #4a5464;
  font-variant-numeric: tabular-nums;
}
.pg-rows__range span {
  color: #1d2532;
  font-weight: 700;
}
.pg-rows__list {
  display: flex;
  gap: 4px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.pg-rows__list > li {
  margin: 0;
  padding: 0;
}
.pg-rows__step {
  display: grid;
  place-items: center;
  width: 40px;
  height: 40px;
  border-radius: 50%;
  color: #1d2532;
  text-decoration: none;
  transition: background-color .15s ease;
}
@media (hover: hover) {
  .pg-rows__step[href]:hover {
    background: #efecfc;
    color: #5b3fd6;
  }
}
.pg-rows__step:focus-visible {
  outline: 3px solid #5b3fd6;
  outline-offset: 1px;
}
.pg-rows__step:not([href]) {
  color: #9aa2b0;
  cursor: default;
}
.pg-rows__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}

Load more, feeds and carousel dots

The alternatives to page numbers. Each one still keeps a real address for every chunk of content, announces what changed and puts keyboard focus somewhere sensible.

Load more with a live announcement

  • role="status"
  • focus()
  • href fallback

Press Load more stories until the list is complete.

The control is a link to page 2, so crawlers and visitors without JavaScript still reach a real page. With the script, four stories are appended with createElement, a status region says how many arrived, and focus moves to the first new story so keyboard users carry on where the new content starts instead of staying on a button at the bottom.

View code
.pg-more {
  container-type: inline-size;
  display: grid;
  gap: 14px;
  width: min(100%, 600px);
}
.pg-more__list {
  display: grid;
  gap: 10px;
  margin: 0;
  padding: 0;
  list-style: none;
}
@container (min-width: 480px) {
  .pg-more__list {
    grid-template-columns: 1fr 1fr;
  }
}
.pg-more__item {
  display: grid;
  align-content: start;
  gap: 6px;
  margin: 0;
  padding: 14px 16px;
  border-radius: 12px;
  background: #fff;
  box-shadow: 0 1px 2px rgba(40, 30, 20, .08);
}
.pg-more__link {
  color: #221d18;
  font-family: "Fraunces", Georgia, serif;
  font-size: 17px;
  font-weight: 600;
  line-height: 1.3;
  letter-spacing: 0;
  text-decoration: none;
}
@media (hover: hover) {
  .pg-more__link:hover {
    color: #221d18;
    text-decoration: underline;
    text-underline-offset: 3px;
  }
}
.pg-more__link:focus-visible {
  border-radius: 3px;
  outline: 3px solid #a3330f;
  outline-offset: 3px;
}
.pg-more__meta {
  color: #6a5f55;
  font-size: 13px;
}
.pg-more__status {
  margin: 0;
  color: #4b433b;
  font-size: 14px;
  text-align: center;
}
.pg-more__navlist {
  display: flex;
  justify-content: center;
  margin: 0;
  padding: 0;
  list-style: none;
}
.pg-more__navlist > li {
  margin: 0;
  padding: 0;
}
.pg-more__btn {
  display: inline-flex;
  align-items: center;
  min-height: 46px;
  padding: 0 24px;
  border: 2px solid #221d18;
  border-radius: 999px;
  background: #221d18;
  color: #fff;
  font-size: 15px;
  font-weight: 700;
  text-decoration: none;
  transition: background-color .15s ease, color .15s ease;
}
@media (hover: hover) {
  .pg-more__btn:hover {
    background: #fff;
    color: #221d18;
  }
}
.pg-more__btn:focus-visible {
  outline: 3px solid #a3330f;
  outline-offset: 3px;
}

Scrolling feed with page markers

  • scroll position
  • aria-current
  • chunk anchors

Page 1

  • FixCheckout no longer drops the coupon on refresh
  • NewSaved filters on the orders screen
  • FixDates in exports follow the account time zone
  • NewKeyboard shortcut to duplicate a product

Page 2

  • NewBulk edit for stock levels
  • FixSearch keeps the selected category
  • NewInvoice notes support line breaks
  • FixImage upload retries after a timeout

Page 3

  • NewShipping zones can be copied
  • FixTax column aligns on narrow screens
  • NewLow stock email digest
  • FixGift cards show the remaining balance

Page 4

  • NewOrder timeline in the customer view
  • FixRefund button respects user roles
  • NewCSV import preview
  • FixSorting by price handles sale items

Scroll the feed or pick a page.

An alternative to endless scrolling: the feed scrolls, but every chunk keeps a page marker and its own anchor, and a small pager tracks which chunk is on screen. That mirrors what Google asks of infinite scroll, a distinct address per chunk that updates as the reader moves; a live version would also update the URL with the History API, which this demo leaves alone.

View code
.pg-feed {
  display: grid;
  gap: 10px;
  box-sizing: border-box;
  width: min(100%, 520px);
  padding: 12px;
  border-radius: 16px;
  background: #13212e;
}
.pg-feed__nav {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  padding: 0 4px;
}
.pg-feed__navlabel {
  color: #a9bccd;
  font-size: 13px;
  font-weight: 600;
}
.pg-feed__list {
  display: flex;
  gap: 4px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.pg-feed__list > li {
  margin: 0;
  padding: 0;
}
.pg-feed__link {
  display: grid;
  place-items: center;
  width: 34px;
  height: 34px;
  border-radius: 8px;
  color: #d8e4ee;
  font-size: 14px;
  font-weight: 700;
  text-decoration: none;
  transition: background-color .15s ease, color .15s ease;
}
@media (hover: hover) {
  .pg-feed__link:hover {
    background: #213547;
    color: #fff;
  }
}
.pg-feed__link:focus-visible {
  outline: 2px solid #ffc857;
  outline-offset: 2px;
}
.pg-feed__link[aria-current="page"],
.pg-feed__link[aria-current="page"]:hover {
  background: #ffc857;
  color: #13212e;
}
.pg-feed__scroll {
  position: relative;
  height: 280px;
  overflow-y: auto;
  overscroll-behavior: contain;
  border-radius: 10px;
  background: #fff;
}
.pg-feed__scroll:focus-visible {
  outline: 3px solid #ffc857;
  outline-offset: 2px;
}
.pg-feed__chunk {
  padding: 0 14px 6px;
}
.pg-feed__marker {
  position: sticky;
  top: 0;
  margin: 0 -14px;
  padding: 8px 14px;
  border-bottom: 1px solid #e7ecf1;
  background: #f4f7fa;
  color: #3d4f60;
  font-family: ui-monospace, Consolas, monospace;
  font-size: 12.5px;
  font-weight: 700;
  letter-spacing: .04em;
  line-height: 1.4;
  text-transform: uppercase;
}
.pg-feed__items {
  margin: 0;
  padding: 0;
  list-style: none;
}
.pg-feed__items li {
  display: flex;
  align-items: baseline;
  gap: 10px;
  margin: 0;
  padding: 11px 0;
  border-bottom: 1px solid #eef1f4;
  color: #1c2a36;
  font-size: 14.5px;
  line-height: 1.4;
}
.pg-feed__tag {
  flex: none;
  min-width: 34px;
  padding: 1px 6px;
  border-radius: 5px;
  background: #e6eef6;
  color: #234a6b;
  font-size: 11.5px;
  font-weight: 700;
  text-align: center;
}
.pg-feed__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}

Content navigation patterns

Pagination that is not a row of numbers: older and newer posts on a blog archive, an A to Z index and a right-to-left bar with mirrored arrows.

Older and newer posts for a blog archive

  • rel="prev" / "next"
  • grid-template-areas
  • Fraunces

The archive version of pagination: two big serif links and the position in the middle. Newer posts point to the lower page number, older posts to the higher one, which is how blog archives usually count. In a narrow container the position line drops under the two links instead of squeezing them.

View code
.pg-blog {
  container-type: inline-size;
  box-sizing: border-box;
  width: min(100%, 620px);
  padding: 8px 0;
  border-top: 2px solid #1e1b18;
  border-bottom: 1px solid #cfc8bd;
  background: #faf7f2;
}
.pg-blog__list {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  grid-template-areas: "newer status older";
  align-items: center;
  gap: 8px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.pg-blog__list > li {
  margin: 0;
  padding: 0;
}
.pg-blog__newer {
  grid-area: newer;
}
.pg-blog__older {
  grid-area: older;
  justify-self: end;
}
.pg-blog__status {
  grid-area: status;
  color: #5c544a;
  font-size: 14px;
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}
.pg-blog__link {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  min-height: 44px;
  padding: 6px 10px;
  border-radius: 6px;
  color: #1e1b18;
  font-family: "Fraunces", Georgia, serif;
  font-size: 21px;
  font-weight: 600;
  letter-spacing: -.01em;
  text-decoration: none;
  transition: color .15s ease, background-color .15s ease;
}
.pg-blog__link svg {
  flex: none;
}
@media (hover: hover) {
  .pg-blog__link[href]:hover {
    background: #efe7da;
    color: #8c2f1b;
  }
}
@media (hover: hover) and (prefers-reduced-motion: no-preference) {
  .pg-blog__link svg {
    transition: transform .2s ease;
  }
  .pg-blog__newer .pg-blog__link[href]:hover svg {
    transform: translateX(-3px);
  }
  .pg-blog__older .pg-blog__link[href]:hover svg {
    transform: translateX(3px);
  }
}
.pg-blog__link:focus-visible {
  outline: 3px solid #8c2f1b;
  outline-offset: 2px;
}
.pg-blog__link:not([href]) {
  color: #8a8174;
  cursor: default;
}
@container (max-width: 460px) {
  .pg-blog__list {
    grid-template-columns: 1fr 1fr;
    grid-template-areas: "newer older" "status status";
  }
  .pg-blog__status {
    justify-self: center;
  }
  .pg-blog__link {
    gap: 6px;
    padding: 6px;
    font-size: 17px;
    white-space: nowrap;
  }
}

A to Z index pagination

  • auto-fill grid
  • non-link letters
  • aria-live panel

A glossary paged by letter. Letters with no entries are plain text with a hidden no terms note, so they are skipped by Tab and never lead to an empty page. The grid uses auto-fill columns of at least 36 pixels, which wraps the 26 letters into two rows on desktop and four on a phone.

View code
.pg-az {
  display: grid;
  gap: 14px;
  box-sizing: border-box;
  width: min(100%, 600px);
  padding: 16px;
  border-radius: 16px;
  background: #fff;
  box-shadow: 0 1px 3px rgba(20, 20, 40, .08);
}
.pg-az__list {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(36px, 1fr));
  gap: 4px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.pg-az__list > li {
  margin: 0;
  padding: 0;
}
.pg-az__letter {
  display: grid;
  place-items: center;
  height: 38px;
  border-radius: 8px;
  background: #f1f0fa;
  color: #2b2766;
  font-family: "Fraunces", Georgia, serif;
  font-size: 17px;
  font-weight: 600;
  line-height: 1;
  text-decoration: none;
  transition: background-color .15s ease, color .15s ease;
}
@media (hover: hover) {
  a.pg-az__letter:hover {
    background: #e1def7;
    color: #2b2766;
  }
}
.pg-az__letter:focus-visible {
  outline: 3px solid #4a3fc4;
  outline-offset: 2px;
}
.pg-az__letter[aria-current="page"],
.pg-az__letter[aria-current="page"]:hover {
  background: #2b2766;
  color: #fff;
}
.pg-az__letter--off {
  background: transparent;
  color: #8f8ca8;
  text-decoration: line-through;
  text-decoration-color: #c9c6dd;
}
.pg-az__panel {
  display: flex;
  align-items: flex-start;
  gap: 16px;
  padding-top: 14px;
  border-top: 1px solid #ebeaf3;
}
.pg-az__big {
  margin: 0;
  color: #2b2766;
  font-family: "Fraunces", Georgia, serif;
  font-size: 44px;
  font-weight: 700;
  letter-spacing: 0;
  line-height: 1;
}
.pg-az__terms {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin: 4px 0 0;
  padding: 0;
  list-style: none;
}
.pg-az__terms li {
  margin: 0;
  padding: 5px 11px;
  border-radius: 999px;
  background: #f5f4f9;
  color: #2a2840;
  font-size: 14px;
}
.pg-az__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}

Right-to-left pagination with mirrored arrows

  • dir="rtl"
  • scaleX(-1)
  • logical properties

An Arabic bar with dir="rtl" on the nav. Flexbox already lays the list out from the right, so Previous sits on the right and Next on the left with no extra rules. The chevrons are the only thing CSS has to flip, through scaleX(-1) under [dir="rtl"], and the hidden text reads the page word in Arabic. In a narrow container the Previous and Next words become hidden text as well.

View code
.pg-rtl {
  container-type: inline-size;
  width: min(100%, 520px);
}
.pg-rtl__list {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 6px;
  margin: 0;
  padding: 10px;
  list-style: none;
  border-radius: 14px;
  background: #fbf6ee;
  box-shadow: inset 0 0 0 1px #eadfcd;
}
.pg-rtl__list > li {
  margin: 0;
  padding: 0;
}
.pg-rtl__link {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 4px;
  box-sizing: border-box;
  min-width: 38px;
  height: 40px;
  padding-inline: 8px;
  border-radius: 10px;
  color: #3b2f22;
  font-size: 16px;
  font-weight: 600;
  line-height: 1;
  text-decoration: none;
  transition: background-color .15s ease, color .15s ease;
}
.pg-rtl__step {
  padding-inline: 10px 12px;
  background: #fff;
  box-shadow: inset 0 0 0 1px #e0d3bf;
}
@media (hover: hover) {
  .pg-rtl__link[href]:hover {
    background: #f1e6d4;
    color: #3b2f22;
  }
}
.pg-rtl__link:focus-visible {
  outline: 3px solid #8c2f39;
  outline-offset: 2px;
}
.pg-rtl__link[aria-current="page"],
.pg-rtl__link[aria-current="page"]:hover {
  background: #8c2f39;
  color: #fff;
}
.pg-rtl__link:not([href]) {
  background: transparent;
  box-shadow: none;
  color: #8b7f70;
  cursor: default;
}
@container (max-width: 440px) {
  .pg-rtl__list {
    gap: 4px;
  }
  .pg-rtl__link {
    min-width: 36px;
  }
  .pg-rtl__step {
    padding-inline: 8px;
  }
  .pg-rtl__text {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    overflow: hidden;
    clip: rect(0 0 0 0);
    white-space: nowrap;
  }
}
.pg-rtl[dir="rtl"] .pg-rtl__icon {
  transform: scaleX(-1);
}
.pg-rtl__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}

Which HTML markup does CSS pagination need?

The whole structure is a nav element with aria-label=”Pagination” wrapped around an unordered list. Every page number gets its own list item and an anchor with a real href.

The nav landmark lets screen reader users jump straight to the control, and the list tells them how many items they’re moving through.

<nav aria-label="Pagination">
  <ul class="pagination">
    <li><a href="/blog/?page=1">Previous</a></li>
    <li><a href="/blog/?page=1">1</a></li>
    <li><a href="/blog/?page=2" aria-current="page">2</a></li>
    <li><a href="/blog/?page=3">3</a></li>
    <li><a href="/blog/?page=3">Next</a></li>
  </ul>
</nav>

A typical pagination UI has previous and next links (text labels, since arrow glyphs alone tell a screen reader very little), numbered anchors with real URLs, one link carrying aria-current=”page”, and an ellipsis item that is plain text standing in for skipped pages.

MDN’s pagination recipe (last updated November 2025) follows this skeleton. It adds visually hidden text before each number and puts aria-hidden on the arrow glyphs.

The widely copied W3Schools how-to goes a shorter way: a div, bare anchors with href=”#” and an .active class on the current page. It looks fine. A screen reader hears an unlabeled run of links and has no idea which page is current.

Browsers add bullets to a ul by default. Set the list-style property to none and zero out the margin and padding while you’re at it.

Layout methods for pagination links: flexbox, grid and inline-block

Flexbox is the one to reach for. Put display: flex on the ul to get a row, and on the nav to center that row.

MDN’s pagination recipe nests them the same way, with spacing set through either a margin on the items or a gap on the list.

nav { display: flex; justify-content: center; }
.pagination { display: flex; gap: 4px; list-style: none; margin: 0; padding: 0; }
MethodKey propertiesCenteringWeak point
Flexboxdisplay: flex, gap, flex-wrapjustify-content: centerEqual-width cells need extra rules
Griddisplay: grid, grid-auto-flow: column, gapjustify-content: centerMore setup for a changing link count
Inline-block or floatdisplay: inline-block, float: lefttext-align: center (inline-block only)Whitespace gaps, clearing, no float centering

Grid starts to make sense when every cell has to be the same width. If you want to try track values first, a CSS grid generator saves some trial and error before anything goes into the stylesheet.

The float: left pattern from the W3Schools example still renders fine. You can’t center floats, though, and the container needs a clearing rule.

I’d go with flexbox on anything new and only touch floats when a stylesheet already depends on them.

How to style hover, focus, current and disabled states

Give every state its own selector and keep them separate. The base .pagination a rule carries padding, a border, the text color and a short transition. .pagination a:hover gets a background shift and nothing else. .pagination a:focus-visible gets a clear outline with an offset, and .pagination a[aria-current="page"] gets a filled background with high-contrast text.

Disabled items need different treatment, which the first subsection below covers.

Hover is where people go overboard. W3C’s guidance for SC 1.4.11 says extra hover treatments need no contrast ratio of their own, as long as the control keeps its contrast while hovered.

Wrap hover rules in @media (hover: hover) so touch screens don’t keep a stuck hover after a tap.

For more color and motion ideas, a collection of CSS hover effects is worth browsing. A plain background shift is still the safer pick for page links.

:focus-visible shows the ring when the browser decides focus should be visible, which in practice means keyboard use. MDN lists it as widely available, and as available across browsers since March 2022.

Take the outline away with nothing in its place and keyboard users lose their spot on the page. MDN says as much.

Current page and disabled links

Drive the current-page style from [aria-current="page"], the way MDN’s recipe does with a dark background and white text. One attribute then controls both the look and what assistive technology announces.

A hand-written .active class drifts over time. Someone moves the class and forgets the attribute, or the other way around.

On page 1 the Previous item has nowhere to go. Render it as a span with muted text, so it’s neither a link nor focusable, or drop it from the list on the first and last pages.

Avoid pointer-events: none on a live link. It blocks the mouse and leaves the link reachable by keyboard.

WCAG exempts inactive controls from its contrast rules (SC 1.4.3 and 1.4.11, W3C), so a muted disabled item is allowed. Keep it legible anyway.

Rounded and bordered buttons

MDN’s recipe uses a 1px border and a 0.2em radius on each link. That’s about as plain as a bordered bar gets, and it works.

Round number buttons are popular. They also fail the target size test more often than square ones.

W3C’s Understanding document for SC 2.5.8 points out that rounded corners can leave no room for a 24 by 24 pixel square inside the target. A circle needs a diameter of about 34 CSS pixels (the diagonal of that square) to contain one, unless the spacing exception applies.

Use a border radius generator to preview the shape, then check the finished size.

Making pagination responsive on small screens

Below a breakpoint, hide the middle page numbers and keep previous, next, first, last and the current page. Nine numbers in a row rarely fit a phone width.

The numbers further from the current page go, and an ellipsis item takes their place. It should be text, not a link.

CSS can’t count outward from the current page. So the server tags the numbers far from it with a class (.far in the example below), and the stylesheet decides whether those tagged items show.

.pagination .far { display: none; }
@media (min-width: 640px) {
  .pagination .far { display: block; }
}

The reveal rule sits inside a media query set at the width where the full bar fits.

Write the compact bar as the base style and widen it with min-width queries. That’s the mobile-first approach.

A pure :nth-child() rule can hide a fixed middle block, for example li:nth-child(n+4):nth-last-child(n+4). It also hides the current page whenever that page lands in the middle, so treat it as a prototype trick and keep it out of production.

Don’t shrink padding to cram more numbers in. Squeezed links are exactly how responsive bars end up under the 24 pixel target minimum.

How do you make CSS pagination accessible?

Most of it comes down to markup and a few lines of CSS. Mark the current link with aria-current=”page”, label the nav, keep a visible keyboard focus ring, give every link at least 24 by 24 CSS pixels (or enough spacing around it), and hold the contrast ratios.

Size comes first. Level AA sets the minimum target size at 24 x 24 CSS px, SC 2.5.8 (W3C, WCAG 2.2). There’s also a spacing exception built on a 24 px circle, so 20 px targets with a 4 px gap pass and 20 px targets with no gap fail (W3C, Understanding SC 2.5.8, updated 11 May 2026).

Contrast is the other half. Text needs 4.5:1, or 3:1 when it’s large (W3C, SC 1.4.3), and controls plus their focus or selected states need 3:1 to be identifiable (W3C, SC 1.4.11).

Native elements cover most of the semantics, and ARIA fills the state gap. The attribute aria-current=”page” tells assistive technology which link matches the highlighted page.

Set it on one link only. MDN notes that an unrecognized value is read as true, so a typo still announces “current”.

The reverse fails too. A highlighted link with no attribute announces nothing, and an attribute with no visible style helps screen reader users only.

MDN’s pagination recipe adds a visually hidden “page” before each number and hides arrow glyphs with aria-hidden. A bare “2” and a bare chevron tell a screen reader very little.

The focus ring is a component state, so the 3:1 rule applies to it. W3C’s own failing example is a yellow ring on a white background.

Text in every state needs 4.5:1 against its background, including the filled current-page link. Check the color contrast of each state instead of eyeballing it.

A one-digit link is narrow, and padding does the work. W3C’s sufficient technique C42 sets min-height and min-width on the target container.

The spacing exception is a fallback, not a plan. W3C recommends meeting the size minimum regardless of spacing, and points to the stricter AAA criterion 2.5.5 Target Size (Enhanced) for important controls.

Bootstrap, Tailwind or custom CSS for pagination

Reuse the framework already on the site. Write custom CSS only when a framework would otherwise be added for this one component.

Bootstrap ships the markup contract already: a nav, a ul.pagination, li.page-item and a.page-link, with .active and .disabled sitting on the item. Tailwind has no pagination class at all, so every link carries its own utility classes. With custom CSS you own every selector and breakpoint, and the accessibility checks that come with them.

Bootstrap’s documentation suggests labeling the nav by purpose, such as “Search results pages” for a results list. That beats a generic label when a page carries more than one nav.

Bootstrap added pagination CSS variables in version 5.2.0, and the 5.3 documentation exposes them for the active and disabled colors, so theming rarely needs overrides.

Bootstrap’s 5.0 and 5.2 documentation, and 5.3 up to 5.3.5, places aria-current=”page” on the active li.page-item. From 5.3.6 it sits on the link itself, the way MDN places it, and a span for the current page may omit it.

Disabled items differ too. The 5.0 documentation keeps a dead link (href="#") and adds tabindex="-1" plus aria-disabled="true". The 5.2 and 5.3 documentation drops the href from disabled links, but still advises tabindex="-1" and custom JavaScript, because pointer-events: none does not stop keyboard use. It also allows swapping the anchor for a span, which needs none of that.

Tailwind ships utilities, not components. Pagination arrives as pre-built blocks such as the Tailwind Plus set (page numbers, next and previous buttons, ellipses), as free copy-and-paste Tailwind pagination components, or as hand-built markup like the examples in this collection of Tailwind pagination patterns.

If you’re still choosing a framework for the whole project, the Tailwind vs Bootstrap comparison matters more than this one component.

Pure CSS pagination without JavaScript: :target, radio hack and scroll snap

You can switch visible content with no script through the :target pseudo-class, through hidden radio inputs read with :checked, or through scroll snap. All of them keep every page inside one document.

:target works by giving each panel an id, pointing a link at it with a fragment, and letting the CSS show only the matching panel. MDN’s page on URL fragments notes that the browser scrolls to the target element. The radio hack uses hidden inputs and labels, with a sibling selector showing one panel at a time. Scroll snap locks panels into place through scroll-snap-type and scroll-snap-align, and there’s no page-number state to style.

Without a fragment in the URL, no panel matches :target. The first panel needs its own fallback rule.

The upside is that there’s no script to load or fail, and switching is instant since nothing gets fetched. That suits a small, fixed set of panels.

The downsides pile up quickly. Every page ships in the first response, which cancels the faster initial load and lower network traffic that Google lists as benefits of loading content in increments. Fragments give no unique URL, so all panels share one address. The radio hack leaves no shareable state, and screen readers meet radio inputs where they expect links.

MDN documents :target as fragment-driven styling. Google’s pagination documentation (last updated 10 December 2025) says fragment identifiers are ignored for page numbers, so these panels cannot rank as separate pages.

Where CSS-only pagination stops working

It’s fine for a small gallery. A blog archive, a search results page or a product listing is a different story, because each page needs its own URL in the index, the list grows past what one response should carry, and readers want to share or return to a position.

When any of that applies, move to server-rendered pages with real URLs, or add a thin layer of JavaScript that fetches each page.

What makes paginated pages crawlable?

Plain anchor links pointing to a separate URL for each page. Google’s crawlers read the href of anchor (a) elements and do not click buttons or run functions that wait for a user action (Google Search Central, updated 10 December 2025).

Give each page a unique URL, such as a ?page=n parameter, because Google treats URLs in a sequence as separate pages. Each page should canonicalize to itself, not to page one.

Skip fragments. Google ignores the text after a # for page numbers and may skip the link as already fetched. Every page should also link to the next one, and optionally back to the first page.

Titles and descriptions do not have to differ across the sequence, since Google tries to recognize the pages in a series. Filtered or re-sorted variants are a separate case, and those should be blocked with noindex or robots.txt.

Google Webmasters announced on March 21, 2019 that it had retired rel=”next” and rel=”prev” as an indexing signal, and Search Engine Journal reported it had stopped using them years earlier. Google’s current documentation adds that other search engines may still read the links.

SEO sources still disagree on what to do. NoGood’s 2024 guide lists the tags among best practices, while Ahrefs says to leave an existing implementation alone.

Both positions hold up. Google ignores the tags and other engines may not, so removing existing ones gains nothing. Adding them to a new build buys you nothing in Google, though.

The common failure is a Next button wired to a click handler with no URL behind it. The page looks paginated to a reader and shows up as one page to the crawler.

Pagination, load more or infinite scroll: which should you use?

Use numbered pagination for content people need to find again or share. Use load more for browse-heavy lists, and infinite scroll only when every chunk has its own URL.

PatternCrawlable by defaultReader seesBuild cost
Numbered paginationYes, with anchor linksResult size and positionLow
Load moreOnly with page URLs underneathA growing list and one buttonMedium
Infinite scrollOnly with page URLs and URL updatesAn endless list, unclear sizeHighest

Google lists the trade-offs plainly. Pagination shows result size and position but splits content across page loads, while infinite scroll can cause scrolling fatigue because the total is unclear.

Baymard’s usability research (a 2016 write-up of its year-long studies of more than 50 leading e-commerce sites, published through Smashing Magazine) found testers called pagination slow. They also browsed less of the product list than on load more or infinite scroll sites.

So the two sources pull in opposite directions. Google counts visible position as a strength of pagination, while Baymard’s testers treated a row of numbered links as friction.

The difference is the lens. Google describes what each pattern shows and what a crawler can index, and Baymard watched people browse.

The study is also a decade old, so treat it as a pointer and not a current benchmark.

Baymard’s own example is Crutchfield. It loads 20 products by default, lazy-loads 20 more as the reader nears the 10th, and shows a Load more button after the 40th.

A load more button usually fetches the next batch through Ajax and appends it to the same page. Google’s crawlers do not click the button, so the batches need page URLs underneath.

Google’s documentation is specific about what makes infinite scroll indexable.

  • Each chunk gets a persistent, unique URL, and the same content loads on that URL every time
  • Page numbers are absolute, like ?page=12, never relative like ?date=yesterday
  • Chunks link to each other in sequence
  • The address bar updates through the History API as each chunk becomes the main visible element

Default to numbered pagination. Move to load more over real ?page=n URLs when breadth of browsing matters more than reaching a specific page, and skip infinite scroll unless you’ll build the URL layer.

CSS Pagination FAQ

Is CSS pagination the same as CSS paged media?

No. CSS paged media covers print and fragmented output, where rules such as @page and break properties decide how content splits across physical pages. Pagination as a screen component styles clickable page links and has no connection to print layout.

Should the current page be a link or a span?

Either works. A link keeps markup and target size consistent across the bar, while a span avoids a pointless self-link. Keep aria-current=”page” on whichever element you use, since the breadcrumb example on MDN’s aria-current page makes the attribute optional only when the item is not a link.

How many page links should show at once?

Fewer than most designs show. Display the current page, one or two neighbors on each side, the first and last pages, and an ellipsis between gaps. Baymard’s testers were discouraged by more than a handful of links.

Can buttons replace links in pagination?

Not for page navigation. Google’s crawlers read the href of anchor elements and do not click buttons, so a button with no URL behind it hides pages two onward. Buttons suit load more controls, and only when page URLs exist underneath.

Testing Pagination for Keyboard, Screen Reader and Crawl Problems

CSS pagination passes testing when every page link is a real anchor with its own URL, because that single choice covers keyboard users and screen readers as well as Googlebot.

Start with the crawl. Turn JavaScript off and confirm each page number loads a distinct URL. Crawl failures stay invisible to visitors, so they go first.

Then tab through the bar and confirm every link shows a visible focus ring. That takes about a minute. The screen reader pass comes last because it takes longest: check that the landmark name and the current page both get announced.

The trade-off is one server request per page, which script-driven buttons avoid.

The web accessibility checklist extends these checks to the rest of the page.