Tailwind Pagination: 17 Examples, From Numbered to Load More

Tailwind has no pagination component, so every pager is built from utility classes on a nav, a list and plain links. Write it yourself, copy it from a kit, or use Laravel's default Tailwind views.

For search, use real anchors: Google's crawlers follow hrefs and do not click buttons. Below are 17 Tailwind pagination examples you can copy, followed by a guide to building them.

By Bogdan Sandu ยท Updated October 29, 2026

Every example is built with Tailwind CSS v4.3 utility classes. To try one quickly, add the Play CDN tags below to your page and paste the example's HTML, CSS and JS. The dark variant line makes dark: classes follow a .dark class instead of the system setting. For production, install Tailwind with Vite or the CLI.

<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<style type="text/tailwindcss">
  @custom-variant dark (&:where(.dark, .dark *));
</style>

Numbered pages, ellipsis and page jumps

Pagers with a little JavaScript: an ellipsis range that keeps a fixed slot count, a Page 3 of 12 jump form, simple Newer and Older links, a row that collapses below sm: and a stepper for multi-page forms.

Ellipsis truncation with a fixed slot count

  • createElement
  • aria-[current=page]:
  • row-start-1 col-span-2
  • aria-hidden

Click the pages, or use Tab and Enter.

The script builds the row with createElement and keeps it at 2 x siblings + 5 slots (7 here): the first page, the last page, the current page with one sibling each side and up to two ellipsis spans marked aria-hidden. Every class name is written out in full in the JS so Tailwind can find it. Below sm: a two-column grid puts the numbers on the first row (row-start-1 col-span-2) and Previous and Next under them.

View code
<div class="w-full max-w-2xl rounded-2xl bg-white p-4 shadow-sm ring-1 ring-stone-200 sm:p-6">
  <div class="flex flex-wrap items-center justify-between gap-3">
    <div>
      <h3 class="font-semibold text-stone-900">Recipe archive</h3>
      <p id="tpg-el-status" aria-live="polite" class="text-sm text-stone-600 tabular-nums">Page 6 of 20</p>
    </div>
    <div class="flex items-center gap-2">
      <label for="tpg-el-total" class="text-sm text-stone-700">Total pages</label>
      <span class="relative">
        <select id="tpg-el-total" class="h-9 cursor-pointer appearance-none rounded-md bg-white pr-8 pl-3 text-sm text-stone-900 ring-1 ring-stone-300 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-700">
          <option>7</option>
          <option selected>20</option>
          <option>100</option>
        </select>
        <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" class="pointer-events-none absolute top-1/2 right-2.5 size-4 -translate-y-1/2 text-stone-500" aria-hidden="true"><path d="m6 8 4 4 4-4" stroke-linecap="round" stroke-linejoin="round"/></svg>
      </span>
    </div>
  </div>
  <nav aria-label="Pagination" class="mt-5 grid grid-cols-2 gap-3 sm:flex sm:items-center sm:justify-between">
    <a id="tpg-el-prev" href="?page=5" class="inline-flex h-10 items-center gap-1 justify-self-start rounded-md px-3 text-sm font-medium text-stone-700 ring-1 ring-stone-300 hover:bg-stone-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-700 aria-disabled:cursor-not-allowed aria-disabled:text-stone-400 aria-disabled:ring-stone-200 aria-disabled:hover:bg-transparent"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" class="size-4" aria-hidden="true"><path d="m12 5-5 5 5 5" stroke-linecap="round" stroke-linejoin="round"/></svg>Previous</a>
    <ul id="tpg-el-list" class="col-span-2 row-start-1 flex items-center justify-center gap-1">
      <li><a href="?page=1" data-page="1" class="inline-flex size-8 items-center justify-center rounded-md text-sm font-medium text-stone-700 tabular-nums hover:bg-stone-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-700 aria-[current=page]:bg-emerald-700 aria-[current=page]:text-white sm:size-10"><span class="sr-only">Page </span>1</a></li>
      <li><span class="inline-flex size-8 items-center justify-center text-sm text-stone-500 sm:size-10" aria-hidden="true">&hellip;</span></li>
      <li><a href="?page=5" data-page="5" class="inline-flex size-8 items-center justify-center rounded-md text-sm font-medium text-stone-700 tabular-nums hover:bg-stone-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-700 aria-[current=page]:bg-emerald-700 aria-[current=page]:text-white sm:size-10"><span class="sr-only">Page </span>5</a></li>
      <li><a href="?page=6" data-page="6" aria-current="page" class="inline-flex size-8 items-center justify-center rounded-md text-sm font-medium text-stone-700 tabular-nums hover:bg-stone-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-700 aria-[current=page]:bg-emerald-700 aria-[current=page]:text-white sm:size-10"><span class="sr-only">Page </span>6</a></li>
      <li><a href="?page=7" data-page="7" class="inline-flex size-8 items-center justify-center rounded-md text-sm font-medium text-stone-700 tabular-nums hover:bg-stone-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-700 aria-[current=page]:bg-emerald-700 aria-[current=page]:text-white sm:size-10"><span class="sr-only">Page </span>7</a></li>
      <li><span class="inline-flex size-8 items-center justify-center text-sm text-stone-500 sm:size-10" aria-hidden="true">&hellip;</span></li>
      <li><a href="?page=20" data-page="20" class="inline-flex size-8 items-center justify-center rounded-md text-sm font-medium text-stone-700 tabular-nums hover:bg-stone-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-700 aria-[current=page]:bg-emerald-700 aria-[current=page]:text-white sm:size-10"><span class="sr-only">Page </span>20</a></li>
    </ul>
    <a id="tpg-el-next" href="?page=7" class="inline-flex h-10 items-center gap-1 justify-self-end rounded-md px-3 text-sm font-medium text-stone-700 ring-1 ring-stone-300 hover:bg-stone-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-700 aria-disabled:cursor-not-allowed aria-disabled:text-stone-400 aria-disabled:ring-stone-200 aria-disabled:hover:bg-transparent">Next<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" class="size-4" aria-hidden="true"><path d="m8 5 5 5-5 5" stroke-linecap="round" stroke-linejoin="round"/></svg></a>
  </nav>
</div>

Page 3 of 12 with a jump-to-page field

  • type="number"
  • [appearance:textfield]
  • aria-invalid:
  • disabled:opacity-40

Type a page number and press Enter, or use the arrows.

A compact viewer toolbar: the page number is a real labelled number input inside a form, so Enter or the Go button jumps straight to a page. [appearance:textfield] and [&::-webkit-inner-spin-button]:appearance-none remove the spinners, and an out-of-range value sets aria-invalid, which aria-invalid:outline-rose-400 picks up, plus a message in a role="status" line.

View code
<div class="w-full max-w-xl overflow-hidden rounded-2xl bg-slate-200 ring-1 ring-slate-300">
  <div class="flex flex-col items-center gap-2 bg-slate-900 px-3 py-2.5 text-white sm:flex-row sm:justify-between">
    <p class="text-sm font-medium">Brand guidelines.pdf</p>
    <form id="tpg-jp-form" class="flex items-center gap-1.5" novalidate>
      <button id="tpg-jp-prev" type="button" aria-label="Previous page" class="grid size-9 cursor-pointer place-items-center rounded-md hover:bg-white/10 focus-visible:outline-2 focus-visible:outline-amber-300 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" class="size-4" aria-hidden="true"><path d="m12 5-5 5 5 5" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
      <label for="tpg-jp-input" class="text-sm text-slate-300">Page</label>
      <input id="tpg-jp-input" type="number" inputmode="numeric" min="1" max="12" value="3" aria-describedby="tpg-jp-total" class="h-9 w-14 rounded-md bg-white/10 text-center text-sm text-white tabular-nums ring-1 ring-white/25 [appearance:textfield] focus:outline-2 focus:outline-amber-300 aria-invalid:outline-2 aria-invalid:outline-rose-400 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none">
      <span id="tpg-jp-total" class="text-sm text-slate-300 tabular-nums">of 12</span>
      <button type="submit" class="h-9 cursor-pointer rounded-md bg-amber-300 px-3 text-sm font-semibold text-slate-950 hover:bg-amber-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-300">Go</button>
      <button id="tpg-jp-next" type="button" aria-label="Next page" class="grid size-9 cursor-pointer place-items-center rounded-md hover:bg-white/10 focus-visible:outline-2 focus-visible:outline-amber-300 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" class="size-4" aria-hidden="true"><path d="m8 5 5 5-5 5" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
    </form>
  </div>
  <div class="px-4 py-6">
    <div class="mx-auto flex aspect-[3/4] w-44 flex-col rounded-sm bg-white p-4 shadow-md sm:w-52">
      <p id="tpg-jp-heading" class="text-sm font-semibold text-slate-900">Logo clear space</p>
      <span class="mt-3 block h-1.5 w-full rounded-full bg-slate-200" aria-hidden="true"></span>
      <span class="mt-1.5 block h-1.5 w-11/12 rounded-full bg-slate-200" aria-hidden="true"></span>
      <span class="mt-1.5 block h-1.5 w-4/5 rounded-full bg-slate-200" aria-hidden="true"></span>
      <span class="mt-4 block h-16 rounded bg-amber-100" aria-hidden="true"></span>
      <span class="mt-4 block h-1.5 w-full rounded-full bg-slate-200" aria-hidden="true"></span>
      <span class="mt-1.5 block h-1.5 w-2/3 rounded-full bg-slate-200" aria-hidden="true"></span>
      <p id="tpg-jp-num" class="mt-auto text-right text-xs text-slate-500 tabular-nums">3</p>
    </div>
    <p id="tpg-jp-msg" role="status" class="mt-4 text-center text-sm text-slate-700">Page 3 of 12: Logo clear space</p>
  </div>
</div>

Simple Newer and Older links with page info

  • font-serif
  • bg-[#fbf7f0]
  • aria-disabled:invisible
  • tabular-nums

Click the pages, or use Tab and Enter.

The blog archive pattern: two links and a Page 2 of 4 line, no numbers. On the first and last page the dead link keeps its slot but turns invisible through aria-disabled:invisible, so the page info never jumps sideways. The script swaps the three post titles and dates per page and keeps every live link pointing at a real ?page= URL.

View code
<div class="w-full max-w-xl rounded-2xl bg-[#fbf7f0] p-5 ring-1 ring-amber-900/10 sm:p-8">
  <h3 class="font-serif text-2xl text-stone-900">Field notes</h3>
  <p class="mt-1 text-sm text-stone-600">Short essays on walking, maps and weather.</p>
  <ul id="tpg-sp-list" class="mt-5 divide-y divide-amber-900/10">
    <li class="py-3"><span class="tpg-sp-title block font-serif text-lg text-stone-900">What the tide tables leave out</span><span class="tpg-sp-date text-sm text-stone-600">October 24, 2026</span></li>
    <li class="py-3"><span class="tpg-sp-title block font-serif text-lg text-stone-900">A week of fog on the coast path</span><span class="tpg-sp-date text-sm text-stone-600">October 17, 2026</span></li>
    <li class="py-3"><span class="tpg-sp-title block font-serif text-lg text-stone-900">Reading contour lines by feel</span><span class="tpg-sp-date text-sm text-stone-600">October 10, 2026</span></li>
  </ul>
  <nav aria-label="Pagination" class="mt-4 flex items-center justify-between gap-3 border-t border-amber-900/15 pt-4">
    <a id="tpg-sp-newer" aria-disabled="true" class="inline-flex h-11 items-center gap-1.5 rounded-full px-3 text-sm font-semibold text-amber-900 hover:bg-amber-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-800 aria-disabled:invisible"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" class="size-4" aria-hidden="true"><path d="m12 5-5 5 5 5" stroke-linecap="round" stroke-linejoin="round"/></svg>Newer</a>
    <p id="tpg-sp-info" aria-live="polite" class="text-sm text-stone-700 tabular-nums">Page 1 of 4</p>
    <a id="tpg-sp-older" href="?page=2" class="inline-flex h-11 items-center gap-1.5 rounded-full px-3 text-sm font-semibold text-amber-900 hover:bg-amber-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-800 aria-disabled:invisible">Older<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" class="size-4" aria-hidden="true"><path d="m8 5 5 5-5 5" stroke-linecap="round" stroke-linejoin="round"/></svg></a>
  </nav>
</div>

Mobile-first row that collapses below sm:

  • hidden sm:block
  • sm:hidden
  • flex-1 sm:flex-none
  • aria-[current=page]:

Use Previous and Next, or a number on wider screens.

Written mobile first: unprefixed classes give phones two wide Previous and Next buttons with a Page 4 of 10 line between them, and from 640px up the number links appear (hidden sm:block) while the text line goes away (sm:hidden). Resize the frame or compare the desktop and mobile screenshots. The script keeps the numbers, the text and both ends in sync.

View code
<div class="w-full max-w-3xl rounded-2xl bg-white p-4 shadow-sm ring-1 ring-sky-950/10 sm:p-6">
  <h3 class="font-semibold text-sky-950">Open roles</h3>
  <p class="mt-0.5 text-sm text-slate-600">97 jobs across 10 pages</p>
  <nav aria-label="Pagination" class="mt-5">
    <ul class="flex items-center gap-2 sm:justify-center sm:gap-1">
      <li class="flex-1 sm:flex-none"><a id="tpg-mf-prev" href="?page=3" class="flex h-11 items-center justify-center gap-1 rounded-lg px-3 text-sm font-semibold text-sky-900 ring-1 ring-sky-200 hover:bg-sky-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700 aria-disabled:cursor-not-allowed aria-disabled:text-sky-900/40 aria-disabled:hover:bg-transparent sm:h-10"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" class="size-4" aria-hidden="true"><path d="m12 5-5 5 5 5" stroke-linecap="round" stroke-linejoin="round"/></svg>Previous</a></li>
      <li class="shrink-0 sm:hidden"><p id="tpg-mf-text" class="px-1 text-sm font-medium text-slate-700 tabular-nums">Page 4 of 10</p></li>
      <li class="hidden sm:block"><a href="?page=1" data-page="1" class="tpg-mf-num inline-flex size-10 items-center justify-center rounded-lg text-sm font-medium text-slate-700 tabular-nums hover:bg-sky-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700 aria-[current=page]:bg-sky-700 aria-[current=page]:text-white"><span class="sr-only">Page </span>1</a></li>
      <li class="hidden sm:block"><a href="?page=2" data-page="2" class="tpg-mf-num inline-flex size-10 items-center justify-center rounded-lg text-sm font-medium text-slate-700 tabular-nums hover:bg-sky-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700 aria-[current=page]:bg-sky-700 aria-[current=page]:text-white"><span class="sr-only">Page </span>2</a></li>
      <li class="hidden sm:block"><a href="?page=3" data-page="3" class="tpg-mf-num inline-flex size-10 items-center justify-center rounded-lg text-sm font-medium text-slate-700 tabular-nums hover:bg-sky-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700 aria-[current=page]:bg-sky-700 aria-[current=page]:text-white"><span class="sr-only">Page </span>3</a></li>
      <li class="hidden sm:block"><a href="?page=4" data-page="4" aria-current="page" class="tpg-mf-num inline-flex size-10 items-center justify-center rounded-lg text-sm font-medium text-slate-700 tabular-nums hover:bg-sky-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700 aria-[current=page]:bg-sky-700 aria-[current=page]:text-white"><span class="sr-only">Page </span>4</a></li>
      <li class="hidden sm:block"><a href="?page=5" data-page="5" class="tpg-mf-num inline-flex size-10 items-center justify-center rounded-lg text-sm font-medium text-slate-700 tabular-nums hover:bg-sky-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700 aria-[current=page]:bg-sky-700 aria-[current=page]:text-white"><span class="sr-only">Page </span>5</a></li>
      <li class="hidden sm:block"><a href="?page=6" data-page="6" class="tpg-mf-num inline-flex size-10 items-center justify-center rounded-lg text-sm font-medium text-slate-700 tabular-nums hover:bg-sky-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700 aria-[current=page]:bg-sky-700 aria-[current=page]:text-white"><span class="sr-only">Page </span>6</a></li>
      <li class="hidden sm:block"><a href="?page=7" data-page="7" class="tpg-mf-num inline-flex size-10 items-center justify-center rounded-lg text-sm font-medium text-slate-700 tabular-nums hover:bg-sky-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700 aria-[current=page]:bg-sky-700 aria-[current=page]:text-white"><span class="sr-only">Page </span>7</a></li>
      <li class="hidden sm:block"><a href="?page=8" data-page="8" class="tpg-mf-num inline-flex size-10 items-center justify-center rounded-lg text-sm font-medium text-slate-700 tabular-nums hover:bg-sky-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700 aria-[current=page]:bg-sky-700 aria-[current=page]:text-white"><span class="sr-only">Page </span>8</a></li>
      <li class="hidden sm:block"><a href="?page=9" data-page="9" class="tpg-mf-num inline-flex size-10 items-center justify-center rounded-lg text-sm font-medium text-slate-700 tabular-nums hover:bg-sky-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700 aria-[current=page]:bg-sky-700 aria-[current=page]:text-white"><span class="sr-only">Page </span>9</a></li>
      <li class="hidden sm:block"><a href="?page=10" data-page="10" class="tpg-mf-num inline-flex size-10 items-center justify-center rounded-lg text-sm font-medium text-slate-700 tabular-nums hover:bg-sky-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700 aria-[current=page]:bg-sky-700 aria-[current=page]:text-white"><span class="sr-only">Page </span>10</a></li>
      <li class="flex-1 sm:flex-none"><a id="tpg-mf-next" href="?page=5" class="flex h-11 items-center justify-center gap-1 rounded-lg px-3 text-sm font-semibold text-sky-900 ring-1 ring-sky-200 hover:bg-sky-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700 aria-disabled:cursor-not-allowed aria-disabled:text-sky-900/40 aria-disabled:hover:bg-transparent sm:h-10">Next<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" class="size-4" aria-hidden="true"><path d="m8 5 5 5-5 5" stroke-linecap="round" stroke-linejoin="round"/></svg></a></li>
    </ul>
  </nav>
</div>

Stepper-style numbered pages for a long form

  • aria-current="step"
  • group-data-done:
  • aria-[current=step]:ring-4
  • group-last:hidden

Click a step number or use Back and Continue.

A multi-page survey uses numbered pages as a stepper. Each item is a group: data-done on the list item swaps the number for a check icon and fills the connector line through group-data-done:, the current section gets aria-current="step" and a wider ring from aria-[current=step]:ring-4, and hover:ring-violet-400 plus focus-visible:outline-* cover pointer and keyboard. group-last:hidden drops the connector after the final step.

View code
<div class="w-full max-w-xl rounded-2xl bg-white p-5 shadow-sm ring-1 ring-violet-950/10 sm:p-7">
  <nav aria-label="Survey sections">
    <ol class="flex items-start">
      <li class="tpg-st-item group relative flex flex-1 flex-col items-center" data-done>
        <span class="absolute top-5 left-1/2 h-0.5 w-full -translate-y-1/2 bg-violet-100 group-last:hidden group-data-done:bg-violet-600" aria-hidden="true"></span>
        <button type="button" data-step="1" class="tpg-st-btn relative grid size-10 cursor-pointer place-items-center rounded-full bg-white text-sm font-semibold text-violet-900 ring-2 ring-violet-200 hover:ring-violet-400 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-violet-700 aria-[current=step]:ring-4 aria-[current=step]:ring-violet-600 group-data-done:bg-violet-600 group-data-done:text-white group-data-done:ring-violet-600 group-data-done:hover:bg-violet-700"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2.5" class="hidden size-4 group-data-done:block" aria-hidden="true"><path d="m5 10 3.5 3.5L15 7" stroke-linecap="round" stroke-linejoin="round"/></svg><span class="group-data-done:sr-only"><span class="sr-only">Section </span>1</span><span class="tpg-st-state sr-only">, completed</span></button>
        <span class="mt-2 text-xs font-medium text-slate-600">Visit</span>
      </li>
      <li class="tpg-st-item group relative flex flex-1 flex-col items-center" data-done>
        <span class="absolute top-5 left-1/2 h-0.5 w-full -translate-y-1/2 bg-violet-100 group-last:hidden group-data-done:bg-violet-600" aria-hidden="true"></span>
        <button type="button" data-step="2" class="tpg-st-btn relative grid size-10 cursor-pointer place-items-center rounded-full bg-white text-sm font-semibold text-violet-900 ring-2 ring-violet-200 hover:ring-violet-400 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-violet-700 aria-[current=step]:ring-4 aria-[current=step]:ring-violet-600 group-data-done:bg-violet-600 group-data-done:text-white group-data-done:ring-violet-600 group-data-done:hover:bg-violet-700"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2.5" class="hidden size-4 group-data-done:block" aria-hidden="true"><path d="m5 10 3.5 3.5L15 7" stroke-linecap="round" stroke-linejoin="round"/></svg><span class="group-data-done:sr-only"><span class="sr-only">Section </span>2</span><span class="tpg-st-state sr-only">, completed</span></button>
        <span class="mt-2 text-xs font-medium text-slate-600">Staff</span>
      </li>
      <li class="tpg-st-item group relative flex flex-1 flex-col items-center">
        <span class="absolute top-5 left-1/2 h-0.5 w-full -translate-y-1/2 bg-violet-100 group-last:hidden group-data-done:bg-violet-600" aria-hidden="true"></span>
        <button type="button" data-step="3" aria-current="step" class="tpg-st-btn relative grid size-10 cursor-pointer place-items-center rounded-full bg-white text-sm font-semibold text-violet-900 ring-2 ring-violet-200 hover:ring-violet-400 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-violet-700 aria-[current=step]:ring-4 aria-[current=step]:ring-violet-600 group-data-done:bg-violet-600 group-data-done:text-white group-data-done:ring-violet-600 group-data-done:hover:bg-violet-700"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2.5" class="hidden size-4 group-data-done:block" aria-hidden="true"><path d="m5 10 3.5 3.5L15 7" stroke-linecap="round" stroke-linejoin="round"/></svg><span class="group-data-done:sr-only"><span class="sr-only">Section </span>3</span><span class="tpg-st-state sr-only"></span></button>
        <span class="mt-2 text-xs font-medium text-slate-600">Food</span>
      </li>
      <li class="tpg-st-item group relative flex flex-1 flex-col items-center">
        <span class="absolute top-5 left-1/2 h-0.5 w-full -translate-y-1/2 bg-violet-100 group-last:hidden group-data-done:bg-violet-600" aria-hidden="true"></span>
        <button type="button" data-step="4" class="tpg-st-btn relative grid size-10 cursor-pointer place-items-center rounded-full bg-white text-sm font-semibold text-violet-900 ring-2 ring-violet-200 hover:ring-violet-400 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-violet-700 aria-[current=step]:ring-4 aria-[current=step]:ring-violet-600 group-data-done:bg-violet-600 group-data-done:text-white group-data-done:ring-violet-600 group-data-done:hover:bg-violet-700"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2.5" class="hidden size-4 group-data-done:block" aria-hidden="true"><path d="m5 10 3.5 3.5L15 7" stroke-linecap="round" stroke-linejoin="round"/></svg><span class="group-data-done:sr-only"><span class="sr-only">Section </span>4</span><span class="tpg-st-state sr-only"></span></button>
        <span class="mt-2 text-xs font-medium text-slate-600">Value</span>
      </li>
      <li class="tpg-st-item group relative flex flex-1 flex-col items-center">
        <span class="absolute top-5 left-1/2 h-0.5 w-full -translate-y-1/2 bg-violet-100 group-last:hidden group-data-done:bg-violet-600" aria-hidden="true"></span>
        <button type="button" data-step="5" class="tpg-st-btn relative grid size-10 cursor-pointer place-items-center rounded-full bg-white text-sm font-semibold text-violet-900 ring-2 ring-violet-200 hover:ring-violet-400 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-violet-700 aria-[current=step]:ring-4 aria-[current=step]:ring-violet-600 group-data-done:bg-violet-600 group-data-done:text-white group-data-done:ring-violet-600 group-data-done:hover:bg-violet-700"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2.5" class="hidden size-4 group-data-done:block" aria-hidden="true"><path d="m5 10 3.5 3.5L15 7" stroke-linecap="round" stroke-linejoin="round"/></svg><span class="group-data-done:sr-only"><span class="sr-only">Section </span>5</span><span class="tpg-st-state sr-only"></span></button>
        <span class="mt-2 text-xs font-medium text-slate-600">Extras</span>
      </li>
    </ol>
  </nav>
  <div class="mt-7 rounded-xl bg-violet-50 p-4">
    <h3 id="tpg-st-title" class="font-semibold text-violet-950">Section 3 of 5: Food and drink</h3>
    <p id="tpg-st-text" class="mt-1 text-sm text-slate-700">How would you rate the temperature and presentation of your meal?</p>
  </div>
  <div class="mt-5 flex justify-between gap-3">
    <button id="tpg-st-back" type="button" class="h-11 cursor-pointer rounded-lg px-4 text-sm font-semibold text-violet-900 ring-1 ring-violet-200 hover:bg-violet-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-700 disabled:cursor-not-allowed disabled:opacity-40">Back</button>
    <button id="tpg-st-next" type="button" class="h-11 cursor-pointer rounded-lg bg-violet-700 px-5 text-sm font-semibold text-white hover:bg-violet-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-700 disabled:cursor-not-allowed disabled:opacity-40">Continue</button>
  </div>
</div>

Tables, load more and infinite scroll

Pagination where it usually lives in apps: a table footer with a range summary and rows per page, a load more grid that moves focus, an infinite list with a fallback button and cursor-based Newer and Older requests.

Table footer with range summary and rows per page

  • sticky top-0
  • hidden sm:table-cell
  • disabled:
  • aria-live

Change Rows per page or step through the pages.

The footer shows Showing 21 to 30 of 97 from the usual math: first = (page - 1) x size + 1, last = min(page x size, total). Changing rows per page keeps the first visible order on screen by recalculating the page. Rows are built with createElement, the status badge classes are full strings in the JS, the head is sticky inside a capped scroll region for the 50-row setting, and First and Last hide below sm: (hidden sm:grid) to keep the footer on one line.

View code
<div class="w-full max-w-2xl overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-neutral-200">
  <h3 id="tpg-tb-h" class="border-b border-neutral-200 px-4 py-3 text-base font-semibold text-neutral-900">Orders</h3>
  <div class="max-h-[26rem] overflow-y-auto focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-blue-700" tabindex="0" role="region" aria-labelledby="tpg-tb-h">
    <table class="w-full text-left text-sm" aria-labelledby="tpg-tb-h">
      <thead class="sticky top-0 bg-neutral-50 text-xs font-semibold text-neutral-600">
        <tr><th scope="col" class="px-4 py-2.5">Order</th><th scope="col" class="hidden px-3 py-2.5 sm:table-cell">Customer</th><th scope="col" class="hidden px-3 py-2.5 md:table-cell">Date</th><th scope="col" class="px-3 py-2.5">Status</th><th scope="col" class="px-4 py-2.5 text-right">Total</th></tr>
      </thead>
      <tbody id="tpg-tb-body" class="divide-y divide-neutral-100 tabular-nums"></tbody>
    </table>
  </div>
  <div class="flex flex-col gap-3 border-t border-neutral-200 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
    <div class="flex items-center gap-2">
      <label for="tpg-tb-size" class="text-sm text-neutral-700">Rows per page</label>
      <span class="relative">
        <select id="tpg-tb-size" class="h-9 cursor-pointer appearance-none rounded-md bg-white pr-8 pl-3 text-sm text-neutral-900 ring-1 ring-neutral-300 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-700">
          <option selected>10</option>
          <option>25</option>
          <option>50</option>
        </select>
        <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" class="pointer-events-none absolute top-1/2 right-2.5 size-4 -translate-y-1/2 text-neutral-500" aria-hidden="true"><path d="m6 8 4 4 4-4" stroke-linecap="round" stroke-linejoin="round"/></svg>
      </span>
    </div>
    <div class="flex items-center justify-between gap-3">
      <p id="tpg-tb-info" aria-live="polite" class="text-sm whitespace-nowrap text-neutral-700 tabular-nums">Showing 21&ndash;30 of 97</p>
      <nav aria-label="Orders pages" class="flex items-center gap-1">
        <button id="tpg-tb-first" type="button" aria-label="First page" class="hidden size-9 sm:grid cursor-pointer place-items-center rounded-md text-neutral-700 hover:bg-neutral-100 focus-visible:outline-2 focus-visible:outline-blue-700 disabled:cursor-not-allowed disabled:text-neutral-300 disabled:hover:bg-transparent"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" class="size-4" aria-hidden="true"><path d="m10 5-5 5 5 5M15 5l-5 5 5 5" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
        <button id="tpg-tb-prev" type="button" aria-label="Previous page" class="grid size-9 cursor-pointer place-items-center rounded-md text-neutral-700 hover:bg-neutral-100 focus-visible:outline-2 focus-visible:outline-blue-700 disabled:cursor-not-allowed disabled:text-neutral-300 disabled:hover:bg-transparent"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" class="size-4" aria-hidden="true"><path d="m12 5-5 5 5 5" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
        <button id="tpg-tb-next" type="button" aria-label="Next page" class="grid size-9 cursor-pointer place-items-center rounded-md text-neutral-700 hover:bg-neutral-100 focus-visible:outline-2 focus-visible:outline-blue-700 disabled:cursor-not-allowed disabled:text-neutral-300 disabled:hover:bg-transparent"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" class="size-4" aria-hidden="true"><path d="m8 5 5 5-5 5" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
        <button id="tpg-tb-last" type="button" aria-label="Last page" class="hidden size-9 sm:grid cursor-pointer place-items-center rounded-md text-neutral-700 hover:bg-neutral-100 focus-visible:outline-2 focus-visible:outline-blue-700 disabled:cursor-not-allowed disabled:text-neutral-300 disabled:hover:bg-transparent"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" class="size-4" aria-hidden="true"><path d="m5 5 5 5-5 5M10 5l5 5-5 5" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
      </nav>
    </div>
  </div>
</div>

Load more with a live count and focus on new items

  • hidden attribute
  • aria-live
  • tabindex="-1"
  • focus:outline-2

Click Load more.

All prints are in the markup and the later ones carry the hidden attribute, which Tailwind preflight keeps at display: none even on elements with a display utility. Each click reveals four more, updates a polite live region (Showing 8 of 12 prints) and a progress bar, and moves focus to the first new card (tabindex="-1" with focus:outline-2), so keyboard and screen reader users land on fresh content instead of the button.

View code
<div class="w-full max-w-3xl">
  <div class="flex items-baseline justify-between gap-3">
    <h3 class="text-lg font-semibold text-stone-900">Landscape prints</h3>
    <p class="text-sm text-stone-600">A3, archival paper</p>
  </div>
  <ul class="mt-4 grid grid-cols-2 gap-3 sm:gap-4 md:grid-cols-4">
    <li class="tpg-lm-item rounded-xl bg-white p-2 shadow-sm ring-1 ring-stone-200 focus:outline-2 focus:outline-offset-2 focus:outline-stone-900" tabindex="-1"><img src="fjord.webp" width="800" height="600" alt="Deep blue fjord winding between steep rocky cliffs" loading="lazy" class="aspect-[4/3] w-full rounded-lg object-cover"><p class="mt-2 text-sm font-medium text-stone-900">Fjord Light</p><p class="text-sm text-stone-600">$48</p></li>
    <li class="tpg-lm-item rounded-xl bg-white p-2 shadow-sm ring-1 ring-stone-200 focus:outline-2 focus:outline-offset-2 focus:outline-stone-900" tabindex="-1"><img src="red-rock-canyon.webp" width="800" height="600" alt="Red rock canyon rim glowing in low evening sun" loading="lazy" class="aspect-[4/3] w-full rounded-lg object-cover"><p class="mt-2 text-sm font-medium text-stone-900">Canyon Rim</p><p class="text-sm text-stone-600">$52</p></li>
    <li class="tpg-lm-item rounded-xl bg-white p-2 shadow-sm ring-1 ring-stone-200 focus:outline-2 focus:outline-offset-2 focus:outline-stone-900" tabindex="-1"><img src="highland-road.webp" width="800" height="600" alt="Narrow road curving through green highland hills" loading="lazy" class="aspect-[4/3] w-full rounded-lg object-cover"><p class="mt-2 text-sm font-medium text-stone-900">Highland Pass</p><p class="text-sm text-stone-600">$44</p></li>
    <li class="tpg-lm-item rounded-xl bg-white p-2 shadow-sm ring-1 ring-stone-200 focus:outline-2 focus:outline-offset-2 focus:outline-stone-900" tabindex="-1"><img src="forest-waterfall.webp" width="800" height="600" alt="Tall waterfall dropping into a mossy green gorge" loading="lazy" class="aspect-[4/3] w-full rounded-lg object-cover"><p class="mt-2 text-sm font-medium text-stone-900">Falls in Fern</p><p class="text-sm text-stone-600">$48</p></li>
    <li hidden class="tpg-lm-item rounded-xl bg-white p-2 shadow-sm ring-1 ring-stone-200 focus:outline-2 focus:outline-offset-2 focus:outline-stone-900" tabindex="-1"><img src="granite-valley.webp" width="800" height="600" alt="Granite cliffs rising above a pine forest and a shallow river" loading="lazy" class="aspect-[4/3] w-full rounded-lg object-cover"><p class="mt-2 text-sm font-medium text-stone-900">Granite Valley</p><p class="text-sm text-stone-600">$56</p></li>
    <li hidden class="tpg-lm-item rounded-xl bg-white p-2 shadow-sm ring-1 ring-stone-200 focus:outline-2 focus:outline-offset-2 focus:outline-stone-900" tabindex="-1"><img src="brick-alley.webp" width="800" height="600" alt="Narrow brick alley with fire escapes and graffiti" loading="lazy" class="aspect-[4/3] w-full rounded-lg object-cover"><p class="mt-2 text-sm font-medium text-stone-900">Back Alley</p><p class="text-sm text-stone-600">$40</p></li>
    <li hidden class="tpg-lm-item rounded-xl bg-white p-2 shadow-sm ring-1 ring-stone-200 focus:outline-2 focus:outline-offset-2 focus:outline-stone-900" tabindex="-1"><img src="city-skyline.webp" width="800" height="600" alt="Dense city skyline under a hazy golden sky" loading="lazy" class="aspect-[4/3] w-full rounded-lg object-cover"><p class="mt-2 text-sm font-medium text-stone-900">Golden Hour City</p><p class="text-sm text-stone-600">$52</p></li>
    <li hidden class="tpg-lm-item rounded-xl bg-white p-2 shadow-sm ring-1 ring-stone-200 focus:outline-2 focus:outline-offset-2 focus:outline-stone-900" tabindex="-1"><img src="jellyfish.webp" width="800" height="600" alt="Orange jellyfish drifting in deep blue water" loading="lazy" class="aspect-[4/3] w-full rounded-lg object-cover"><p class="mt-2 text-sm font-medium text-stone-900">Drift</p><p class="text-sm text-stone-600">$44</p></li>
    <li hidden class="tpg-lm-item rounded-xl bg-white p-2 shadow-sm ring-1 ring-stone-200 focus:outline-2 focus:outline-offset-2 focus:outline-stone-900" tabindex="-1"><img src="canal-houses.webp" width="800" height="600" alt="Painted houses and small boats along a quiet canal" loading="lazy" class="aspect-[4/3] w-full rounded-lg object-cover"><p class="mt-2 text-sm font-medium text-stone-900">Canal Row</p><p class="text-sm text-stone-600">$48</p></li>
    <li hidden class="tpg-lm-item rounded-xl bg-white p-2 shadow-sm ring-1 ring-stone-200 focus:outline-2 focus:outline-offset-2 focus:outline-stone-900" tabindex="-1"><img src="mountain-hiker.webp" width="800" height="600" alt="Hiker with a backpack looking over misty mountain ridges" loading="lazy" class="aspect-[4/3] w-full rounded-lg object-cover"><p class="mt-2 text-sm font-medium text-stone-900">Above the Mist</p><p class="text-sm text-stone-600">$56</p></li>
    <li hidden class="tpg-lm-item rounded-xl bg-white p-2 shadow-sm ring-1 ring-stone-200 focus:outline-2 focus:outline-offset-2 focus:outline-stone-900" tabindex="-1"><img src="snow-camp.webp" width="800" height="600" alt="Yellow tents pitched in snow below jagged peaks" loading="lazy" class="aspect-[4/3] w-full rounded-lg object-cover"><p class="mt-2 text-sm font-medium text-stone-900">Base Camp</p><p class="text-sm text-stone-600">$52</p></li>
    <li hidden class="tpg-lm-item rounded-xl bg-white p-2 shadow-sm ring-1 ring-stone-200 focus:outline-2 focus:outline-offset-2 focus:outline-stone-900" tabindex="-1"><img src="coast-road.webp" width="800" height="600" alt="Green headland dropping into a calm blue sea" loading="lazy" class="aspect-[4/3] w-full rounded-lg object-cover"><p class="mt-2 text-sm font-medium text-stone-900">Coast Road</p><p class="text-sm text-stone-600">$48</p></li>
  </ul>
  <div class="mx-auto mt-6 flex max-w-xs flex-col items-center gap-3">
    <p id="tpg-lm-count" aria-live="polite" class="text-sm text-stone-700 tabular-nums">Showing 4 of 12 prints</p>
    <span class="block h-1 w-full overflow-hidden rounded-full bg-stone-200" aria-hidden="true"><span id="tpg-lm-bar" class="block h-full w-1/3 rounded-full bg-stone-800"></span></span>
    <button id="tpg-lm-more" type="button" class="h-11 w-full cursor-pointer rounded-full bg-stone-900 px-5 text-sm font-semibold text-white hover:bg-stone-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-stone-900">Load 4 more</button>
  </div>
</div>

Infinite list with a sentinel and a fallback button

  • IntersectionObserver
  • overflow-y-auto
  • overscroll-contain
  • motion-safe:animate-spin

Scroll inside the list, or use the button.

An IntersectionObserver watches an empty sentinel at the end of the scroll box (root is the box, with an 80px bottom margin), and each hit appends ten threads with createElement. A visible Load more threads button calls the same function, so keyboard users and browsers without the observer can still reach every item, and the count line says how many are loaded. overscroll-contain stops the page from scrolling once the box hits its end.

View code
<div class="w-full max-w-md overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-neutral-200">
  <div class="flex items-center justify-between gap-3 border-b border-neutral-200 px-4 py-3">
    <h3 class="font-semibold text-neutral-900">Latest threads</h3>
    <p id="tpg-if-count" aria-live="polite" class="text-xs text-neutral-600 tabular-nums">10 of 50 loaded</p>
  </div>
  <div id="tpg-if-box" class="h-80 overflow-y-auto overscroll-contain focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-fuchsia-700" tabindex="0" role="region" aria-label="Thread list">
    <ul id="tpg-if-list" class="divide-y divide-neutral-100"></ul>
    <div id="tpg-if-sentinel" class="h-px" aria-hidden="true"></div>
    <p id="tpg-if-loading" hidden class="flex items-center justify-center gap-2 py-4 text-sm text-neutral-600"><svg viewBox="0 0 24 24" fill="none" class="size-4 motion-safe:animate-spin" aria-hidden="true"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="3" class="opacity-25"/><path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" stroke-width="3" stroke-linecap="round"/></svg>Loading more threads</p>
    <p id="tpg-if-end" hidden class="py-4 text-center text-sm text-neutral-600">You have reached the end.</p>
  </div>
  <div class="border-t border-neutral-200 p-3">
    <button id="tpg-if-more" type="button" class="h-11 w-full cursor-pointer rounded-lg text-sm font-semibold text-fuchsia-800 ring-1 ring-fuchsia-200 hover:bg-fuchsia-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-fuchsia-700 disabled:cursor-not-allowed disabled:text-neutral-400 disabled:ring-neutral-200 disabled:hover:bg-transparent">Load more threads</button>
  </div>
</div>

Cursor-based Newer and Older pagination

  • font-mono
  • break-all
  • disabled:
  • aria-live

Click Older, then Newer.

Cursor APIs return a page plus an id to continue from, not a page count, so the pager has only Newer and Older. The request line shows the before= or after= cursor taken from the last or first row, Newer is disabled on the first batch, and a polite live region announces each move. Status uses a colored dot plus the word Ready or Failed, so color is never the only signal.

View code
<div class="w-full max-w-xl rounded-2xl bg-neutral-900 p-4 ring-1 ring-white/10 sm:p-6">
  <div class="flex items-baseline justify-between gap-3">
    <h3 class="font-semibold text-white">Deploys</h3>
    <p class="text-xs text-neutral-400">6 per request</p>
  </div>
  <p class="mt-3 rounded-lg bg-black/40 px-3 py-2 font-mono text-xs break-all text-emerald-300"><span class="text-neutral-400">GET</span> /api/deploys?limit=6<span id="tpg-cu-cursor"></span></p>
  <ul id="tpg-cu-list" class="mt-3 divide-y divide-white/5"></ul>
  <p id="tpg-cu-status" aria-live="polite" class="sr-only"></p>
  <nav aria-label="Deploy history" class="mt-4 flex justify-between gap-3">
    <button id="tpg-cu-newer" type="button" class="inline-flex h-11 cursor-pointer items-center gap-1.5 rounded-lg px-4 text-sm font-semibold text-neutral-100 ring-1 ring-white/15 hover:bg-white/5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-300 disabled:cursor-not-allowed disabled:text-neutral-500 disabled:ring-white/5 disabled:hover:bg-transparent"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" class="size-4" aria-hidden="true"><path d="m12 5-5 5 5 5" stroke-linecap="round" stroke-linejoin="round"/></svg>Newer</button>
    <button id="tpg-cu-older" type="button" class="inline-flex h-11 cursor-pointer items-center gap-1.5 rounded-lg px-4 text-sm font-semibold text-neutral-100 ring-1 ring-white/15 hover:bg-white/5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-300 disabled:cursor-not-allowed disabled:text-neutral-500 disabled:ring-white/5 disabled:hover:bg-transparent">Older<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" class="size-4" aria-hidden="true"><path d="m8 5 5 5-5 5" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
  </nav>
</div>

Dots, touch targets, dark mode and @theme

Carousel dots, 44px touch targets, a pager that switches with the dark: variant and an A to Z index built on prefixed @theme tokens and one @utility.

Carousel dot pagination

  • snap-x snap-mandatory
  • group-aria-[current=true]:w-6
  • motion-safe:transition-all
  • [scrollbar-width:none]

Click a dot or swipe the photos.

The slides are a snap-x snap-mandatory scroll row, and each dot is a button at least 24px tall with a small span inside, so the visible dot can stay tiny while the target passes WCAG 2.5.8. The active button carries aria-current="true" and group-aria-[current=true]:w-6 stretches its dot into a bar. An IntersectionObserver keeps the dots in sync when people swipe, and scrollTo uses smooth only when reduced motion is off.

View code
<section aria-roledescription="carousel" aria-label="Featured trips" class="w-full max-w-xl">
  <div class="relative overflow-hidden rounded-2xl bg-slate-900">
    <ul id="tpg-dt-track" class="relative flex snap-x snap-mandatory overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
      <li class="tpg-dt-slide relative w-full shrink-0 snap-start" role="group" aria-roledescription="slide" aria-label="1 of 5"><img src="snow-camp.webp" width="800" height="600" alt="Yellow tents pitched in snow below jagged peaks" class="aspect-[4/3] w-full object-cover"><p class="absolute inset-x-0 bottom-0 bg-slate-950/75 px-4 pt-3 pb-10 text-white"><span class="block font-semibold">Winter base camp</span><span class="text-sm text-slate-200">6 days, guided</span></p></li>
      <li class="tpg-dt-slide relative w-full shrink-0 snap-start" role="group" aria-roledescription="slide" aria-label="2 of 5"><img src="coast-road.webp" width="800" height="600" alt="Green headland dropping into a calm blue sea" loading="lazy" class="aspect-[4/3] w-full object-cover"><p class="absolute inset-x-0 bottom-0 bg-slate-950/75 px-4 pt-3 pb-10 text-white"><span class="block font-semibold">Coastal ridge walk</span><span class="text-sm text-slate-200">3 days, self-guided</span></p></li>
      <li class="tpg-dt-slide relative w-full shrink-0 snap-start" role="group" aria-roledescription="slide" aria-label="3 of 5"><img src="granite-valley.webp" width="800" height="600" alt="Granite cliffs rising above a pine forest and a shallow river" loading="lazy" class="aspect-[4/3] w-full object-cover"><p class="absolute inset-x-0 bottom-0 bg-slate-950/75 px-4 pt-3 pb-10 text-white"><span class="block font-semibold">Granite valley loop</span><span class="text-sm text-slate-200">4 days, guided</span></p></li>
      <li class="tpg-dt-slide relative w-full shrink-0 snap-start" role="group" aria-roledescription="slide" aria-label="4 of 5"><img src="canal-houses.webp" width="800" height="600" alt="Painted houses and small boats along a quiet canal" loading="lazy" class="aspect-[4/3] w-full object-cover"><p class="absolute inset-x-0 bottom-0 bg-slate-950/75 px-4 pt-3 pb-10 text-white"><span class="block font-semibold">Canal towns by bike</span><span class="text-sm text-slate-200">5 days, self-guided</span></p></li>
      <li class="tpg-dt-slide relative w-full shrink-0 snap-start" role="group" aria-roledescription="slide" aria-label="5 of 5"><img src="mountain-hiker.webp" width="800" height="600" alt="Hiker with a backpack looking over misty mountain ridges" loading="lazy" class="aspect-[4/3] w-full object-cover"><p class="absolute inset-x-0 bottom-0 bg-slate-950/75 px-4 pt-3 pb-10 text-white"><span class="block font-semibold">High ridges traverse</span><span class="text-sm text-slate-200">7 days, guided</span></p></li>
    </ul>
    <div class="absolute inset-x-0 bottom-3 flex justify-center gap-1">
      <button type="button" class="tpg-dt-dot group grid h-6 min-w-6 cursor-pointer place-items-center rounded-full px-1 focus-visible:outline-2 focus-visible:outline-white" aria-label="Slide 1" aria-current="true"><span class="block h-2 w-2 rounded-full bg-white/55 motion-safe:transition-all group-hover:bg-white/80 group-aria-[current=true]:w-6 group-aria-[current=true]:bg-white"></span></button>
      <button type="button" class="tpg-dt-dot group grid h-6 min-w-6 cursor-pointer place-items-center rounded-full px-1 focus-visible:outline-2 focus-visible:outline-white" aria-label="Slide 2"><span class="block h-2 w-2 rounded-full bg-white/55 motion-safe:transition-all group-hover:bg-white/80 group-aria-[current=true]:w-6 group-aria-[current=true]:bg-white"></span></button>
      <button type="button" class="tpg-dt-dot group grid h-6 min-w-6 cursor-pointer place-items-center rounded-full px-1 focus-visible:outline-2 focus-visible:outline-white" aria-label="Slide 3"><span class="block h-2 w-2 rounded-full bg-white/55 motion-safe:transition-all group-hover:bg-white/80 group-aria-[current=true]:w-6 group-aria-[current=true]:bg-white"></span></button>
      <button type="button" class="tpg-dt-dot group grid h-6 min-w-6 cursor-pointer place-items-center rounded-full px-1 focus-visible:outline-2 focus-visible:outline-white" aria-label="Slide 4"><span class="block h-2 w-2 rounded-full bg-white/55 motion-safe:transition-all group-hover:bg-white/80 group-aria-[current=true]:w-6 group-aria-[current=true]:bg-white"></span></button>
      <button type="button" class="tpg-dt-dot group grid h-6 min-w-6 cursor-pointer place-items-center rounded-full px-1 focus-visible:outline-2 focus-visible:outline-white" aria-label="Slide 5"><span class="block h-2 w-2 rounded-full bg-white/55 motion-safe:transition-all group-hover:bg-white/80 group-aria-[current=true]:w-6 group-aria-[current=true]:bg-white"></span></button>
    </div>
    <button id="tpg-dt-prev" type="button" aria-label="Previous slide" class="absolute top-1/3 left-3 grid size-10 cursor-pointer place-items-center rounded-full bg-white/90 text-slate-900 shadow hover:bg-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" class="size-5" aria-hidden="true"><path d="m12 5-5 5 5 5" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
    <button id="tpg-dt-next" type="button" aria-label="Next slide" class="absolute top-1/3 right-3 grid size-10 cursor-pointer place-items-center rounded-full bg-white/90 text-slate-900 shadow hover:bg-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" class="size-5" aria-hidden="true"><path d="m8 5 5 5-5 5" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
  </div>
</section>

Large 44px touch targets

  • size-11
  • pointer-coarse:size-12
  • group-data-show:outline-dashed
  • aria-pressed:

Press Show targets, then step through the chapters.

Every control is at least size-11 (44px), with gap-2 between them, which clears both WCAG 2.5.8 (24px) and the 44px of 2.5.5. pointer-coarse:size-12 grows the chapter numbers again on touch screens. Show targets toggles data-show on the card, and group-data-show:outline-dashed draws each hit area so you can check spacing by eye.

View code
<div id="tpg-tt" class="group w-full max-w-md rounded-3xl bg-white p-5 shadow-sm ring-1 ring-green-950/10">
  <div class="flex items-start justify-between gap-3">
    <div>
      <h3 class="font-semibold text-green-950">The Lighthouse Keeper</h3>
      <p id="tpg-tt-sub" aria-live="polite" class="text-sm text-neutral-600 tabular-nums">Chapter 7 of 24</p>
    </div>
    <button id="tpg-tt-show" type="button" aria-pressed="false" class="h-11 shrink-0 cursor-pointer rounded-full px-4 text-sm font-medium text-green-900 ring-1 ring-green-200 hover:bg-green-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-green-800 aria-pressed:bg-green-800 aria-pressed:text-white aria-pressed:ring-green-800">Show targets</button>
  </div>
  <nav aria-label="Chapters" class="mt-5">
    <ul class="flex items-center justify-between gap-2">
      <li><a id="tpg-tt-prev" href="?chapter=6" aria-label="Previous chapter" class="grid size-11 place-items-center rounded-full text-green-900 ring-1 ring-green-200 hover:bg-green-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-green-800 aria-disabled:cursor-not-allowed aria-disabled:text-green-900/35 group-data-show:outline-2 group-data-show:outline-offset-0 group-data-show:outline-pink-600 group-data-show:outline-dashed"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" class="size-5" aria-hidden="true"><path d="m12 5-5 5 5 5" stroke-linecap="round" stroke-linejoin="round"/></svg></a></li>
      <li class="flex gap-2">
        <a href="?chapter=6" class="tpg-tt-num grid size-11 place-items-center rounded-full text-base font-semibold text-green-900 tabular-nums hover:bg-green-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-green-800 aria-[current=page]:bg-green-800 aria-[current=page]:text-white pointer-coarse:size-12 group-data-show:outline-2 group-data-show:outline-offset-0 group-data-show:outline-pink-600 group-data-show:outline-dashed"><span class="sr-only">Chapter </span><span class="tpg-tt-n">6</span></a>
        <a href="?chapter=7" aria-current="page" class="tpg-tt-num grid size-11 place-items-center rounded-full text-base font-semibold text-green-900 tabular-nums hover:bg-green-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-green-800 aria-[current=page]:bg-green-800 aria-[current=page]:text-white pointer-coarse:size-12 group-data-show:outline-2 group-data-show:outline-offset-0 group-data-show:outline-pink-600 group-data-show:outline-dashed"><span class="sr-only">Chapter </span><span class="tpg-tt-n">7</span></a>
        <a href="?chapter=8" class="tpg-tt-num grid size-11 place-items-center rounded-full text-base font-semibold text-green-900 tabular-nums hover:bg-green-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-green-800 aria-[current=page]:bg-green-800 aria-[current=page]:text-white pointer-coarse:size-12 group-data-show:outline-2 group-data-show:outline-offset-0 group-data-show:outline-pink-600 group-data-show:outline-dashed"><span class="sr-only">Chapter </span><span class="tpg-tt-n">8</span></a>
      </li>
      <li><a id="tpg-tt-next" href="?chapter=8" aria-label="Next chapter" class="grid size-11 place-items-center rounded-full text-green-900 ring-1 ring-green-200 hover:bg-green-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-green-800 aria-disabled:cursor-not-allowed aria-disabled:text-green-900/35 group-data-show:outline-2 group-data-show:outline-offset-0 group-data-show:outline-pink-600 group-data-show:outline-dashed"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" class="size-5" aria-hidden="true"><path d="m8 5 5 5-5 5" stroke-linecap="round" stroke-linejoin="round"/></svg></a></li>
    </ul>
  </nav>
  <a id="tpg-tt-big" href="?chapter=8" class="mt-5 flex min-h-12 items-center justify-between gap-3 rounded-2xl bg-green-800 px-5 py-3 text-white hover:bg-green-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-green-800 group-data-show:outline-2 group-data-show:outline-offset-0 group-data-show:outline-pink-600 group-data-show:outline-dashed"><span><span class="block text-xs text-green-100">Next chapter</span><span id="tpg-tt-title" class="font-semibold">The Second Lamp</span></span><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" class="size-5 shrink-0" aria-hidden="true"><path d="m8 5 5 5-5 5" stroke-linecap="round" stroke-linejoin="round"/></svg></a>
</div>

Dark mode pagination with the dark: variant

  • dark:
  • dark:aria-[current=page]:
  • @custom-variant dark
  • aria-pressed

Press the moon button.

Every link pairs its light classes with dark: ones, the current page included: dark:aria-[current=page]:bg-lime-300 gives the active page its own dark color instead of reusing the light one. The toggle adds the dark class to the card, and the @custom-variant dark line from the setup box makes dark: follow that class instead of the system setting.

View code
<div id="tpg-dk" class="w-full max-w-xl rounded-2xl bg-white p-4 shadow-sm ring-1 ring-zinc-200 sm:p-6 dark:bg-zinc-900 dark:ring-white/10">
  <div class="flex items-center justify-between gap-3">
    <div>
      <h3 class="font-semibold text-zinc-900 dark:text-white">Photo library</h3>
      <p class="text-sm text-zinc-600 dark:text-zinc-400">72 photos, page 3 of 12</p>
    </div>
    <button id="tpg-dk-toggle" type="button" aria-pressed="false" class="grid size-11 cursor-pointer place-items-center rounded-full text-zinc-700 ring-1 ring-zinc-300 hover:bg-zinc-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-900 dark:text-lime-300 dark:ring-zinc-700 dark:hover:bg-zinc-800 dark:focus-visible:outline-lime-300"><span class="sr-only">Dark mode</span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" class="size-5 dark:hidden" aria-hidden="true"><path d="M20 14.5A8 8 0 0 1 9.5 4a8 8 0 1 0 10.5 10.5Z" stroke-linejoin="round"/></svg><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" class="hidden size-5 dark:block" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" stroke-linecap="round"/></svg></button>
  </div>
  <ul class="mt-4 grid grid-cols-3 gap-2 sm:grid-cols-6">
    <li><img src="fjord.webp" width="800" height="600" alt="Fjord between rocky cliffs" loading="lazy" class="aspect-square w-full rounded-md object-cover"></li>
    <li><img src="jellyfish.webp" width="800" height="600" alt="Orange jellyfish in blue water" loading="lazy" class="aspect-square w-full rounded-md object-cover"></li>
    <li><img src="city-skyline.webp" width="800" height="600" alt="City skyline at golden hour" loading="lazy" class="aspect-square w-full rounded-md object-cover"></li>
    <li><img src="brick-alley.webp" width="800" height="600" alt="Brick alley with fire escapes" loading="lazy" class="aspect-square w-full rounded-md object-cover"></li>
    <li><img src="red-rock-canyon.webp" width="800" height="600" alt="Red rock canyon rim at sunset" loading="lazy" class="aspect-square w-full rounded-md object-cover"></li>
    <li><img src="forest-waterfall.webp" width="800" height="600" alt="Waterfall in a mossy gorge" loading="lazy" class="aspect-square w-full rounded-md object-cover"></li>
  </ul>
  <nav aria-label="Pagination" class="mt-5">
    <ul class="flex items-center justify-center gap-1">
      <li><a href="?page=2" class="inline-flex size-9 items-center justify-center rounded-lg text-zinc-700 hover:bg-zinc-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-900 dark:text-zinc-300 dark:hover:bg-zinc-800 dark:focus-visible:outline-lime-300"><span class="sr-only">Previous page</span><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" class="size-4" aria-hidden="true"><path d="m12 5-5 5 5 5" stroke-linecap="round" stroke-linejoin="round"/></svg></a></li>
      <li><a href="?page=1" class="inline-flex size-9 items-center justify-center rounded-lg text-sm font-medium text-zinc-700 tabular-nums ring-1 ring-zinc-200 hover:bg-zinc-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-900 aria-[current=page]:bg-zinc-900 aria-[current=page]:text-white aria-[current=page]:ring-zinc-900 dark:text-zinc-300 dark:ring-zinc-700 dark:hover:bg-zinc-800 dark:focus-visible:outline-lime-300 dark:aria-[current=page]:bg-lime-300 dark:aria-[current=page]:text-zinc-950 dark:aria-[current=page]:ring-lime-300"><span class="sr-only">Page </span>1</a></li>
      <li><a href="?page=2" class="inline-flex size-9 items-center justify-center rounded-lg text-sm font-medium text-zinc-700 tabular-nums ring-1 ring-zinc-200 hover:bg-zinc-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-900 aria-[current=page]:bg-zinc-900 aria-[current=page]:text-white aria-[current=page]:ring-zinc-900 dark:text-zinc-300 dark:ring-zinc-700 dark:hover:bg-zinc-800 dark:focus-visible:outline-lime-300 dark:aria-[current=page]:bg-lime-300 dark:aria-[current=page]:text-zinc-950 dark:aria-[current=page]:ring-lime-300"><span class="sr-only">Page </span>2</a></li>
      <li><a href="?page=3" aria-current="page" class="inline-flex size-9 items-center justify-center rounded-lg text-sm font-medium text-zinc-700 tabular-nums ring-1 ring-zinc-200 hover:bg-zinc-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-900 aria-[current=page]:bg-zinc-900 aria-[current=page]:text-white aria-[current=page]:ring-zinc-900 dark:text-zinc-300 dark:ring-zinc-700 dark:hover:bg-zinc-800 dark:focus-visible:outline-lime-300 dark:aria-[current=page]:bg-lime-300 dark:aria-[current=page]:text-zinc-950 dark:aria-[current=page]:ring-lime-300"><span class="sr-only">Page </span>3</a></li>
      <li><span class="inline-flex w-6 justify-center text-sm text-zinc-500 dark:text-zinc-400" aria-hidden="true">&hellip;</span></li>
      <li><a href="?page=12" class="inline-flex size-9 items-center justify-center rounded-lg text-sm font-medium text-zinc-700 tabular-nums ring-1 ring-zinc-200 hover:bg-zinc-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-900 aria-[current=page]:bg-zinc-900 aria-[current=page]:text-white aria-[current=page]:ring-zinc-900 dark:text-zinc-300 dark:ring-zinc-700 dark:hover:bg-zinc-800 dark:focus-visible:outline-lime-300 dark:aria-[current=page]:bg-lime-300 dark:aria-[current=page]:text-zinc-950 dark:aria-[current=page]:ring-lime-300"><span class="sr-only">Page </span>12</a></li>
      <li><a href="?page=4" class="inline-flex size-9 items-center justify-center rounded-lg text-zinc-700 hover:bg-zinc-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-900 dark:text-zinc-300 dark:hover:bg-zinc-800 dark:focus-visible:outline-lime-300"><span class="sr-only">Next page</span><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" class="size-4" aria-hidden="true"><path d="m8 5 5 5-5 5" stroke-linecap="round" stroke-linejoin="round"/></svg></a></li>
    </ul>
  </nav>
</div>

A to Z index with @theme tokens and @utility

  • @theme
  • @utility tpg-letter
  • @apply
  • bg-tpg-clay

Six prefixed @theme tokens (--color-tpg-paper, --color-tpg-clay, --radius-tpg, --font-tpg-display and more) turn into real utilities such as bg-tpg-paper, rounded-tpg and font-tpg-display. One @utility tpg-letter rule collects the shared link styles with @apply, variants included, so 26 letter links stay one class each. Letters with no entries are anchors without an href and aria-disabled="true".

View code
<div class="w-full max-w-2xl rounded-tpg bg-tpg-paper p-5 ring-1 ring-tpg-line sm:p-8">
  <h3 class="font-tpg-display text-2xl text-tpg-ink">Typography glossary</h3>
  <nav aria-label="Glossary letters" class="mt-4">
    <ul class="flex flex-wrap gap-1">
      <li><a href="?letter=a" class="tpg-letter">A</a></li>
      <li><a href="?letter=b" class="tpg-letter">B</a></li>
      <li><a href="?letter=c" class="tpg-letter">C</a></li>
      <li><a href="?letter=d" class="tpg-letter">D</a></li>
      <li><a href="?letter=e" class="tpg-letter">E</a></li>
      <li><a href="?letter=f" class="tpg-letter">F</a></li>
      <li><a href="?letter=g" class="tpg-letter">G</a></li>
      <li><a href="?letter=h" class="tpg-letter">H</a></li>
      <li><a href="?letter=i" class="tpg-letter">I</a></li>
      <li><a href="?letter=j" class="tpg-letter">J</a></li>
      <li><a href="?letter=k" aria-current="page" class="tpg-letter">K</a></li>
      <li><a href="?letter=l" class="tpg-letter">L</a></li>
      <li><a href="?letter=m" class="tpg-letter">M</a></li>
      <li><a href="?letter=n" class="tpg-letter">N</a></li>
      <li><a href="?letter=o" class="tpg-letter">O</a></li>
      <li><a href="?letter=p" class="tpg-letter">P</a></li>
      <li><a aria-disabled="true" class="tpg-letter">Q</a></li>
      <li><a href="?letter=r" class="tpg-letter">R</a></li>
      <li><a href="?letter=s" class="tpg-letter">S</a></li>
      <li><a href="?letter=t" class="tpg-letter">T</a></li>
      <li><a href="?letter=u" class="tpg-letter">U</a></li>
      <li><a href="?letter=v" class="tpg-letter">V</a></li>
      <li><a href="?letter=w" class="tpg-letter">W</a></li>
      <li><a href="?letter=x" class="tpg-letter">X</a></li>
      <li><a aria-disabled="true" class="tpg-letter">Y</a></li>
      <li><a aria-disabled="true" class="tpg-letter">Z</a></li>
    </ul>
  </nav>
  <dl class="mt-6 divide-y divide-tpg-line border-t border-tpg-line">
    <div class="py-3"><dt class="font-tpg-display text-lg text-tpg-ink">Kerning</dt><dd class="text-sm text-tpg-ink/80">Adjusting the space between one specific pair of letters, such as A and V.</dd></div>
    <div class="py-3"><dt class="font-tpg-display text-lg text-tpg-ink">Kicker</dt><dd class="text-sm text-tpg-ink/80">A short line set above a headline to introduce the story.</dd></div>
    <div class="py-3"><dt class="font-tpg-display text-lg text-tpg-ink">Knockout</dt><dd class="text-sm text-tpg-ink/80">Type that shows the paper color through a printed background.</dd></div>
  </dl>
  <p class="mt-4 flex justify-between text-sm"><a href="?letter=j" class="rounded-tpg font-medium text-tpg-clay underline underline-offset-4 hover:text-tpg-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tpg-clay">Previous letter: J</a><a href="?letter=l" class="rounded-tpg font-medium text-tpg-clay underline underline-offset-4 hover:text-tpg-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tpg-clay">Next letter: L</a></p>
</div>

Does Tailwind CSS include a pagination component?

No. Core Tailwind gives you utility classes and nothing prebuilt, so the pager is either assembled by hand or taken from a kit.

Because Tailwind is a utility-first framework, the control comes down to a nav element, a list of links and a handful of classes on each link. No extra stylesheet is involved.

Tailwind Labs did sell finished versions as part of Tailwind Plus, its paid collection of UI blocks. Its availability has changed, which is covered further down.

Where the finished pagination pattern comes from depends on the project. Some people write the markup and classes directly in the template. Others pull from a kit, either paid blocks such as Tailwind Plus (sold until September 2026, when it closed to new customers) or free snippet libraries such as HyperUI. Laravel already ships Tailwind-compatible paginator views, and php artisan vendor:publish --tag=laravel-pagination copies tailwind.blade.php into the project for editing (Laravel documentation).

Tailwind v4 moves theme configuration into CSS through @theme, but that changes nothing here. Pagination is still plain utilities and variants.

How to build Tailwind pagination step by step

Build it as a nav element holding a list of links, one list item per control, with identical base classes on every link and a real URL in every href.

Markup and classes

  1. Wrap the control in <nav aria-label="Pagination"> so assistive technology exposes it as a landmark.
  2. Add a <ul class="flex items-center gap-1"> with one <li> per control.
  3. Every link gets the same base classes: h-10, border, rounded-sm, text-sm and a hover background.
  4. Number links are fixed at w-10 with justify-center so one and two digit numbers line up. Previous and Next size themselves with px-3.
  5. Put a real URL in every href, such as ?page=2.
  6. Mark the current page with aria-current and style it through a variant (covered below).
<nav aria-label="Pagination">
  <ul class="flex items-center gap-1">
    <li><a href="?page=1" class="flex h-10 items-center rounded-sm border border-gray-300 px-3 text-sm text-gray-700 hover:bg-gray-100">Previous</a></li>
    <li><a href="?page=1" class="flex h-10 w-10 items-center justify-center rounded-sm border border-gray-300 text-sm text-gray-700 hover:bg-gray-100">1</a></li>
    <li><a href="?page=2" class="flex h-10 w-10 items-center justify-center rounded-sm border border-gray-300 text-sm text-gray-700 hover:bg-gray-100">2</a></li>
    <li><a href="?page=3" class="flex h-10 items-center rounded-sm border border-gray-300 px-3 text-sm text-gray-700 hover:bg-gray-100">Next</a></li>
  </ul>
</nav>

Heroicons chevrons work well for the Previous and Next arrows. Paste them in as inline SVG in the HTML so they pick up the link color through currentColor.

If you copy v3 snippets into a v4 project, the utility scale has shifted. rounded becomes rounded-sm, shadow becomes shadow-sm, and the default ring width drops from 3px to 1px (Tailwind v4 upgrade guide).

Shape variants

VariantKey classesFits
Rounded squareh-10 w-10 rounded-smStandard numbered lists
Circularh-10 w-10 rounded-fullSoft, compact UI
SimplePrevious, Next, “Page 2 of 10” textLinear content, small screens
Button group-ml-px, first:rounded-l-sm, last:rounded-r-smAdmin tables

Whichever shape you choose, use it everywhere. The markup, the states and the href rules don’t change between the four.

How to style the current page and disabled buttons

Set aria-current="page" on the active link and style it with an aria-[current=page]: variant, so the visual state and the screen reader state read from one attribute.

<a href="?page=2" aria-current="page"
  class="... aria-[current=page]:border-indigo-600 aria-[current=page]:bg-indigo-600 aria-[current=page]:text-white">2</a>

GitHub’s Primer treats the current-page style as an accessibility requirement. It has to meet minimum color contrast, not only look different from its neighbors.

The disabled ends need a little more care. On the first page, Previous renders as an anchor with no href and aria-disabled="true". Without an href there is nothing to activate from the keyboard, and the built-in aria-disabled: variant handles the look.

<a aria-disabled="true" class="flex h-10 items-center rounded-sm border border-gray-200 px-3 text-sm text-gray-400 aria-disabled:cursor-not-allowed aria-disabled:opacity-50">Previous</a>

Sources disagree on how to disable the ends. The native disabled attribute works on button elements and drops the control from the tab order, but it is invalid on anchors. eBay’s MIND pattern keeps the bookend links focusable and adds aria-disabled, so a greyed-out control is also announced as disabled. HeroUI’s Pagination documents both :disabled and [aria-disabled="true"] as styling hooks.

For link-based pagination, aria-disabled wins. Buttons that run JavaScript can keep the native attribute.

In v4, hover: styles apply only on devices that support hover, so touch screens no longer keep a stuck hover color (v4 upgrade guide). Add focus-visible:outline-2 focus-visible:outline-offset-2 for keyboard users.

Responsive Tailwind pagination with an ellipsis for long page lists

On narrow screens, hide the numbered links and leave Previous and Next in place. For long page counts, collapse the range so only the first page, the last page, the current page and its siblings show, with an ellipsis on either side.

Hiding page numbers on small screens

Tailwind’s sm breakpoint starts at 40rem, which is 640px (Tailwind CSS documentation).

Breakpoints follow mobile-first design. Unprefixed utilities apply at every width, and prefixed ones apply from their breakpoint up. So hidden sm:block hides a number link on phones and shows it from 640px.

The docs warn against reading sm: as “on small screens”. It means “at the small breakpoint and above”.

Each prefix compiles to a media query, @media (width >= 40rem) in v4, so no hand-written CSS is needed.

<li class="hidden sm:block">...number link...</li>
<li class="sm:hidden">Page 2 of 10</li>

Primer advises against hiding page links on narrow viewports. Use this pattern only where Previous and Next cover the navigation, as with linear content, and keep the “Page 2 of 10” text so the position stays visible.

Showing result counts

A summary line such as “Showing 21 to 30 of 100 results” sits beside the control on wide screens. HeroUI ships it as a dedicated summary part of its Pagination component.

The first item is (current page – 1) x per page + 1, and the last is the smaller of current page x per page and total results. Total pages is total results divided by per page, rounded up.

Take page 3 with 10 per page and 100 results, and you get 21 to 30 of 100.

Calculating siblings and ellipsis

The slot count should stay fixed at 2 x siblings + 5. That covers the first page, the last page, the current page, the siblings on both sides and two ellipsis slots, so the row never changes width as the user moves through the pages.

Current pageSiblingsRow for 20 pages
111 2 3 4 5 … 20
611 … 5 6 7 … 20
2011 … 16 17 18 19 20

The ellipsis is a plain span, not a link, and takes aria-hidden="true" so screen readers skip it (HeroUI Pagination documentation).

react-paginate computes the range and renders the break element for you. TanStack Table tracks page index and page size but leaves the page-number row to you.

What makes Tailwind pagination accessible?

Utility classes style the control, but the markup has to carry the semantics. That means a named nav landmark, aria-current on the active link, a visible focus style, disabled ends that get announced, and targets big enough to hit.

GitHub’s Primer design system has a clear pattern here. The nav landmark is named “Pagination” through aria-label="Pagination" (Primer), each link carries an ARIA label such as “Page 1” or “Previous Page”, and the active one gets aria-current="page".

Keyboard order follows the DOM, so Tab walks the links left to right and ends on Next (eBay’s MIND pagination pattern). With client-side pagination, the new page also has to be announced after a link is activated.

Pair focus-visible:outline-2 with focus-visible:outline-offset-2 on every link, and never strip the outline without replacing it.

Target size is the easy part. WCAG 2.2 sets the minimum at 24 by 24 CSS pixels (W3C, WCAG 2.2 SC 2.5.8, Level AA), and the usual h-10 w-10 number link is 2.5rem, which is 40px at the default 16px root size (Tailwind CSS spacing documentation).

Reflow is harder. Primer requires the control to stay readable at a 320px viewport with no horizontal scrolling, and it warns against hiding page links on narrow viewports, since anything available on wide screens has to stay available on narrow ones. A long number row needs a collapsed range with ellipses before it needs hidden links.

Do search engines follow Tailwind pagination links?

Google generally crawls URLs found in the href of <a> elements, and it treats every distinct URL as a separate page. A pager built from buttons hands the crawler nothing to follow.

Googlebot does not click, so the next-page control cannot depend on a handler (Google Search Central, last updated December 10, 2025). Pagination wired up through JavaScript click events alone never exposes page two.

Kit snippets vary in how close they come to that rule.

SourcePage link elementCrawlable as shown
Material Tailwind (HTML)buttonNo
daisyUIbutton with join-item and btnNo
Flowbitea with href=”#”Only after real URLs replace the placeholder
shadcn/uia, with aria-current on the active linkYes, once each href is real

daisyUI’s classes are not the obstacle, since its documentation says join-item can sit on a button, an input or other elements. Only the examples default to buttons.

A copied Flowbite link with href="#" fails a second rule. Google ignores everything after a hash, so every page resolves to the same URL.

A few other details are worth getting right. Each page’s canonical URL should name itself, never page one. Google no longer reads rel prev and next tags, although other search engines may still use them. Sort and filter variants such as ?order=price need noindex or a robots.txt rule. And an optional link from every page back to page one hints that it is the better landing page.

Titles and descriptions can repeat across the sequence. Google allows it explicitly.

Tailwind pagination in Laravel, React, Vue and Next.js

The pager stays the same nav, list and anchors in every stack. What changes is who supplies the page number and the URL.

Laravel and Livewire

Laravel’s paginator already carries what the markup needs.

On the backend, firstItem(), lastItem() and total() feed the summary line, and withQueryString() keeps active filters in every link.

Tailwind v4 adds one trap. Class detection skips files that .gitignore lists, and vendor/ is one of them, so the built-in views need an @source line in resources/css/app.css.

@import 'tailwindcss';
  @source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';

Fresh Laravel projects already carry that line, according to the Laravel docs. After a manual upgrade to v4, check it first when the pager renders unstyled.

Livewire ships its own Tailwind views, tailwind.blade.php and tailwind-simple.blade.php, and tracks the current page in ?page= by default (Livewire docs). php artisan livewire:publish --pagination copies them into the project for editing.

Inertia.js

Inertia hands the paginator’s JSON to the page, so the pager becomes one component looping over a links array.

Each entry has a url, which is null for Previous on the first page, Next on the last page and the “…” gaps, a label with the visible text (Previous and Next included), and an active flag that is true for the current page. Map a null url to the disabled end or a plain ellipsis, and active to the current page.

React and Next.js

Keep the page in the URL, not in useState. App Router layouts do not receive searchParams, because a shared layout is not re-rendered on navigation and could show a stale page number (Next.js docs).

Pages read the value from the searchParams prop, which is a promise from Next.js 15 onward. Client components call useSearchParams() instead, which is read-only.

With Tailwind CSS already set up in React, each page link becomes a next/link with ` href={?page=${n}} . shadcn/ui's PaginationLink renders a plain anchor by default, and its docs show the swap to Link` for client-side navigation.

Vue

Build the Vue pager as a controlled component. The parent owns the current page, passes it down as a prop and receives an update event.

Drive that page from the route query. RouterLink renders an anchor with an href by default, so the links stay crawlable without extra work (Nuxt SEO guide).

Static generation is the catch. Every intended page URL has to be part of the build.

Which Tailwind pagination library should you use?

For one paginated list, hand-built markup beats a library. A kit pays off when pagination is one of many components that have to look alike.

Libraries earn their place once the pager sits beside tables, tabs and modals, which is where a Tailwind UI kit removes the repeated styling work.

The deciding factor is consistency across a design system, not the pager itself.

Tailwind Plus, which was the official paid source of ready-made blocks, no longer takes new customers. Tailwind Labs announced on September 9, 2026 that it is joining Shopify and closing sign-ups, while existing customers keep access (Tailwind Labs announcement).

OptionHow it shipsNotable detail
Tailwind PlusWas sold as paid blocks in HTML, React and VueClosed to new customers since September 2026
FlowbiteOpen-source library, HTML markup plus ports such as SvelteIcon and previous/next-only variants
daisyUIFree plugin with semantic classes (join, join-item, btn)Sizes from btn-xs to btn-xl, radio-input variant
Preline UIOpen-source prebuilt componentsPage jumper and dropdown variants for long ranges
HyperUIMIT-licensed copy-paste HTML, no installationPagination snippets written for Tailwind v4
shadcn/uiCLI copies React source into the projectisActive sets aria-current=”page”

Cell details come from each project’s own documentation and repository.

The right pick depends on the setup. For one list in one app, hand-build it and skip the dependency. Several apps sharing a look point to shadcn/ui on React, otherwise Flowbite or Preline UI. HyperUI suits anyone who wants copy-paste HTML with nothing to install, and daisyUI gives shorter class lists at the price of its semantic class layer.

When Tailwind pagination is the wrong pattern

Numbered pagination fits result sets people revisit and compare. It fits poorly in feeds and in tables with expensive total counts, or anywhere a pager can’t give each page its own URL.

Google’s pattern comparison lays out the trade-offs (Google Search Central). Pagination shows result size and position, at the cost of more controls and a page load per step. “Load more” keeps everything on one page but can’t carry a very large result set. Infinite scroll feels natural, though unclear result size causes scroll fatigue, and it has the same size limit.

Large tables add a database cost. Laravel’s paginate() counts every matching record before it fetches a page.

simplePaginate() skips the count, and cursorPaginate() goes further with where clauses on indexed columns, but neither renders page numbers. A Tailwind row of numbers is only possible with paginate().

Dynamic class names break production builds. Tailwind scans source files as plain text, so a concatenated string never produces a real class (Tailwind CSS documentation).

// Breaks: "bg-indigo-600" never exists as a whole string
className={`bg-${active ? "indigo" : "white"}-600`}

// Works: both class strings exist in full
className={active ? "bg-indigo-600 text-white" : "bg-white text-gray-700"}

State kept outside the URL fails quietly. Livewire tracks the current page in ?page= by default, and the WithoutUrlPagination trait switches that off, so a reload returns to the first page.

Filters need the opposite care. Call resetPage() when a search term changes, or a user on page 4 lands on a result set that only has two pages (Livewire docs).

A pager that fetches pages through Ajax and never touches the address bar has the same weakness. No back button, no shareable link.

Tailwind pagination FAQ

How does Tailwind pagination differ from Bootstrap pagination?

Bootstrap ships a finished pagination component, complete with its own page-link and active classes. With Tailwind there is no such thing, just utilities, so the markup carries every style.

Laravel supports both. Tailwind views are the default, and a paginator call in the AppServiceProvider switches it to Bootstrap.

How do I add dark mode to Tailwind pagination?

Pair each light class with a dark: variant, such as dark:border-gray-700 and dark:hover:bg-gray-800, on every link, the current page included.

Tailwind’s dark variant follows the operating system setting by default. A manual toggle needs a custom variant declared in CSS.

Is Tailwind Plus pagination free?

No. Tailwind Plus was a paid product, sold as a one-time purchase with lifetime access, and Tailwind Labs closed it to new customers in September 2026 (Tailwind Labs announcement).

HyperUI snippets are MIT-licensed and free, and Flowbite’s core library is open source.

How many items should each page show?

No single number is correct. Laravel’s documentation examples use 15 items per page, and data tables often add a rows-per-page selector so users choose.

Whatever the value, the result summary and the total page count both derive from it.

What to Test Before Tailwind Pagination Goes Live

Test against the production build, not the dev server, since a class that Tailwind never generated makes every later visual check meaningless. Order matters here.

Start with the compiled CSS and confirm the active-page and disabled-end styles exist. Then check the page source to see that every number sits in an anchor with its own URL. Last comes a keyboard pass at a narrow width, on the first, last and out-of-range pages.

The keyboard pass goes last because it depends on final markup. Edge pages go with it, since the disabled ends and the ellipsis only render at those positions.

This order holds for Tailwind v4 as verified on October 29, 2026. Another release that renames utilities would put a compiled-CSS diff ahead of everything else.

Pagination is one control on a page, and a full web accessibility checklist covers the rest.