All patterns

Pagination Page Turn

The current rows leave in the direction you asked for and the next set arrives from the opposite side.

navigationminimalcalminteraction · finite · intermediate · ~0.3s
Interactive · click to play
Variant

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.

386 lines · react + motion only
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.

Admin data tableInvoice listSearch result pagesArchive browsing

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Ridgeline
    Members
    General
    Billing
    Security
    Integrations
    MembersNew
    Nils Bergströmnils@ridgeline.coAdmin
    Priya Ramanpriya@ridgeline.coMember
    Marcus Bellmarcus@ridgeline.coMember
    Dana Whitfielddana@ridgeline.coViewer
    Data table

    A dense list of records where paging shifts the rows rather than blanking the table.

Related patterns