Pagination Page Turn
The current rows leave in the direction you asked for and the next set arrives from the opposite side.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useState, type ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Pagination Page Turn
*
* The table turns like a page: the current rows leave in the direction
* you asked for and the next set arrives from the opposite side, so
* "next" and "previous" never look like the same event.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color, so the table reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `pageSize`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type PaginationPageTurnProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Rows shown per page. */
pageSize?: number;
/** Fires with the new zero-based page index. */
onPageChange?: (page: number) => void;
};
type VariantConfig = {
/** How far a page travels as it enters or leaves, in px. */
travel: number;
/** Seconds for the outgoing page to clear. */
exit: number;
/** Seconds between rows of the arriving page. */
stagger: number;
spring: { type: "spring"; stiffness: number; damping: number };
};
// Quality rule: pages are wall-to-wall text, so the travel is short and
// the springs sit at or above a 0.8 damping ratio — a page that overshoots
// drags every row past its column and back, which is unreadable at any
// speed. Nothing scales. Variants change the distance and the pace, never
// the settle.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// A short shift, barely more than a cut. For dense admin tables paged
// through quickly.
subtle: {
travel: 10,
exit: 0.1,
stagger: 0.015,
spring: { type: "spring", stiffness: 620, damping: 46 },
},
// Enough travel to read the direction, gone before it is in the way.
default: {
travel: 18,
exit: 0.13,
stagger: 0.025,
spring: { type: "spring", stiffness: 480, damping: 38 },
},
// Longer travel with a light cascade down the rows — for a browsing
// list rather than a working table.
playful: {
travel: 28,
exit: 0.16,
stagger: 0.04,
spring: { type: "spring", stiffness: 380, damping: 32 },
},
};
const ACCENT = "#7C7CF0";
const POSITIVE = "#3E9E76";
/** Theme-adaptive neutral: `currentColor` is the text color this component
* inherits — near-black on a light page, near-white on a dark one — so
* mixing it with `transparent` yields a surface, border or fill that is
* correctly toned in either theme. Status colors stay literal. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
type Invoice = { id: string; account: string; amount: string; paid: boolean };
const INVOICES: Invoice[] = [
{ id: "INV-2481", account: "Northwind Trading", amount: "$1,240.00", paid: true },
{ id: "INV-2480", account: "Halcyon Labs", amount: "$680.00", paid: true },
{ id: "INV-2479", account: "Ridgeway Group", amount: "$3,150.00", paid: false },
{ id: "INV-2478", account: "Bright Harbour", amount: "$420.00", paid: true },
{ id: "INV-2477", account: "Meridian Studio", amount: "$2,090.00", paid: false },
{ id: "INV-2476", account: "Keystone Freight", amount: "$775.00", paid: true },
{ id: "INV-2475", account: "Alder & Finch", amount: "$1,860.00", paid: true },
{ id: "INV-2474", account: "Copperline Co", amount: "$540.00", paid: false },
{ id: "INV-2473", account: "Fenwick Media", amount: "$4,300.00", paid: true },
{ id: "INV-2472", account: "Sable Logistics", amount: "$915.00", paid: true },
{ id: "INV-2471", account: "Ferrous Works", amount: "$260.00", paid: false },
{ id: "INV-2470", account: "Latimer Health", amount: "$1,105.00", paid: true },
{ id: "INV-2469", account: "Onyx Interiors", amount: "$2,640.00", paid: true },
{ id: "INV-2468", account: "Pelham Foods", amount: "$380.00", paid: true },
{ id: "INV-2467", account: "Vantage Rail", amount: "$5,020.00", paid: false },
{ id: "INV-2466", account: "Wrenfield Ltd", amount: "$1,470.00", paid: true },
];
export default function PaginationPageTurn({
variant = "default",
pageSize = 4,
onPageChange,
}: PaginationPageTurnProps) {
const [page, setPage] = useState(0);
const [direction, setDirection] = useState(1);
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const pageCount = Math.ceil(INVOICES.length / pageSize);
const rows = INVOICES.slice(page * pageSize, page * pageSize + pageSize);
const goTo = (next: number) => {
if (next === page || next < 0 || next >= pageCount) return;
setDirection(next > page ? 1 : -1);
setPage(next);
onPageChange?.(next);
};
// Reduced motion: the page still changes and the pager still says where
// you are — the rows simply cross-fade in place instead of travelling.
const travel = reduceMotion ? 0 : cfg.travel;
const pageVariants = {
enter: (dir: number) => ({ x: dir * travel, opacity: 0 }),
center: {
x: 0,
opacity: 1,
transition: reduceMotion
? { duration: 0.14, ease: "easeOut" as const }
: {
...cfg.spring,
opacity: { duration: 0.18, ease: "easeOut" as const },
staggerChildren: cfg.stagger,
delayChildren: 0.03,
},
},
exit: (dir: number) => ({
x: -dir * travel,
opacity: 0,
transition: { duration: reduceMotion ? 0.1 : cfg.exit, ease: "easeIn" as const },
}),
};
const rowVariants = {
enter: { opacity: 0 },
center: { opacity: 1, transition: { duration: reduceMotion ? 0.12 : 0.2 } },
};
return (
<div
style={{
width: 336,
borderRadius: 18,
background: tone(6),
color: "inherit",
border: `1px solid ${tone(12)}`,
boxShadow: "0 14px 34px rgba(0,0,0,0.18)",
overflow: "hidden",
}}
>
<div
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
padding: "15px 16px 10px",
}}
>
<span style={{ fontSize: 14.5, fontWeight: 650 }}>Invoices</span>
<span style={{ fontSize: 11.5, opacity: 0.5 }}>
{INVOICES.length} total
</span>
</div>
{/* Fixed height: a page that resizes mid-turn fights its own
horizontal travel, and the pager below would hop with it. */}
<div
style={{
position: "relative",
height: pageSize * 44,
borderTop: `1px solid ${tone(10)}`,
overflow: "hidden",
}}
>
<AnimatePresence mode="wait" custom={direction} initial={false}>
<motion.ul
key={page}
custom={direction}
variants={pageVariants}
initial="enter"
animate="center"
exit="exit"
style={{
position: "absolute",
inset: 0,
margin: 0,
padding: 0,
listStyle: "none",
}}
>
{rows.map((invoice) => (
<motion.li
key={invoice.id}
variants={rowVariants}
style={{
display: "flex",
alignItems: "center",
gap: 10,
height: 44,
padding: "0 16px",
borderBottom: `1px solid ${tone(8)}`,
}}
>
<span style={{ flex: 1, minWidth: 0 }}>
<span
style={{
display: "block",
fontSize: 12.5,
fontWeight: 600,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{invoice.account}
</span>
<span style={{ display: "block", fontSize: 11, opacity: 0.5 }}>
{invoice.id}
</span>
</span>
<span
style={{
fontSize: 12.5,
fontWeight: 600,
fontVariantNumeric: "tabular-nums",
}}
>
{invoice.amount}
</span>
<span
style={{
width: 54,
textAlign: "right",
fontSize: 11,
fontWeight: 600,
color: invoice.paid ? POSITIVE : "inherit",
opacity: invoice.paid ? 1 : 0.5,
}}
>
{invoice.paid ? "Paid" : "Open"}
</span>
</motion.li>
))}
</motion.ul>
</AnimatePresence>
</div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
padding: "10px 12px",
borderTop: `1px solid ${tone(10)}`,
}}
>
<PagerButton
label="Previous page"
disabled={page === 0}
onClick={() => goTo(page - 1)}
>
<path d="M9.8 3.6 5.4 8l4.4 4.4" />
</PagerButton>
<div style={{ display: "flex", gap: 4 }}>
{Array.from({ length: pageCount }, (_, index) => {
const isCurrent = index === page;
return (
<button
key={index}
type="button"
onClick={() => goTo(index)}
aria-label={`Page ${index + 1}`}
aria-current={isCurrent ? "page" : undefined}
style={{
position: "relative",
width: 26,
height: 26,
borderRadius: 8,
border: 0,
background: "none",
color: "inherit",
fontSize: 12,
fontWeight: 600,
fontFamily: "inherit",
fontVariantNumeric: "tabular-nums",
cursor: "pointer",
}}
>
{/* The current-page pill fades where it is rather than
sliding between numbers: the rows are the thing that
travels here, and two travelling objects would compete
for the same glance. */}
<motion.span
aria-hidden
initial={false}
animate={{ opacity: isCurrent ? 1 : 0 }}
transition={{ duration: reduceMotion ? 0 : 0.16, ease: "easeOut" }}
style={{
position: "absolute",
inset: 0,
borderRadius: 8,
background: tone(12),
border: `1px solid ${tone(16)}`,
}}
/>
<span
style={{
position: "relative",
opacity: isCurrent ? 1 : 0.5,
color: isCurrent ? ACCENT : "inherit",
}}
>
{index + 1}
</span>
</button>
);
})}
</div>
<PagerButton
label="Next page"
disabled={page === pageCount - 1}
onClick={() => goTo(page + 1)}
>
<path d="M6.2 3.6 10.6 8l-4.4 4.4" />
</PagerButton>
</div>
</div>
);
}
type PagerButtonProps = {
label?: string;
disabled?: boolean;
onClick?: () => void;
children?: ReactNode;
};
function PagerButton({ label, disabled, onClick, children }: PagerButtonProps) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={label}
style={{
display: "grid",
placeItems: "center",
width: 28,
height: 28,
borderRadius: 9,
border: `1px solid ${tone(12)}`,
background: tone(7),
color: "inherit",
opacity: disabled ? 0.35 : 1,
cursor: disabled ? "default" : "pointer",
}}
>
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
{children}
</svg>
</button>
);
}About this pattern
Paging a table without losing your place in it. Direction is the entire message: forward sends the current set left and brings the next in from the right, back reverses both, so two presses that land on the same content still read as opposite moves. The body is a fixed height, because a page that resizes mid-turn fights its own horizontal travel and makes the pager below hop. Rows are wall-to-wall text, so the travel is short, nothing scales, and the springs sit near critical damping — an overshoot here drags every figure past its column and back. The page indicator deliberately does not slide between numbers: the rows are what travels, and a second travelling object would only compete for the same glance.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Data table
A dense list of records where paging shifts the rows rather than blanking the table.
Related patterns
- Accordion ExpandOne section opens as the previous one closes, both heights easing over the same beat so the list never jumps.
- Tree Node ExpandA folder's chevron turns as its children unfold with a small stagger.
- Wizard Step AdvanceThe rail fills toward the step you are entering while the panel travels the way you sent it.