React + MotionNavigation

Paginierung mit rollenden Zahlen

Eine Pille gleitet zur gewählten Seite und der Zähler „Seite 3 von 12“ rollt seine Ziffern; Pfeiltasten, Pos1 und Ende gehen auch.

Einstellungen

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="Seitennavigation"
      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="Vorherige Seite"
          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={"Seite " + 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="Nächste Seite"
          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 für KI

Füg ihn in ChatGPT, Claude oder Cursor ein und der Baustein wird mit diesen Einstellungen an dein Projekt angepasst, auch ohne React.

Mehr aus Navigation

Wofür du ihn nutzen kannst

Eine Paginierung ist überall sinnvoll, wo man wissen will, wo man gerade ist: ein Blogarchiv, eine Produktliste, Suchergebnisse, eine Rechnungsübersicht. Diese Paginierung fasst lange Bereiche mit Punkten zusammen, schiebt die Markierung zur gewählten Seite und lässt die Ziffern des Zählers darunter rollen, je nach Richtung nach oben oder unten.

Bei endlosen Feeds ist ein „Mehr laden“-Button freundlicher als nummerierte Seiten. Hier stellst du die Seitenzahl und deine Farbe ein, mehr braucht es kaum. Die Tastenkürzel greifen nur, wenn der Fokus in der Paginierung liegt, und kommen dem Rest der Seite nicht in die Quere.