CSS Pagination Component
Numbered page buttons with an active state driven by radio inputs and :checked.
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.
Three versions are here. The first is the numbered bar with decorative prev and next ends. The second makes prev and next actually work, which takes more markup than people expect and is worth seeing spelled out. The third is a row of dots for a small number of pages, where the count matters more than the numbering.
Click a page number
HTML
<input type="radio" name="page" id="pg1" class="pg-radio" checked>
<input type="radio" name="page" id="pg2" class="pg-radio">
<input type="radio" name="page" id="pg3" class="pg-radio">
<input type="radio" name="page" id="pg4" class="pg-radio">
<input type="radio" name="page" id="pg5" class="pg-radio">
<div class="pg-wrap">
<div class="pg-bar">
<!-- Prev/Next are decorative: pure CSS cannot do relative steps -->
<label class="pg-prev" aria-hidden="true">← Prev</label>
<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>
<label class="pg-next" aria-hidden="true">Next →</label>
</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
.pg-radio {
display: none;
}
.pg-wrap {
display: flex;
flex-direction: column;
gap: 1.25rem;
align-items: center;
}
.pg-bar {
display: flex;
align-items: center;
gap: .3rem;
}
.pg-bar label {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
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-bar label:hover {
background: #1c1c1e;
color: #f0f0f0;
border-color: #88888f;
}
/* Wider than the numbers, and inert: they only label the ends */
.pg-bar .pg-prev,
.pg-bar .pg-next {
width: auto;
padding: 0 .75rem;
gap: .3rem;
font-size: .85rem;
cursor: default;
opacity: .45;
}
.pg-bar .pg-prev:hover,
.pg-bar .pg-next:hover {
background: none;
color: #88888f;
border-color: #2a2a2d;
}
.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;
}
/* Active page button */
#pg1:checked ~ .pg-wrap .pg-bar label[for="pg1"],
#pg2:checked ~ .pg-wrap .pg-bar label[for="pg2"],
#pg3:checked ~ .pg-wrap .pg-bar label[for="pg3"],
#pg4:checked ~ .pg-wrap .pg-bar label[for="pg4"],
#pg5:checked ~ .pg-wrap .pg-bar label[for="pg5"] {
background: #b8ff57;
color: #0c0c0d;
border-color: #b8ff57;
}
/* 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;
}
Other ways to build it
Prev and next that actually move
CSS cannot add one to the current page, so this version includes every prev and next button it could ever need and shows only the pair that fits the checked radio. Four pages means three real prev labels, three real next labels, and a disabled stand-in at each end. The rule count grows with the page count, which is the honest limit of the technique. The radios stay focusable here, so the arrow keys page through as well.
HTML
<input class="pgn-radio" type="radio" name="pagination-nav" id="n1" checked>
<input class="pgn-radio" type="radio" name="pagination-nav" id="n2">
<input class="pgn-radio" type="radio" name="pagination-nav" id="n3">
<input class="pgn-radio" type="radio" name="pagination-nav" id="n4">
<div class="pgn-wrap">
<div class="pgn-bar">
<span class="pgn-end pgn-prev">
<span class="pgn-off">Prev</span>
<label for="n1">Prev</label>
<label for="n2">Prev</label>
<label for="n3">Prev</label>
</span>
<span class="pgn-nums">
<label for="n1">1</label>
<label for="n2">2</label>
<label for="n3">3</label>
<label for="n4">4</label>
</span>
<span class="pgn-end pgn-next">
<label for="n2">Next</label>
<label for="n3">Next</label>
<label for="n4">Next</label>
<span class="pgn-off">Next</span>
</span>
</div>
<div class="pgn-content">
<span class="pgn-page" id="c1">Page 1 of 4. Prev is disabled here, because there is a rule for that too.</span>
<span class="pgn-page" id="c2">Page 2 of 4. The visible Prev button points at page 1.</span>
<span class="pgn-page" id="c3">Page 3 of 4. A different Prev and Next label pair is showing now.</span>
<span class="pgn-page" id="c4">Page 4 of 4. Next is the disabled stand-in at this end.</span>
</div>
</div>
CSS
/* all of them hidden, then one revealed per page */
.pgn-end > * {
display: none;
}
#n1:checked ~ .pgn-wrap .pgn-prev .pgn-off,
#n2:checked ~ .pgn-wrap .pgn-prev label[for="n1"],
#n3:checked ~ .pgn-wrap .pgn-prev label[for="n2"],
#n4:checked ~ .pgn-wrap .pgn-prev label[for="n3"] {
display: flex;
}
#n1:checked ~ .pgn-wrap .pgn-next label[for="n2"],
#n2:checked ~ .pgn-wrap .pgn-next label[for="n3"],
#n3:checked ~ .pgn-wrap .pgn-next label[for="n4"],
#n4:checked ~ .pgn-wrap .pgn-next .pgn-off {
display: flex;
}
/* the stand-in at each end looks like a button and does nothing */
.pgn-off {
opacity: .35;
cursor: default;
}
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;
}
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, and the reason the first demo marks them aria-hidden rather than wiring them up. 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 four pages that is three prev labels and three next labels stacked in the same slot, with display: none on all of them and one rule per page to reveal the pair that applies.
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.
CSS properties used
:checked- Matches the radio for the current page. Every other rule in the component is written against it.
~- The general sibling combinator, which carries the checked state forward to the button bar and the content area.
displaynoneandflexswitch which page content shows, and which prev and next buttons are the right ones for the current position.flex- Lays the row out and keeps the numbers evenly spaced. The prev and next slots are
width: autoso they can hold a word rather than a digit. transition- Smooths the background, color and border changes when the active page moves. Without it the bar flickers between states.
:focus-visible- Draws the focus ring on the label, because the radio behind it is hidden and paints nothing of its own.
Browser support
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
:checked and ~ | 4 | 3.5 | 3.2 | 12 |
flexbox | 21 | 28 | 6.1 | 12 |
transition | 4 | 5 | 5.1 | 12 |
:focus-visible | 86 | 4 | 15.4 | 86 |
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.
Accessibility notes
Pagination that changes the URL should be built from links, not from labels. A link announces as a link, works with a middle click, appears in the browser's history, and can be opened in a new tab. A label announces as a form control and does none of those things. The radio version on this page is correct only when the pages genuinely never leave the document.
Wrap the bar in <nav aria-label="Pagination"> so a screen reader can find it and skip it. 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.
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, which is the variant below rather than the numbered one.
- 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.
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 rules below, 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
checkedattribute on the first radio. The bar loads with no page highlighted and no content visible, which looks like a failed render. - Reusing one
nameacross two paginated lists on the same page. They merge into a single radio group, so paging the first list resets the second.
Frequently asked questions
How do I build pagination with CSS only?
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?
Is CSS-only pagination bad for SEO?
How do I mark the current page for a screen reader?
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.