Components · Navigation
Pagination
Page links with gaps: first, last, the current page and its neighbours; the ends turn off.
38% of the 2,062 sites in The UI Index have a navigation (781 sites).
Both previews render the same markup; the HTML tab below is that markup without a framework.
Install
- React
- npx shadcn@
latest add https:// www.themes.dev/ caseroom/ r/ pagination.json - Astro
- npx shadcn@
latest add https:// www.themes.dev/ caseroom/ r/ astro/ pagination.json
Or copy the files below into your project. They need clsx, tailwind-merge, and the Caseroom theme. The HTML needs only the compiled CSS.
/** The pages to show: the first, the last, the current one and one either side; null is a gap (…). */
export function pageList(current: number, total: number): (number | null)[] {
const keep = new Set([1, total, current - 1, current, current + 1].filter((p) => p >= 1 && p <= total));
const out: (number | null)[] = [];
let last = 0;
for (const p of [...keep].sort((a, b) => a - b)) {
if (p - last === 2) out.push(last + 1);
else if (p - last > 2) out.push(null);
out.push(p);
last = p;
}
return out;
}import { cn } from '@/lib/utils';
import { NEXT_PATH, pageList, pagination, PREV_PATH } from './pagination.variants';
export interface PaginationProps {
current: number;
total: number;
/** The address of a page, e.g. (p) => `/blog/page/${p}/`. */
href: (page: number) => string;
label?: string;
className?: string;
}
/** Page links with gaps: the first, the last, the current page and one either side. */
export function Pagination({ current, total, href, label = 'Pages', className }: PaginationProps) {
const pages = pageList(current, total);
const edge = (to: number, off: boolean) => (off ? { 'aria-disabled': 'true' as const, role: 'link' } : { href: href(to) });
return (
<nav aria-label={label} className={cn(pagination.root, className)}>
<ul className={pagination.list}>
<li>
<a {...edge(current - 1, current <= 1)} rel="prev" className={pagination.link}>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d={PREV_PATH} /></svg>
<span className={pagination.edgeLabel}>Previous</span>
</a>
</li>
{pages.map((p, k) => (
<li key={p ?? `gap-${k}`} className={pagination.number}>
{p === null ? <span aria-hidden="true" className={pagination.gap}>…</span> : <a href={href(p)} aria-current={p === current ? 'page' : undefined} aria-label={`Page ${p}`} className={pagination.link}>{p}</a>}
</li>
))}
<li className={pagination.status}>
Page {current} of {total}
</li>
<li>
<a {...edge(current + 1, current >= total)} rel="next" className={pagination.link}>
<span className={pagination.edgeLabel}>Next</span>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d={NEXT_PATH} /></svg>
</a>
</li>
</ul>
</nav>
);
}import { pageList } from './pagination.pages';
/** Page links: previous, numbers with gaps, next. The current page is aria-current; the ends are disabled links.
* It measures its own width (@container): under 32rem the numbers give way to 'Page 5 of 12' between the arrows. */
export const pagination = {
root: 'w-full @container',
list: 'm-0 flex list-none flex-wrap items-center gap-1 p-0',
link: 'inline-flex h-9 min-w-9 cursor-pointer items-center justify-center gap-1 rounded-md border border-transparent px-2.5 text-sm font-medium tabular-nums transition-[color,background-color,transform] select-none hover:bg-accent active:scale-[0.97] active:bg-pressed aria-[current=page]:border aria-[current=page]:border-input aria-[current=page]:bg-background aria-[current=page]:shadow-[0_1px_2px_rgb(0_0_0/0.06)] aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:size-4',
number: '@max-lg:hidden',
status: 'inline-flex h-9 items-center px-2 text-sm text-muted-foreground tabular-nums @lg:hidden',
gap: 'inline-flex h-9 min-w-6 items-center justify-center text-sm text-muted-foreground',
edgeLabel: '@max-md:sr-only',
};
export { pageList };
export const PREV_PATH = 'M10 3.5L5.5 8l4.5 4.5';
export const NEXT_PATH = 'M6 3.5l4.5 4.5L6 12.5';---
import { cn } from '@/lib/utils';
import { NEXT_PATH, pageList, pagination, PREV_PATH } from './pagination.variants';
interface Props {
current: number;
total: number;
/** The address of a page, e.g. (p) => `/blog/page/${p}/`. */
href: (page: number) => string;
label?: string;
class?: string;
}
const { current, total, href, label = 'Pages', class: className } = Astro.props;
const pages = pageList(current, total);
const edge = (to: number, off: boolean) => (off ? { 'aria-disabled': 'true', role: 'link' } : { href: href(to) });
---
<nav aria-label={label} class={cn(pagination.root, className)}>
<ul class={pagination.list}>
<li>
<a {...edge(current - 1, current <= 1)} rel="prev" class={pagination.link}>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d={PREV_PATH} /></svg>
<span class={pagination.edgeLabel}>Previous</span>
</a>
</li>
{
pages.map((p) => (
<li class={pagination.number}>
{p === null ? <span aria-hidden="true" class={pagination.gap}>…</span> : <a href={href(p)} aria-current={p === current ? 'page' : undefined} aria-label={`Page ${p}`} class={pagination.link}>{p}</a>}
</li>
))
}
<li class={pagination.status}>Page {current} of {total}</li>
<li>
<a {...edge(current + 1, current >= total)} rel="next" class={pagination.link}>
<span class={pagination.edgeLabel}>Next</span>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d={NEXT_PATH} /></svg>
</a>
</li>
</ul>
</nav>/** The pages to show: the first, the last, the current one and one either side; null is a gap (…). */
export function pageList(current: number, total: number): (number | null)[] {
const keep = new Set([1, total, current - 1, current, current + 1].filter((p) => p >= 1 && p <= total));
const out: (number | null)[] = [];
let last = 0;
for (const p of [...keep].sort((a, b) => a - b)) {
if (p - last === 2) out.push(last + 1);
else if (p - last > 2) out.push(null);
out.push(p);
last = p;
}
return out;
}import { pageList } from './pagination.pages';
/** Page links: previous, numbers with gaps, next. The current page is aria-current; the ends are disabled links.
* It measures its own width (@container): under 32rem the numbers give way to 'Page 5 of 12' between the arrows. */
export const pagination = {
root: 'w-full @container',
list: 'm-0 flex list-none flex-wrap items-center gap-1 p-0',
link: 'inline-flex h-9 min-w-9 cursor-pointer items-center justify-center gap-1 rounded-md border border-transparent px-2.5 text-sm font-medium tabular-nums transition-[color,background-color,transform] select-none hover:bg-accent active:scale-[0.97] active:bg-pressed aria-[current=page]:border aria-[current=page]:border-input aria-[current=page]:bg-background aria-[current=page]:shadow-[0_1px_2px_rgb(0_0_0/0.06)] aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:size-4',
number: '@max-lg:hidden',
status: 'inline-flex h-9 items-center px-2 text-sm text-muted-foreground tabular-nums @lg:hidden',
gap: 'inline-flex h-9 min-w-6 items-center justify-center text-sm text-muted-foreground',
edgeLabel: '@max-md:sr-only',
};
export { pageList };
export const PREV_PATH = 'M10 3.5L5.5 8l4.5 4.5';
export const NEXT_PATH = 'M6 3.5l4.5 4.5L6 12.5';<div class="grid w-full max-w-xl gap-6">
<nav aria-label="Deploys, pages" class="w-full @container">
<ul class="m-0 flex list-none flex-wrap items-center gap-1 p-0">
<li>
<a href="#page-4" rel="prev" class="inline-flex h-9 min-w-9 cursor-pointer items-center justify-center gap-1 rounded-md border border-transparent px-2.5 text-sm font-medium tabular-nums transition-[color,background-color,transform] select-none hover:bg-accent active:scale-[0.97] active:bg-pressed aria-[current=page]:border aria-[current=page]:border-input aria-[current=page]:bg-background aria-[current=page]:shadow-[0_1px_2px_rgb(0_0_0/0.06)] aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:size-4">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 3.5L5.5 8l4.5 4.5"></path></svg>
<span class="@max-md:sr-only">Previous</span>
</a>
</li>
<li class="@max-lg:hidden">
<a href="#page-1" aria-label="Page 1" class="inline-flex h-9 min-w-9 cursor-pointer items-center justify-center gap-1 rounded-md border border-transparent px-2.5 text-sm font-medium tabular-nums transition-[color,background-color,transform] select-none hover:bg-accent active:scale-[0.97] active:bg-pressed aria-[current=page]:border aria-[current=page]:border-input aria-[current=page]:bg-background aria-[current=page]:shadow-[0_1px_2px_rgb(0_0_0/0.06)] aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:size-4">
1
</a>
</li>
<li class="@max-lg:hidden"><span aria-hidden="true" class="inline-flex h-9 min-w-6 items-center justify-center text-sm text-muted-foreground">…</span></li>
<li class="@max-lg:hidden">
<a href="#page-4" aria-label="Page 4" class="inline-flex h-9 min-w-9 cursor-pointer items-center justify-center gap-1 rounded-md border border-transparent px-2.5 text-sm font-medium tabular-nums transition-[color,background-color,transform] select-none hover:bg-accent active:scale-[0.97] active:bg-pressed aria-[current=page]:border aria-[current=page]:border-input aria-[current=page]:bg-background aria-[current=page]:shadow-[0_1px_2px_rgb(0_0_0/0.06)] aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:size-4">
4
</a>
</li>
<li class="@max-lg:hidden">
<a href="#page-5" aria-current="page" aria-label="Page 5" class="inline-flex h-9 min-w-9 cursor-pointer items-center justify-center gap-1 rounded-md border border-transparent px-2.5 text-sm font-medium tabular-nums transition-[color,background-color,transform] select-none hover:bg-accent active:scale-[0.97] active:bg-pressed aria-[current=page]:border aria-[current=page]:border-input aria-[current=page]:bg-background aria-[current=page]:shadow-[0_1px_2px_rgb(0_0_0/0.06)] aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:size-4">
5
</a>
</li>
<li class="@max-lg:hidden">
<a href="#page-6" aria-label="Page 6" class="inline-flex h-9 min-w-9 cursor-pointer items-center justify-center gap-1 rounded-md border border-transparent px-2.5 text-sm font-medium tabular-nums transition-[color,background-color,transform] select-none hover:bg-accent active:scale-[0.97] active:bg-pressed aria-[current=page]:border aria-[current=page]:border-input aria-[current=page]:bg-background aria-[current=page]:shadow-[0_1px_2px_rgb(0_0_0/0.06)] aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:size-4">
6
</a>
</li>
<li class="@max-lg:hidden"><span aria-hidden="true" class="inline-flex h-9 min-w-6 items-center justify-center text-sm text-muted-foreground">…</span></li>
<li class="@max-lg:hidden">
<a href="#page-12" aria-label="Page 12" class="inline-flex h-9 min-w-9 cursor-pointer items-center justify-center gap-1 rounded-md border border-transparent px-2.5 text-sm font-medium tabular-nums transition-[color,background-color,transform] select-none hover:bg-accent active:scale-[0.97] active:bg-pressed aria-[current=page]:border aria-[current=page]:border-input aria-[current=page]:bg-background aria-[current=page]:shadow-[0_1px_2px_rgb(0_0_0/0.06)] aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:size-4">
12
</a>
</li>
<li class="inline-flex h-9 items-center px-2 text-sm text-muted-foreground tabular-nums @lg:hidden">Page 5 of 12</li>
<li>
<a href="#page-6" rel="next" class="inline-flex h-9 min-w-9 cursor-pointer items-center justify-center gap-1 rounded-md border border-transparent px-2.5 text-sm font-medium tabular-nums transition-[color,background-color,transform] select-none hover:bg-accent active:scale-[0.97] active:bg-pressed aria-[current=page]:border aria-[current=page]:border-input aria-[current=page]:bg-background aria-[current=page]:shadow-[0_1px_2px_rgb(0_0_0/0.06)] aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:size-4">
<span class="@max-md:sr-only">Next</span>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 3.5l4.5 4.5L6 12.5"></path></svg>
</a>
</li>
</ul>
</nav>
<nav aria-label="Logs, pages" class="w-full @container">
<ul class="m-0 flex list-none flex-wrap items-center gap-1 p-0">
<li>
<a aria-disabled="true" role="link" rel="prev" class="inline-flex h-9 min-w-9 cursor-pointer items-center justify-center gap-1 rounded-md border border-transparent px-2.5 text-sm font-medium tabular-nums transition-[color,background-color,transform] select-none hover:bg-accent active:scale-[0.97] active:bg-pressed aria-[current=page]:border aria-[current=page]:border-input aria-[current=page]:bg-background aria-[current=page]:shadow-[0_1px_2px_rgb(0_0_0/0.06)] aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:size-4">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 3.5L5.5 8l4.5 4.5"></path></svg>
<span class="@max-md:sr-only">Previous</span>
</a>
</li>
<li class="@max-lg:hidden">
<a href="#page-1" aria-current="page" aria-label="Page 1" class="inline-flex h-9 min-w-9 cursor-pointer items-center justify-center gap-1 rounded-md border border-transparent px-2.5 text-sm font-medium tabular-nums transition-[color,background-color,transform] select-none hover:bg-accent active:scale-[0.97] active:bg-pressed aria-[current=page]:border aria-[current=page]:border-input aria-[current=page]:bg-background aria-[current=page]:shadow-[0_1px_2px_rgb(0_0_0/0.06)] aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:size-4">
1
</a>
</li>
<li class="@max-lg:hidden">
<a href="#page-2" aria-label="Page 2" class="inline-flex h-9 min-w-9 cursor-pointer items-center justify-center gap-1 rounded-md border border-transparent px-2.5 text-sm font-medium tabular-nums transition-[color,background-color,transform] select-none hover:bg-accent active:scale-[0.97] active:bg-pressed aria-[current=page]:border aria-[current=page]:border-input aria-[current=page]:bg-background aria-[current=page]:shadow-[0_1px_2px_rgb(0_0_0/0.06)] aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:size-4">
2
</a>
</li>
<li class="@max-lg:hidden">
<a href="#page-3" aria-label="Page 3" class="inline-flex h-9 min-w-9 cursor-pointer items-center justify-center gap-1 rounded-md border border-transparent px-2.5 text-sm font-medium tabular-nums transition-[color,background-color,transform] select-none hover:bg-accent active:scale-[0.97] active:bg-pressed aria-[current=page]:border aria-[current=page]:border-input aria-[current=page]:bg-background aria-[current=page]:shadow-[0_1px_2px_rgb(0_0_0/0.06)] aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:size-4">
3
</a>
</li>
<li class="inline-flex h-9 items-center px-2 text-sm text-muted-foreground tabular-nums @lg:hidden">Page 1 of 3</li>
<li>
<a href="#page-2" rel="next" class="inline-flex h-9 min-w-9 cursor-pointer items-center justify-center gap-1 rounded-md border border-transparent px-2.5 text-sm font-medium tabular-nums transition-[color,background-color,transform] select-none hover:bg-accent active:scale-[0.97] active:bg-pressed aria-[current=page]:border aria-[current=page]:border-input aria-[current=page]:bg-background aria-[current=page]:shadow-[0_1px_2px_rgb(0_0_0/0.06)] aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:size-4">
<span class="@max-md:sr-only">Next</span>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 3.5l4.5 4.5L6 12.5"></path></svg>
</a>
</li>
</ul>
</nav>
</div>