React + MotionNavigation

Pagination à chiffres roulants

Une pastille glisse jusqu’à la page choisie et le compteur « Page 3 sur 12 » fait rouler ses chiffres.

Réglages

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="Page précédente"
          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="Page suivante"
          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 pour l’IA

Collez-le dans ChatGPT, Claude ou Cursor : le bloc sera adapté à votre projet avec ces réglages, même sans React.

Plus dans Navigation

Où l’utiliser

Une pagination reste utile partout où l’on veut savoir où l’on en est : les archives d’un blog, une liste de produits, des résultats de recherche, un historique de factures. Celle-ci regroupe les longues séries avec des points, fait glisser la pastille jusqu’à la page choisie et fait rouler les chiffres du compteur dans le sens du déplacement.

Pour un fil sans fin, un bouton « Charger plus » est plus agréable que des pages numérotées. Ici, il suffit de régler le nombre de pages et votre couleur. Les raccourcis clavier (flèches, Début, Fin) n’agissent que si le focus est dans la pagination.