React + MotionNavigation

Rolling number pagination

A pill slides to the chosen page while the “Page 3 of 12” counter rolls its digits, also from the keyboard.

Settings

12

Code

import { useId, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { ChevronLeft, ChevronRight } from "lucide-react";

function paginationWindow(page: number, total: number): (number | "gap")[] {
  if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
  if (page <= 4) return [1, 2, 3, 4, 5, "gap", total];
  if (page >= total - 3) return [1, "gap", total - 4, total - 3, total - 2, total - 1, total];
  return [1, "gap", page - 1, page, page + 1, "gap", total];
}

const PAGINATION_SPRING = { type: "spring", stiffness: 520, damping: 38 } as const;

function RollingDigits({ value, dir }: { value: number; dir: number }) {
  return (
    <span className="inline-flex tabular-nums">
      {String(value)
        .split("")
        .map((d, i, all) => (
          <span
            key={all.length - i}
            className="relative inline-block h-[1.25em] w-[0.62em] overflow-hidden"
          >
            <AnimatePresence initial={false} custom={dir}>
              <motion.span
                key={d}
                custom={dir}
                initial={{ y: dir > 0 ? "100%" : "-100%" }}
                animate={{ y: "0%" }}
                exit={{ y: dir > 0 ? "-100%" : "100%" }}
                transition={PAGINATION_SPRING}
                className="absolute inset-0 text-center"
              >
                {d}
              </motion.span>
            </AnimatePresence>
          </span>
        ))}
    </span>
  );
}

interface PaginationProps {
  color?: string;
  total?: number;
}

export default function Pagination({ color = "#0a84ff", total = 12 }: PaginationProps) {
  const [rawPage, setPage] = useState(1);
  const [dir, setDir] = useState(1);
  const layoutId = "pagination-pill-" + useId();
  const page = Math.min(rawPage, total);

  const go = (p: number) => {
    const next = Math.max(1, Math.min(total, p));
    if (next === page) return;
    setDir(next > page ? 1 : -1);
    setPage(next);
  };

  const arrow =
    "grid h-9 w-9 shrink-0 place-items-center rounded-full text-[#1d1d1f] outline-none transition-[background-color,opacity,transform] duration-150 hover:bg-black/[0.05] focus-visible:ring-2 focus-visible:ring-black/15 active:scale-90 disabled:pointer-events-none disabled:opacity-25 dark:text-[#f5f5f7] dark:hover:bg-white/10 dark:focus-visible:ring-white/25";

  return (
    <nav
      aria-label="Pagination"
      onKeyDown={(e) => {
        const keys: Record<string, number> = {
          ArrowLeft: page - 1,
          ArrowRight: page + 1,
          Home: 1,
          End: total,
        };
        const to = keys[e.key];
        if (to === undefined) return;
        e.preventDefault();
        go(to);
      }}
      className="flex max-w-full flex-col items-center gap-3"
    >
      <div className="flex max-w-full items-center gap-0.5 rounded-full bg-white p-1 shadow-[0_1px_2px_rgba(0,0,0,0.04),0_10px_32px_rgba(0,0,0,0.07)] ring-1 ring-black/[0.06] dark:bg-[#1c1c1e] dark:shadow-[0_10px_32px_rgba(0,0,0,0.4)] dark:ring-white/10">
        <button
          type="button"
          aria-label="Previous page"
          disabled={page === 1}
          onClick={() => go(page - 1)}
          className={arrow}
        >
          <ChevronLeft className="h-4 w-4" strokeWidth={2} />
        </button>
        <ol className="flex items-center">
          {paginationWindow(page, total).map((p, i) =>
            p === "gap" ? (
              <li
                key={"gap-" + i}
                aria-hidden
                className="grid h-9 w-6 place-items-center text-[13px] text-[#aeaeb2]"
              >
                ···
              </li>
            ) : (
              <li key={p}>
                <button
                  type="button"
                  onClick={() => go(p)}
                  aria-label={"Page " + p}
                  aria-current={p === page ? "page" : undefined}
                  className={
                    "relative grid h-9 min-w-8 place-items-center rounded-full px-1.5 text-[13px] font-medium tabular-nums outline-none transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-black/15 dark:focus-visible:ring-white/25 " +
                    (p === page
                      ? "text-white"
                      : "text-[#86868b] hover:bg-black/[0.04] hover:text-[#1d1d1f] dark:hover:bg-white/[0.07] dark:hover:text-[#f5f5f7]")
                  }
                >
                  {p === page && (
                    <motion.span
                      layoutId={layoutId}
                      className="absolute inset-0 rounded-full shadow-[inset_0_1px_0_rgba(255,255,255,0.18),0_2px_8px_rgba(0,0,0,0.18)] dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.18)]"
                      style={{ backgroundColor: color }}
                      transition={PAGINATION_SPRING}
                    />
                  )}
                  <span className="relative">{p}</span>
                </button>
              </li>
            ),
          )}
        </ol>
        <button
          type="button"
          aria-label="Next page"
          disabled={page === total}
          onClick={() => go(page + 1)}
          className={arrow}
        >
          <ChevronRight className="h-4 w-4" strokeWidth={2} />
        </button>
      </div>
      <p aria-live="polite" className="flex items-center text-[12px] text-[#86868b]">
        Página&nbsp;
        <span className="font-medium text-[#1d1d1f] dark:text-[#f5f5f7]">
          <RollingDigits value={page} dir={dir} />
        </span>
        &nbsp;de {total}
      </p>
    </nav>
  );
}

Prompt for AI

Paste it into ChatGPT, Claude or Cursor and it will adapt the block to your project with these settings, even if you don’t use React.

More in Navigation

Where to use it

Pagination still makes sense wherever people want to know where they are: a blog archive, a product listing, search results, a list of invoices. This pagination component collapses long ranges with dots, slides the highlight to the page you pick and rolls the digits of the counter underneath, up or down depending on the direction.

For endless feeds, a “Load more” button is kinder than numbered pages. Here, set the total and your colour, and you are mostly done. The keyboard shortcuts only work while focus is inside the pagination, so they never fight with the rest of the page.