Pagination
Page navigation with sibling and boundary ellipsis logic, a spring-gliding active pill, numbers that roll as the window shifts, arrow-nudge prev/next controls, and a compact dots variant.
Preview
Install
Add it with the shadcn CLI, or copy the source manually.
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
"use client";
import { useEffect, useState } from "react";
/**
* Returns true only on devices that have a true hover (mouse / trackpad).
* Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere
* — gate hover-only effects (scale lifts, magnetic pulls) behind this.
*/
export function useHoverCapable() {
const [canHover, setCanHover] = useState(false);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => setCanHover(mq.matches);
update();
mq.addEventListener?.("change", update);
return () => mq.removeEventListener?.("change", update);
}, []);
return canHover;
}
// Shared touch primitives. iOS and iPadOS run their own gestures on top of the
// page — the long-press selection callout and the selection it drags in with
// it — and they win: once the platform claims a touch it cancels ours
// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own
// their gesture have to opt out.
//
// What the two classes below cover, precisely:
// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:
// it is not a property other engines have, so it is inert everywhere else.
// - `user-select: none` stops the long-press selection on every engine,
// Android included, and stops a drag from painting a selection under the
// cursor. It is inherited, so it reaches every descendant — which is why the
// two classes differ only in whether they apply it unconditionally.
// What neither covers:
// - Chrome for Android's long-press menu on a link or an image. No CSS
// suppresses it; a gesture surface that wraps one needs its own
// `onContextMenu` with `preventDefault()`.
// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is
// not inherited and plain divs and buttons are not drag sources, so setting
// it on the surface does nothing — the child itself needs `draggable={false}`.
/**
* Classes for a surface that *is* the control: a thumb, a drum, a stage, a
* handle, a hold button. Selection is suppressed on every input, because a
* drag that highlights the control's own label is wrong on a mouse too.
* Compose with `touch-none` when the surface also owns the scroll axis — leave
* it off when the page must still scroll from there.
*/
export const TOUCH_GESTURE_CLASS = "select-none [-webkit-touch-callout:none]";
/**
* The same opt-out for a gesture surface that wraps content the consumer owns:
* a scroller, a context-menu trigger, a sheet header, a list row. Selection is
* suppressed only where the platform runs its own press gestures — a coarse
* pointer — so a mouse user can still select and copy that content. If the
* gesture itself would paint a selection under the cursor, add `select-none`
* for the duration of the gesture rather than reaching for
* `TOUCH_GESTURE_CLASS`.
*
* `pointer: coarse` describes the *primary* pointer and nothing else, so a
* hybrid machine reads it wrong in both directions: a tablet with a mouse
* plugged in keeps touch as primary and loses mouse selection, and a laptop
* with a touchscreen keeps the mouse as primary and leaves selection live
* under a finger. No media query can answer per interaction — the query is
* about the device, and the question is about the gesture in progress. The
* default stays here because it is right on the machines that are one thing or
* the other, and losing a selection is a nuisance; where the miss costs a
* *gesture* instead, the surface pairs it with `holdSelection` on the press.
*/
export const TOUCH_GESTURE_CONTENT_CLASS =
"[-webkit-touch-callout:none] pointer-coarse:select-none";
/**
* Grow a small control's *hit area* to at least 44×44px (WCAG 2.5.5, Apple
* HIG) on a coarse pointer, without changing what it looks like or the space
* it takes in layout. An invisible `::after` box, centered on the control and
* `max(100%, 2.75rem)` on each axis, catches the taps: a 32px icon button
* reaches 6px past each edge, a wide 40px button only grows vertically, and a
* control already 44px or larger is left as it is. Hit-testing a pseudo
* element lands on its owner, so handlers, focus and `:active` all behave.
*
* Mouse and trackpad rendering is untouched — nothing here applies outside
* `pointer: coarse`. Requirements on the element it goes on:
* - It must be the containing block: add `relative` (and keep it mergeable, so
* a consumer's `absolute` still wins through `cn`).
* - It must not clip itself (`overflow-hidden`), or the overhang is clipped
* away along with its hit area.
* - It must render children: `<input>` and other void elements have no
* `::after`. Put it on the wrapper or label instead.
* - It must not already use `::after` for something visible.
*
* Centered 44px means two 32px buttons with a 4px gap overlap by 8px; the
* later one wins that strip, which still ends well short of the earlier
* button's visual center. Below a ~32px pitch the later neighbour starts
* eating into that center — use `TOUCH_TARGET_Y_CLASS` for such dense rows.
*
* The overhang is real geometry: inside a scroll container it counts as
* scrollable overflow, so a control flush against a scroller's edge can add a
* few pixels of scroll on touch screens. Give such a scroller a little padding
* on that side.
*/
export const TOUCH_TARGET_CLASS =
"pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-[max(100%,2.75rem)] pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']";
/**
* `TOUCH_TARGET_CLASS` on the vertical axis only, for controls packed tighter
* than ~32px apart in a row (pagination dots, dense swatches), where growing
* sideways would hand most of each control to its neighbour. Same rules.
*/
export const TOUCH_TARGET_Y_CLASS =
"pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:top-1/2 pointer-coarse:after:h-[max(100%,2.75rem)] pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']";
/**
* Suppress selection on `element` for as long as a gesture is running on it,
* whatever the primary pointer of the machine happens to be. Returns the
* release. Inline, so it wins over the class above and is gone again the
* moment the gesture ends.
*
* For the press gestures a native selection would otherwise steal — a
* long-press that opens a menu. Elsewhere prefer the classes: a surface that
* takes selection away for the whole session is a surface whose text nobody
* can copy.
*/
export function holdSelection(element: HTMLElement) {
element.style.setProperty("user-select", "none");
element.style.setProperty("-webkit-user-select", "none");
return () => {
element.style.removeProperty("user-select");
element.style.removeProperty("-webkit-user-select");
};
}
/**
* Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer
* is already gone by the time the handler runs — routine on iOS, where the
* system can claim the touch first — and an uncaught throw takes the rest of
* the handler, the gesture included, down with it. Touch pointers carry
* implicit capture anyway, so losing it is never fatal.
*/
export function capturePointer(element: Element, pointerId: number) {
try {
element.setPointerCapture(pointerId);
} catch {
// Pointer is no longer active — implicit capture still applies on touch.
}
}
/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */
export function releasePointer(element: Element, pointerId: number) {
try {
if (element.hasPointerCapture(pointerId)) {
element.releasePointerCapture(pointerId);
}
} catch {
// Capture was already dropped by the browser.
}
}
/**
* Whether this event came from a pointer that is *hovering*: not a touch, and
* not currently pressed. Which input the user is holding right now is not
* something a device capability can answer — a touchscreen laptop hovers and
* taps, and iPadOS reports a fine hovering pointer for a finger — so both
* paths stay live and each handler branches on the event it was given.
*
* A pen resting on the glass is making contact, not hovering: `buttons` is the
* tell, and it sends a pen tap down the same route a finger takes.
*
* This answers what an *enter* asks. A leave is the other half of a pair and
* has to be read against the enter that started it — `useHoverGesture` in
* `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it
* rather than asking this question twice.
*/
export const isHoveringPointer = (event: {
pointerType: string;
buttons: number;
}) => event.pointerType !== "touch" && event.buttons === 0;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// karaui.kamisama-v0.my.id/components/motion/pagination
import { ChevronLeft, ChevronRight } from "lucide-react";
import {
AnimatePresence,
LayoutGroup,
motion,
useReducedMotion,
type Variants,
} from "motion/react";
import { useCallback, useId, useState } from "react";
import { EASE_OUT, SPRING_LAYOUT, SPRING_PRESS, SPRING_SWAP } from "@/lib/ease";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { TOUCH_TARGET_CLASS, TOUCH_TARGET_Y_CLASS } from "@/lib/touch";
import { cn } from "@/lib/utils";
export type PaginationVariant = "numbers" | "dots";
export type PaginationItem = number | "ellipsis";
export interface PaginationProps {
/** Total number of pages (1-based). */
total: number;
/** Controlled current page (1-based). */
page?: number;
/** Initial page for uncontrolled use. */
defaultPage?: number;
onPageChange?: (page: number) => void;
/** Pages shown on each side of the current page. */
siblings?: number;
/** Pages always shown at the start and end. */
boundaries?: number;
/** `numbers` renders page buttons; `dots` is a compact indicator row. */
variant?: PaginationVariant;
/** Most dots shown at once in the `dots` variant; the window follows the page. */
maxDots?: number;
/** Render previous / next controls. */
showControls?: boolean;
disabled?: boolean;
/** Accessible name for the `<nav>` landmark. */
"aria-label"?: string;
/** Accessible name per page button. */
getPageLabel?: (page: number) => string;
className?: string;
}
function range(start: number, end: number) {
const out: number[] = [];
for (let i = start; i <= end; i++) out.push(i);
return out;
}
/**
* Page items with ellipses. The item count stays constant once ellipses
* appear, so slots keep their position and only their numbers change.
*/
export function getPaginationItems(
total: number,
page: number,
siblings = 1,
boundaries = 1,
): PaginationItem[] {
const count = Math.max(0, Math.floor(total));
const sib = Math.max(0, Math.floor(siblings));
const bound = Math.max(1, Math.floor(boundaries));
// boundaries + siblings + current + two ellipses
const slots = sib * 2 + 3 + bound * 2;
if (count <= slots) return range(1, count);
const left = Math.max(page - sib, bound + 1);
const right = Math.min(page + sib, count - bound);
const showLeft = left > bound + 2;
const showRight = right < count - bound - 1;
if (!showLeft && showRight) {
const leftCount = sib * 2 + bound + 2;
return [
...range(1, leftCount),
"ellipsis",
...range(count - bound + 1, count),
];
}
if (showLeft && !showRight) {
const rightCount = sib * 2 + bound + 2;
return [
...range(1, bound),
"ellipsis",
...range(count - rightCount + 1, count),
];
}
return [
...range(1, bound),
"ellipsis",
...range(left, right),
"ellipsis",
...range(count - bound + 1, count),
];
}
function clamp(value: number, total: number) {
return Math.min(Math.max(1, Math.round(value)), Math.max(1, total));
}
// Numbers roll in the direction of travel when the window shifts.
const ROLL_VARIANTS: Variants = {
initial: (dir: number) => ({ opacity: 0, y: dir >= 0 ? "100%" : "-100%" }),
animate: { opacity: 1, y: "0%", transition: SPRING_SWAP },
exit: (dir: number) => ({
opacity: 0,
y: dir >= 0 ? "-100%" : "100%",
transition: { duration: 0.14, ease: EASE_OUT },
}),
};
const FADE_VARIANTS: Variants = {
initial: { opacity: 0 },
animate: { opacity: 1, transition: { duration: 0.15 } },
exit: { opacity: 0, transition: { duration: 0.1 } },
};
const ITEM_CLASS =
"relative grid size-9 shrink-0 place-items-center rounded-full text-sm tabular-nums outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-40";
export function Pagination({
total,
page,
defaultPage = 1,
onPageChange,
siblings = 1,
boundaries = 1,
variant = "numbers",
maxDots = 7,
showControls = true,
disabled = false,
"aria-label": ariaLabel = "Pagination",
getPageLabel = (p) => `Page ${p}`,
className,
}: PaginationProps) {
const reduce = useReducedMotion() ?? false;
const canHover = useHoverCapable();
const groupId = useId();
const [internal, setInternal] = useState(() => clamp(defaultPage, total));
const controlled = page !== undefined;
const current = clamp(controlled ? page : internal, total);
// Direction of the last move drives the roll. Derived during render with
// the setState-in-render pattern so it is ready on the same pass.
const [track, setTrack] = useState({ page: current, dir: 0 });
if (track.page !== current) {
setTrack({ page: current, dir: current > track.page ? 1 : -1 });
}
const direction = track.page !== current
? current > track.page ? 1 : -1
: track.dir;
const go = useCallback(
(next: number) => {
const target = clamp(next, total);
if (target === current || disabled) return;
if (!controlled) setInternal(target);
onPageChange?.(target);
},
[controlled, current, disabled, onPageChange, total],
);
const nudge = !reduce && canHover;
const rollVariants = reduce ? FADE_VARIANTS : ROLL_VARIANTS;
const pillTransition = reduce ? { duration: 0 } : SPRING_LAYOUT;
const control = (dir: -1 | 1) => {
const isPrev = dir === -1;
const Icon = isPrev ? ChevronLeft : ChevronRight;
const atEdge = isPrev ? current <= 1 : current >= total;
return (
<motion.button
type="button"
aria-label={isPrev ? "Previous page" : "Next page"}
disabled={disabled || atEdge || total < 1}
onClick={() => go(current + dir)}
initial="rest"
animate="rest"
whileHover={nudge ? "hover" : undefined}
whileTap={reduce ? undefined : { scale: 0.9 }}
transition={SPRING_PRESS}
className={cn(
ITEM_CLASS,
TOUCH_TARGET_CLASS,
"text-muted-foreground hover:bg-muted hover:text-foreground",
variant === "dots" && "size-8",
)}
>
<motion.span
aria-hidden="true"
variants={{ rest: { x: 0 }, hover: { x: isPrev ? -2 : 2 } }}
transition={SPRING_PRESS}
className="grid place-items-center"
>
<Icon className="size-4" />
</motion.span>
</motion.button>
);
};
if (variant === "dots") {
const size = Math.max(3, Math.min(maxDots, total));
const start = Math.min(
Math.max(1, current - Math.floor(size / 2)),
Math.max(1, total - size + 1),
);
const dots = range(start, Math.min(total, start + size - 1));
const first = dots[0] ?? 1;
const last = dots[dots.length - 1] ?? 1;
return (
<nav aria-label={ariaLabel} className={cn("inline-flex items-center gap-1", className)}>
{showControls ? control(-1) : null}
<LayoutGroup id={groupId}>
<ul className="flex items-center gap-0.5">
<AnimatePresence initial={false} mode="popLayout">
{dots.map((p) => {
const active = p === current;
// Shrink edge dots when more pages hide beyond them.
const edge =
(p === first && first > 1) || (p === last && last < total);
return (
<motion.li
key={p}
layout={reduce ? false : "position"}
initial={{ opacity: 0, scale: reduce ? 1 : 0.4 }}
animate={{ opacity: 1, scale: edge && !reduce ? 0.6 : 1 }}
exit={{ opacity: 0, scale: reduce ? 1 : 0.4 }}
transition={reduce ? { duration: 0.12 } : SPRING_LAYOUT}
className="flex"
>
<button
type="button"
aria-label={getPageLabel(p)}
aria-current={active ? "page" : undefined}
disabled={disabled}
onClick={() => go(p)}
className={cn(
"group relative grid h-5 w-5 place-items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:opacity-40",
TOUCH_TARGET_Y_CLASS,
)}
>
<span className="size-2 rounded-full bg-muted-foreground/35 transition-colors group-hover:bg-muted-foreground/60" />
{active ? (
<motion.span
layoutId="pagination-dot"
transition={pillTransition}
className="absolute inset-x-0 top-1.5 h-2 rounded-full bg-foreground"
/>
) : null}
</button>
</motion.li>
);
})}
</AnimatePresence>
</ul>
</LayoutGroup>
{showControls ? control(1) : null}
</nav>
);
}
const items = getPaginationItems(total, current, siblings, boundaries);
return (
<nav aria-label={ariaLabel} className={cn("inline-flex", className)}>
<ul className="flex items-center gap-1">
{showControls ? <li className="flex">{control(-1)}</li> : null}
<LayoutGroup id={groupId}>
{items.map((item, index) => {
const active = item === current;
const itemKey = String(item);
return (
// Slots are keyed by position so buttons (and their focus)
// persist while the numbers inside roll to new values.
// biome-ignore lint/suspicious/noArrayIndexKey: positional slots are the point.
<li key={index} className="relative flex">
{active ? (
<motion.span
layoutId="pagination-pill"
transition={pillTransition}
className="absolute inset-0 rounded-full bg-primary"
/>
) : null}
{item === "ellipsis" ? (
<span
aria-hidden="true"
className={cn(ITEM_CLASS, "text-muted-foreground")}
>
…
</span>
) : (
<motion.button
type="button"
aria-label={getPageLabel(item)}
aria-current={active ? "page" : undefined}
disabled={disabled}
onClick={() => go(item)}
whileTap={reduce ? undefined : { scale: 0.9 }}
transition={SPRING_PRESS}
className={cn(
ITEM_CLASS,
TOUCH_TARGET_CLASS,
active
? "text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
{/* The roll clips here rather than on the button, so the
button's touch hit area can overhang it. */}
<span className="relative grid size-full place-items-center overflow-hidden rounded-[inherit]">
<AnimatePresence
initial={false}
mode="popLayout"
custom={direction}
>
<motion.span
key={itemKey}
custom={direction}
variants={rollVariants}
initial="initial"
animate="animate"
exit="exit"
className="col-start-1 row-start-1"
>
{item}
</motion.span>
</AnimatePresence>
</span>
</motion.button>
)}
</li>
);
})}
</LayoutGroup>
{showControls ? <li className="flex">{control(1)}</li> : null}
</ul>
</nav>
);
}
@karaui not found? Add it to components.json
{
"registries": {
"@karaui": "https://karaui.kamisama-v0.my.id/r/{name}.json"
}
}@karaui is in shadcn's public registry directory, so the latest CLI needs no setup. On an older CLI, merge this into the existing file once and every @karaui/… install resolves.
API Reference
totalnumberTotal number of pages (1-based).
—page?numberControlled current page (1-based).
—defaultPage?numberInitial page for uncontrolled use.
1onPageChange?((page: number) => void)—siblings?numberPages shown on each side of the current page.
1boundaries?numberPages always shown at the start and end.
1variant?"numbers" | "dots"`numbers` renders page buttons; `dots` is a compact indicator row.
numbersmaxDots?numberMost dots shown at once in the `dots` variant; the window follows the page.
7showControls?booleanRender previous / next controls.
truedisabled?booleanfalsearia-label?stringAccessible name for the `<nav>` landmark.
PaginationgetPageLabel?((page: number) => string)Accessible name per page button.
(p) => `Page ${p}`className?string—Updated