Most demos need a newer browser

They use :has() and container queries. Update your browser, or on an iPhone update iOS to 16.4 or later.

CSS Pagination Component

Numbered page buttons with an active state driven by radio inputs and :checked. Previous and next controls sit at each end.

Level
Intermediate
Published
Updated
Reading time
8 min

Try it

2 live demos

Showing results 1-10 of 48 Showing results 11-20 of 48 Showing results 21-30 of 48 Showing results 31-40 of 48 Showing results 41-48 of 48

Click a number, prev or next, or use arrow keys

Other ways to build it

Slide one Slide two Slide three Slide four

Dots instead of numbers

See the code

Build it

4 snippets to copy

CSS Pagination Component

HTML

<input type="radio" name="page" id="pg1" class="pg-radio" aria-label="Page 1" checked>
<input type="radio" name="page" id="pg2" class="pg-radio" aria-label="Page 2">
<input type="radio" name="page" id="pg3" class="pg-radio" aria-label="Page 3">
<input type="radio" name="page" id="pg4" class="pg-radio" aria-label="Page 4">
<input type="radio" name="page" id="pg5" class="pg-radio" aria-label="Page 5">

<div class="pg-wrap">
  <div class="pg-bar">
    <!-- one Prev per page it can go back to, plus a disabled
         stand-in for page 1; CSS shows exactly one of them -->
    <span class="pg-end pg-prev" aria-hidden="true">
      <span class="pg-off">← Prev</span>
      <label for="pg1">← Prev</label>
      <label for="pg2">← Prev</label>
      <label for="pg3">← Prev</label>
      <label for="pg4">← Prev</label>
    </span>
    <span class="pg-nums">
      <label for="pg1">1</label>
      <label for="pg2">2</label>
      <label for="pg3">3</label>
      <label for="pg4">4</label>
      <label for="pg5">5</label>
    </span>
    <span class="pg-end pg-next" aria-hidden="true">
      <label for="pg2">Next →</label>
      <label for="pg3">Next →</label>
      <label for="pg4">Next →</label>
      <label for="pg5">Next →</label>
      <span class="pg-off">Next →</span>
    </span>
  </div>
  <div class="pg-content">
    <span class="pg-page pg-page-1">Showing results 1-10 of 48</span>
    <span class="pg-page pg-page-2">Showing results 11-20 of 48</span>
    <span class="pg-page pg-page-3">Showing results 21-30 of 48</span>
    <span class="pg-page pg-page-4">Showing results 31-40 of 48</span>
    <span class="pg-page pg-page-5">Showing results 41-48 of 48</span>
  </div>
</div>

CSS

/* hidden but still focusable, so Tab reaches the group and the
   arrow keys move between pages */
.pg-radio {
  position: absolute;
  opacity: 0;
  width: 1px;
  height: 1px;
  pointer-events: none;
}

.pg-wrap {
  display: flex;
  flex-direction: column;
  gap: 1.25rem;
  align-items: center;
}

.pg-bar,
.pg-nums {
  display: flex;
  align-items: center;
  gap: .3rem;
}

.pg-bar label,
.pg-off {
  align-items: center;
  justify-content: center;
  height: 36px;
  border-radius: 8px;
  border: 1px solid #2a2a2d;
  color: #88888f;
  font-size: .9rem;
  font-weight: 500;
  cursor: pointer;
  transition: background .15s, color .15s, border-color .15s;
  user-select: none;
}

.pg-nums label {
  display: flex;
  width: 36px;
}

.pg-bar label:hover {
  background: #1c1c1e;
  color: #f0f0f0;
  border-color: #88888f;
}

/* every Prev and Next the bar could need, all hidden */
.pg-bar .pg-end > * {
  display: none;
  padding: 0 .75rem;
  font-size: .85rem;
  white-space: nowrap;
}

#pg1:checked ~ .pg-wrap .pg-prev .pg-off,
#pg2:checked ~ .pg-wrap .pg-prev label[for="pg1"],
#pg3:checked ~ .pg-wrap .pg-prev label[for="pg2"],
#pg4:checked ~ .pg-wrap .pg-prev label[for="pg3"],
#pg5:checked ~ .pg-wrap .pg-prev label[for="pg4"],
#pg1:checked ~ .pg-wrap .pg-next label[for="pg2"],
#pg2:checked ~ .pg-wrap .pg-next label[for="pg3"],
#pg3:checked ~ .pg-wrap .pg-next label[for="pg4"],
#pg4:checked ~ .pg-wrap .pg-next label[for="pg5"],
#pg5:checked ~ .pg-wrap .pg-next .pg-off {
  display: flex;
}

/* the stand-in at each end: same shape, dimmed, and not a label,
   so a click on it does nothing */
.pg-off {
  opacity: .45;
  cursor: default;
}

/* Active page button */
#pg1:checked ~ .pg-wrap .pg-nums label[for="pg1"],
#pg2:checked ~ .pg-wrap .pg-nums label[for="pg2"],
#pg3:checked ~ .pg-wrap .pg-nums label[for="pg3"],
#pg4:checked ~ .pg-wrap .pg-nums label[for="pg4"],
#pg5:checked ~ .pg-wrap .pg-nums label[for="pg5"] {
  background: #b8ff57;
  color: #0c0c0d;
  border-color: #b8ff57;
}

/* the radio is invisible, so its focus ring is drawn on its number */
#pg1:focus-visible ~ .pg-wrap .pg-nums label[for="pg1"],
#pg2:focus-visible ~ .pg-wrap .pg-nums label[for="pg2"],
#pg3:focus-visible ~ .pg-wrap .pg-nums label[for="pg3"],
#pg4:focus-visible ~ .pg-wrap .pg-nums label[for="pg4"],
#pg5:focus-visible ~ .pg-wrap .pg-nums label[for="pg5"] {
  outline: 2px solid #b8ff57;
  outline-offset: 2px;
}

.pg-content {
  background: #141415;
  border: 1px solid #2a2a2d;
  border-radius: 8px;
  padding: .75rem 1.25rem;
  font-size: .85rem;
  color: #88888f;
  min-height: 44px;
  display: flex;
  align-items: center;
  width: 100%;
  max-width: 320px;
  justify-content: center;
}

.pg-page {
  display: none;
}

/* Show the matching page content */
#pg1:checked ~ .pg-wrap .pg-content .pg-page-1,
#pg2:checked ~ .pg-wrap .pg-content .pg-page-2,
#pg3:checked ~ .pg-wrap .pg-content .pg-page-3,
#pg4:checked ~ .pg-wrap .pg-content .pg-page-4,
#pg5:checked ~ .pg-wrap .pg-content .pg-page-5 {
  display: block;
  color: #f0f0f0;
}

/* on a phone the numbers take the first line and
   Prev and Next share the second */
@media (max-width: 30rem) {
  .pg-bar {
    flex-wrap: wrap;
    justify-content: center;
    row-gap: .5rem;
  }

  .pg-nums {
    order: -1;
    flex-basis: 100%;
    justify-content: center;
  }
}

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

Dots instead of numbers

When the reader needs to know how many items there are and which one is showing, but has no reason to jump to a specific index, the numbers are noise. The active dot stretches into a short bar so the current position is readable without relying on color alone. Everything else is the same radio group, with the labels sized down to circles.

HTML

<input class="pgd-radio" type="radio" name="pagination-dots" id="d1" aria-label="Slide 1" checked>
<input class="pgd-radio" type="radio" name="pagination-dots" id="d2" aria-label="Slide 2">
<input class="pgd-radio" type="radio" name="pagination-dots" id="d3" aria-label="Slide 3">
<input class="pgd-radio" type="radio" name="pagination-dots" id="d4" aria-label="Slide 4">
<div class="pgd-wrap">
  <div class="pgd-slide">
    <span class="pgd-panel" id="s1">Slide one</span>
    <span class="pgd-panel" id="s2">Slide two</span>
    <span class="pgd-panel" id="s3">Slide three</span>
    <span class="pgd-panel" id="s4">Slide four</span>
  </div>
  <div class="pgd-dots">
    <label for="d1"></label>
    <label for="d2"></label>
    <label for="d3"></label>
    <label for="d4"></label>
  </div>
</div>

CSS

.pgd-dots label {
  width: 8px;
  height: 8px;
  border-radius: 99px;
  background: #2a2a2d;
  cursor: pointer;
  transition: width .25s, background .25s;
}

/* the active dot changes shape as well as color, so the
   position reads without depending on hue */
#d1:checked ~ .pgd-wrap .pgd-dots label[for="d1"],
#d2:checked ~ .pgd-wrap .pgd-dots label[for="d2"],
#d3:checked ~ .pgd-wrap .pgd-dots label[for="d3"],
#d4:checked ~ .pgd-wrap .pgd-dots label[for="d4"] {
  width: 22px;
  background: #b8ff57;
}

Understand it

How it works and 6 properties

A CSS pagination bar is a row of numbered buttons where exactly one is active at a time, which is the same job a radio group already does. One hidden radio per page, a <label> for each pointing at its radio, and :checked plus the ~ combinator to highlight the active number and reveal the matching content. No script holds the current page, because the form control does.

Two versions are here. The numbered bar has working prev and next buttons, which takes more markup than people expect and is worth seeing spelled out. The second is a row of dots for a small number of pages, where the count matters more than the numbering.

How it works

Hidden radio inputs track which page is selected. Each numbered <label> targets one radio input. When a label is clicked, its radio becomes :checked. CSS then highlights that label and shows the corresponding content block using the ~ sibling combinator. The active page gets the accent background while unselected pages remain neutral.

Prev and next are the hard part. CSS has no arithmetic on state. There is no way to express "check the radio one higher than the one currently checked", because a selector can only match what is already true. What it can do is have one prev button per page and show only the correct one. On five pages that is four prev labels and four next labels stacked in the same slot, plus a disabled stand-in at each end, with display: none on all of them. The aria-hidden on the prev and next groups exists because the radios already carry the page names.

That trick scales badly and honestly. The number of rules grows with the page count, and the markup carries a hidden button for every possible position. At four or five pages it is a reasonable component. At fifty it is generated CSS, and at that point the state belongs somewhere other than a stylesheet.

For real pagination on a content site, none of this is the right tool. Radios keep the current page inside the document, so the URL never changes, the browser's back button does nothing, and a crawler following links finds one page where there should be twelve. Production pagination is a list of ordinary <a href="/blog/page/2/"> links to real URLs, styled with the same CSS as here. Google stopped treating rel="prev" and rel="next" as an indexing signal, so what matters is that each page is reachable by a normal link.

Where the radio version does fit is anywhere the pages are already in the document: a set of results rendered at build time, a short image gallery, a form split into steps. In those cases nothing is being fetched, and holding the position in a radio group saves both a script and a round trip.

The dot version is the same mechanism with the numbers taken away. It works when the reader needs to know how many items there are and which one they are on, but does not need to jump to a specific index. That makes it a companion to a scroll carousel rather than a replacement for numbered pages.

Accessibility notes

Pagination that changes the URL should be built from links, not from labels. A link works with a middle click, appears in the browser's history, and can be opened in a new tab. The radio version on this page is correct only when the pages never leave the document.

Wrap the bar in <nav aria-label="Pagination"> so it can be found and skipped. Mark the current page with aria-current="page" on whichever element carries the active state. Neither of those is styling: they are what tells a reader which of a dozen numbers they are standing on, since color alone does not carry that.

Hiding the radios with display: none removes them from the tab order and makes the bar unusable without a mouse. Hidden with position: absolute; opacity: 0 instead, they stay focusable, and arrow keys move between pages the way they do in any radio group. Put the focus ring on the label with :focus-visible, since the input itself is invisible.

Keep the buttons large enough to hit. A 36 by 36 pixel target is the floor for a pointer, and a page bar is often the densest row of small controls on a page, so a couple of extra pixels of gap between them prevents mis-taps on a phone.

Mistakes worth avoiding

  • Shipping decorative prev and next buttons that look enabled. A control that reacts to hover but does nothing on click reads as a bug. Either wire them up with the per page selectors, or style them as disabled and mark them aria-hidden.
  • Using this pattern for content pagination on a public site. The URL never changes, so a reader cannot link to page three, the back button does nothing, and a crawler sees only the first page.
  • Hiding the radios with display: none. The bar stops working for anyone using a keyboard, and nothing about the rendered page reveals the problem.
  • Forgetting the checked attribute on the first radio. The bar loads with no page highlighted and no content visible, which looks like a failed render.
  • Reusing one name across two paginated lists on the same page. They merge into a single radio group, so paging the first list resets the second.

Ship it

4 features in 4 browsers, 5 questions

Browser support

Feature Chrome Firefox Safari Edge
:checked and ~43.53.212
flexbox21286.112
transition455.112
:focus-visible86415.486

Figures come from Can I Use for CSS3 selectors, the Flexible Box Layout module, CSS transitions, and the :focus-visible pseudo-class. Everything except :focus-visible predates any browser still in use, and :focus-visible degrades to :focus behavior rather than to nothing, so no fallback is needed. If the same bar is used for real link based pagination, none of these floors apply at all, since links need no selector work to be operable.

What you can build with it

  • Search results built at compile time. A static site that renders all its results up front can page through them without a request, which is where this technique costs nothing.
  • Image galleries. A small set of photos with a dot row underneath, where the reader wants to know how many are left rather than jump to number seven.
  • Onboarding and tours. A short sequence of panels with working prev and next buttons that step through them.
  • Comparison tables split by category. Rows grouped into pages so a wide table never scrolls sideways on a laptop.
  • Documentation examples. Numbered steps that stay on one URL, paired with a breadcrumb trail so the reader still knows where the page sits in the site.

Frequently asked questions

How do I build pagination with CSS only?
One hidden radio per page, all sharing a name, placed before the bar and the content. A <label> per page points at its radio, and a rule per page highlights the label and shows the matching content block through the ~ combinator. The first radio carries checked so the component has a starting state.
Can CSS pagination have working prev and next buttons?
Yes, CSS pagination can have working prev and next buttons, but not by arithmetic. CSS cannot step from the current page to the next one, so you include a prev and a next button for every position and reveal only the pair that matches the checked radio. The numbered bar above does exactly that for five pages.
Is CSS-only pagination bad for SEO?
For paginating real content, yes. Everything lives on one URL, so there is nothing for a crawler to follow and nothing for anyone to link to. Use ordinary links to real pages and style them with the same CSS. Keep the radio version for content that is already in the document.
How do I mark the current page in the accessibility tree?
aria-current="page" on the element representing the active page, inside a <nav> with an accessible name such as aria-label="Pagination". The color change is for sighted readers only and carries nothing on its own.
How many pages is this technique good for?
Up to about ten before the selector lists get unwieldy, and fewer than that if you want working prev and next, since those add two selectors per page inside one shared rule, two labels per page, and one disabled stand-in at each end. Past that point, generate the stylesheet or move the state out of CSS.