Skip to content
    Style: Wireframe

    Components · Navigation

    Pagination

    Page links with gaps: first, last, the current page and its neighbours; the ends turn off.

    38% of the 2,073 sites in The UI Index have a navigation (790 sites).

    Used in UI Pro: Posts, Changelog, Pagination

    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/ui/r/pagination.json
    Astro
    npx shadcn@latest add https://www.themes.dev/ui/r/astro/pagination.json

    Or copy the files below into your project. They need clsx, tailwind-merge, and the themes.dev UI theme. The HTML needs only the compiled CSS.

    pagination.pages.ts
    /** 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;
    }
    pagination.tsx
    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>
      );
    }
    pagination.variants.ts
    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';