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; }
| Method | Key properties | Centering | Weak point |
|---|
| Flexbox | display: flex, gap, flex-wrap | justify-content: center | Equal-width cells need extra rules |
| Grid | display: grid, grid-auto-flow: column, gap | justify-content: center | More setup for a changing link count |
| Inline-block or float | display: inline-block, float: left | text-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.
| Pattern | Crawlable by default | Reader sees | Build cost |
|---|
| Numbered pagination | Yes, with anchor links | Result size and position | Low |
| Load more | Only with page URLs underneath | A growing list and one button | Medium |
| Infinite scroll | Only with page URLs and URL updates | An endless list, unclear size | Highest |
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.