Pagination
Pages in a row that keeps its width, with one tile that steps to the page you pick.
Sessions 61–80 of 236
The idea
The current page sits on a raised tile that slides to the next one, rather than a background blinking on every number — going from page 3 to 4 should look like a step, because it is one. While there are enough pages to need gaps, the number of slots stays fixed, so the row never changes width under the pointer. It is buttons for a list that pages in place, or real links through your router when every page should have a URL.
Install
npm i mihirmodi-ui motionThe tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.
Usage
import { Pagination } from "mihirmodi-ui";
// Buttons, for a list that pages in place…
<Pagination page={page} count={12} onChange={setPage} />
// …or links through your router, so every page has a URL.
<Pagination page={page} count={12} href={(p) => `/sessions?page=${p}`} />Props
| Prop | Type | Notes |
|---|---|---|
| page / countnumber | number | The current page, counted from 1, and how many there are. |
| onChange(page) => void | (page) => void | Buttons: called with the page to go to. |
| href(page) => string | (page) => string | Links instead: the URL of each page, so each can be opened, shared and crawled. |
| siblings / boundariesnumber | number= 1 / 1 | Pages either side of the current one, and always shown at each end. |
Source
Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.
"use client";
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
import { glyphProps } from "../../lib/glyph.js";
import { FLAT, SLIDE } from "../../lib/motion.js";
import { useLinkComponent, type LinkComponent } from "../../lib/link.js";
export type PaginationProps = {
/** The current page, counted from 1. */
page: number;
/** How many pages there are. */
count: number;
/** Buttons: called with the page to go to. */
onChange?: (page: number) => void;
/** Links: the URL of a page. Given this, every number is a real link the router can prefetch. */
href?: (page: number) => string;
/** Pages shown either side of the current one. */
siblings?: number;
/** Pages always shown at each end. */
boundaries?: number;
/** The landmark's name. */
label?: string;
link?: LinkComponent;
className?: string;
};
type Slot = number | "gap-start" | "gap-end";
const TILE = "rounded-[8px] bg-highlight shadow-[0_0_0_0.5px_var(--color-gray-alpha-400),0_2px_2px_rgba(0,0,0,0.04)]";
/**
* The slots to draw. While there are enough pages to need gaps, the count of
* slots is constant — a gap takes the place of the page it hides — so the row
* keeps its width as you page and the highlight has a fixed set of places to go.
*/
export function pageSlots(page: number, count: number, siblings = 1, boundaries = 1): Slot[] {
const range = (a: number, b: number) => Array.from({ length: Math.max(0, b - a + 1) }, (_, i) => a + i);
const total = boundaries * 2 + siblings * 2 + 3;
if (count <= total) return range(1, count);
const lo = Math.max(Math.min(page - siblings, count - boundaries - siblings * 2 - 1), boundaries + 2);
const hi = Math.min(Math.max(page + siblings, boundaries + siblings * 2 + 2), count - boundaries - 1);
return [
...range(1, boundaries),
lo > boundaries + 2 ? "gap-start" : boundaries + 1,
...range(lo, hi),
hi < count - boundaries - 1 ? "gap-end" : count - boundaries,
...range(count - boundaries + 1, count),
];
}
function Chevron({ back }: { back?: boolean }) {
return (
<svg {...glyphProps} strokeWidth="1.8" className="h-3.5 w-3.5">
<path d={back ? "M14.5 6l-6 6 6 6" : "M9.5 6l6 6-6 6"} />
</svg>
);
}
/**
* A row of pages with the current one on a raised tile.
*
* The tile is one element that slides to the page you pick, rather than a
* background on every number that blinks on and off — moving from page 3 to
* page 4 should look like a step, because it is one. Slots are keyed by their
* place in the row, not by the number in them, so when the window around the
* current page shifts the numbers change under a tile that only moves as far as
* the row really changed.
*
* It works two ways. With `onChange` the pages are buttons, for a list that
* pages in place. With `href` they are links through your router, so each page
* has a URL, can be opened in a new tab, and is crawled. Either way the current
* page carries `aria-current="page"`, and Previous and Next go quiet at the
* ends instead of disappearing, so the row does not jump.
*/
export function Pagination({
page,
count,
onChange,
href,
siblings = 1,
boundaries = 1,
label = "Pagination",
link,
className = "",
}: PaginationProps) {
const reduce = useReducedMotion();
const Link = useLinkComponent(link);
const slots = pageSlots(page, count, siblings, boundaries);
const at = slots.indexOf(page);
const refs = useRef<(HTMLElement | null)[]>([]);
const [tile, setTile] = useState<{ x: number; w: number } | null>(null);
useLayoutEffect(() => {
const el = refs.current[at];
setTile(el ? { x: el.offsetLeft, w: el.offsetWidth } : null);
}, [at, slots.length, count]);
const cell =
"relative inline-flex h-8 min-w-8 select-none items-center justify-center rounded-[8px] px-2 text-[13px] font-medium leading-none tabular-nums transition-[background-color,color,transform] duration-150 ease-geist active:scale-[0.97] motion-reduce:transition-none";
const target = (p: number, children: ReactNode, opts: { current?: boolean; aria?: string; slot?: number; extra?: string }) => {
// Until the row is measured (on the server, or with no script), the current page wears the tile itself.
const still = opts.current && !tile ? TILE : "";
const cls = `${cell} ${opts.current ? "text-gray-1000" : "text-muted hover:bg-gray-alpha-200 hover:text-gray-1000"} ${still} ${opts.extra ?? ""}`;
const ref = (el: HTMLElement | null) => {
if (opts.slot !== undefined) refs.current[opts.slot] = el;
};
if (href) {
return (
<span ref={ref} className="inline-flex">
<Link
href={href(p)}
aria-label={opts.aria}
aria-current={opts.current ? "page" : undefined}
onClick={() => onChange?.(p)}
className={cls}
>
{children}
</Link>
</span>
);
}
return (
<button
ref={ref}
type="button"
aria-label={opts.aria}
aria-current={opts.current ? "page" : undefined}
onClick={() => onChange?.(p)}
className={cls}
>
{children}
</button>
);
};
const end = (dir: -1 | 1) => {
const to = page + dir;
const off = to < 1 || to > count;
const body = (
<>
{dir < 0 && <Chevron back />}
<span>{dir < 0 ? "Previous" : "Next"}</span>
{dir > 0 && <Chevron />}
</>
);
if (off) {
// Quiet, not gone: the row keeps its shape at either end.
return (
<span aria-disabled="true" className={`${cell} gap-1 text-faint opacity-60`}>
{body}
</span>
);
}
return target(to, body, { aria: dir < 0 ? "Previous page" : "Next page", extra: "gap-1" });
};
return (
<LazyMotion features={domAnimation} strict>
<nav aria-label={label} className={className}>
<ul className="relative flex items-center gap-0.5">
<li className="mr-1">{end(-1)}</li>
{tile && (
<m.li
aria-hidden
className={`pointer-events-none absolute top-0 h-8 ${TILE}`}
style={{ left: 0 }}
initial={false}
animate={{ x: tile.x, width: tile.w }}
transition={reduce ? FLAT : SLIDE}
/>
)}
{slots.map((s, i) =>
typeof s === "number" ? (
<li key={i}>{target(s, s, { current: s === page, aria: `Page ${s}`, slot: i })}</li>
) : (
<li key={i} aria-hidden className="inline-flex h-8 min-w-8 items-center justify-center text-[13px] text-faint">
…
</li>
)
)}
<li className="ml-1">{end(1)}</li>
</ul>
</nav>
</LazyMotion>
);
}
"use client";
import { createContext, useContext, type AnchorHTMLAttributes, type ComponentType, type ReactNode } from "react";
/**
* Anything that renders an `<a>` given an `href`. Next's `Link`, React Router's
* `Link` (rename `to`), TanStack's, or nothing at all.
*/
export type LinkComponent = ComponentType<
{ href: string; children?: ReactNode } & Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href">
>;
const LinkContext = createContext<LinkComponent | null>(null);
/**
* Teaches every component beneath it how to route.
*
* Without it the components render plain `<a>` tags, which navigate correctly
* but reload the page — fine for a marketing site, wrong inside an app. Rather
* than depend on a router (which would tie this package to one framework), the
* dependency is inverted: you hand in the link component you already use.
*
* import Link from "next/link";
* <UIProvider link={Link}>{children}</UIProvider>
*
* Every component also takes a `link` prop, so a single odd one out does not
* need its own provider.
*/
export function UIProvider({ link, children }: { link?: LinkComponent; children: ReactNode }) {
return <LinkContext.Provider value={link ?? null}>{children}</LinkContext.Provider>;
}
/** The nearest provided link component, or `"a"` when there is none. */
export function useLinkComponent(override?: LinkComponent): LinkComponent | "a" {
const ctx = useContext(LinkContext);
return override ?? ctx ?? "a";
}
/**
* Whether a destination is the current one — React Router's NavLink semantics,
* kept: "/" matches exactly, everything else matches itself and its descendants,
* so a nav entry stays lit while you are reading one of its children.
*
* Exported because the dock takes `active` as a boolean rather than computing it:
* the dock has no idea what your routes mean, but you do.
*/
export function isCurrentPath(pathname: string, to: string): boolean {
return to === "/" ? pathname === "/" : pathname === to || pathname.startsWith(`${to}/`);
}