navigationclient
pagination
page numbers in a well, with a thumb that slides to the page you're on.
installation
new to blurrow? this sets up your project in one go: the package, the styles and the font. already done it? skip straight to usage. the installation page has the details.
bash
npx @blurrow/ui init›rather own the code? copy the source instead
let the cli copy it, along with anything it needs:
bash
npx @blurrow/ui add paginationor by hand: drop this file into components/ui and import from there.
components/ui/Pagination.tsxtsx
1"use client";2
3import React, { useRef, useState, useEffect, useLayoutEffect } from "react";4
5interface PaginationProps {6 currentPage: number;7 totalPages: number;8 onPageChange: (page: number) => void;9 className?: string;10}11
12export function Pagination({13 currentPage,14 totalPages,15 onPageChange,16 className = "",17}: PaginationProps) {18 const slots = buildSlots(currentPage, totalPages);19 const [pill, setPill] = useState({ left: 0, width: 0, ready: false });20 const containerRef = useRef<HTMLDivElement>(null);21 const btnRefs = useRef<Record<string, HTMLButtonElement | null>>({});22
23 const measurePill = () => {24 const btn = btnRefs.current[String(currentPage)];25 const container = containerRef.current;26 if (!btn || !container) return;27 const cr = container.getBoundingClientRect();28 const br = btn.getBoundingClientRect();29 setPill({ left: br.left - cr.left, width: br.width, ready: true });30 };31
32 useLayoutEffect(() => {33 measurePill();34 }, []);35
36 /* Only the thumb moves: it glides to the new page without overshoot,37 and the numbers themselves stay still. */38 useEffect(() => {39 measurePill();40 }, [currentPage]);41
42 useEffect(() => {43 const ro = new ResizeObserver(() => measurePill());44 if (containerRef.current) ro.observe(containerRef.current);45 return () => ro.disconnect();46 }, [currentPage]);47
48 return (49 <nav50 aria-label="Pagination"51 className={[52 "inline-flex items-center gap-0.5 rounded-[13px] p-1",53 className,54 ].join(" ")}55 style={{ background: "var(--well)", boxShadow: "var(--well-shadow)" }}56 >57 <button58 onClick={() => onPageChange(currentPage - 1)}59 disabled={currentPage <= 1}60 aria-label="Previous page"61 className="group/nav relative overflow-hidden h-8 px-3 rounded-[9px] flex items-center gap-1.5 text-[13px] font-medium text-neutral-600 hover:text-neutral-900 transition-colors cursor-pointer disabled:opacity-30 disabled:pointer-events-none"62 >63 <span className="pointer-events-none absolute inset-0 rounded-[9px] opacity-0 group-hover/nav:opacity-100 transition-opacity duration-200 [background:var(--face-light)] [box-shadow:var(--thumb-shadow)]" />64 <svg className="relative z-10" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">65 <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />66 </svg>67 <span className="relative z-10 hidden sm:inline">prev</span>68 </button>69
70 <div ref={containerRef} className="relative flex items-center">71 {pill.ready && (72 <span73 aria-hidden74 className="absolute top-0 bottom-0 rounded-[9px] pointer-events-none z-0"75 style={{76 left: pill.left,77 width: pill.width,78 background: "var(--face-light)",79 boxShadow: "var(--thumb-shadow)",80 transition: "left 340ms var(--ease-out-soft), width 340ms var(--ease-out-soft)",81 }}82 />83 )}84
85 {slots.map((slot, i) =>86 slot === "..." ? (87 <span88 key={`dot-${i}`}89 aria-hidden90 className="w-8 h-8 flex items-center justify-center text-[11px] text-neutral-400 select-none relative z-10"91 >92 ···93 </span>94 ) : (95 <button96 key={`slot-${i}`}97 ref={(el) => {98 btnRefs.current[String(slot)] = el;99 }}100 onClick={() => slot !== currentPage && onPageChange(slot as number)}101 aria-label={`Page ${slot}`}102 aria-current={slot === currentPage ? "page" : undefined}103 className={[104 "relative z-10 min-w-8 h-8 px-1 rounded-[9px] flex items-center justify-center text-[13px] font-medium transition-colors duration-200 cursor-pointer tabular-nums",105 slot === currentPage106 ? "text-neutral-900"107 : "text-neutral-500 hover:text-neutral-900",108 ].join(" ")}109 >110 {slot}111 </button>112 ),113 )}114 </div>115
116 <button117 onClick={() => onPageChange(currentPage + 1)}118 disabled={currentPage >= totalPages}119 aria-label="Next page"120 className="group/nav relative overflow-hidden h-8 px-3 rounded-[9px] flex items-center gap-1.5 text-[13px] font-medium text-neutral-600 hover:text-neutral-900 transition-colors cursor-pointer disabled:opacity-30 disabled:pointer-events-none"121 >122 <span className="pointer-events-none absolute inset-0 rounded-[9px] opacity-0 group-hover/nav:opacity-100 transition-opacity duration-200 [background:var(--face-light)] [box-shadow:var(--thumb-shadow)]" />123 <span className="relative z-10 hidden sm:inline">next</span>124 <svg className="relative z-10" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">125 <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />126 </svg>127 </button>128 </nav>129 );130}131
132function buildSlots(current: number, total: number): (number | "...")[] {133 if (total <= 7) {134 return Array.from({ length: total }, (_, i) => i + 1);135 }136
137 const slots: (number | "...")[] = new Array(7);138
139 slots[0] = 1;140 slots[6] = total;141
142 if (current <= 3) {143 slots[1] = 2;144 slots[2] = 3;145 slots[3] = 4;146 slots[4] = 5;147 slots[5] = "...";148 } else if (current >= total - 2) {149 slots[1] = "...";150 slots[2] = total - 4;151 slots[3] = total - 3;152 slots[4] = total - 2;153 slots[5] = total - 1;154 } else {155 slots[1] = "...";156 slots[2] = current - 1;157 slots[3] = current;158 slots[4] = current + 1;159 slots[5] = "...";160 }161
162 return slots;163}usage
tsx
import { Pagination } from "@blurrow/ui";tsx
<Pagination currentPage={page} totalPages={12} onPageChange={setPage} />props
straight from the source, so they're always up to date.
propsts
interface PaginationProps { currentPage: number; totalPages: number; onPageChange: (page: number) => void; className?: string;}