React + MotionNavegación

Paginación con números que ruedan

Una píldora se desliza a la página elegida y el contador «Página 3 de 12» hace rodar sus dígitos.

Ajustes

12

Código

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="Paginación"
      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="Página anterior"
          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={"Página " + 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="Página siguiente"
          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 para IA

Pégalo en ChatGPT, Claude o Cursor y adaptará el bloque a tu proyecto con estos ajustes, aunque no uses React.

Más en Navegación

Dónde usarlo

La paginación sigue teniendo sentido donde la gente quiere saber en qué punto está: el archivo de un blog, un listado de productos, resultados de búsqueda, una lista de facturas. Esta paginación agrupa los tramos largos con puntos, desliza la marca hasta la página que eliges y hace rodar los dígitos del contador, arriba o abajo según la dirección.

Para un feed infinito, un botón de «Cargar más» es más amable que las páginas numeradas. Aquí basta con fijar el total y tu color. Los atajos de teclado solo funcionan con el foco dentro de la paginación, así que no chocan con el resto de la página.