Retour au catalogue

Pagination Cursor Based

Pagination basee sur curseur avec boutons Precedent/Suivant, indicateur de position et transitions fluides.

paginationmedium Both Responsive a11y
minimalcorporatesaasecommerceuniversalstacked
Theme
"use client";

import { useState, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { ChevronLeft, ChevronRight, Loader2, Database, ArrowRight } from "lucide-react";
import React from "react";

interface CursorItem {
  id: string;
  name: string;
  description: string;
  status: "active" | "inactive" | "pending";
  date: string;
}

interface CursorPage {
  items: CursorItem[];
  cursor: string;
  hasNext: boolean;
  hasPrev: boolean;
}

interface PaginationCursorBasedProps {
  title?: string;
  subtitle?: string;
  badge?: string;
  pages?: CursorPage[];
}

const EASE = [0.16, 1, 0.3, 1] as const;

const statusStyles: Record<CursorItem["status"], { label: string; color: string }> = {
  active: { label: "Actif", color: "#22c55e" },
  inactive: { label: "Inactif", color: "var(--color-foreground-light)" },
  pending: { label: "En attente", color: "#f59e0b" },
};

export default function PaginationCursorBased({
  title,
  subtitle,
  badge,
  pages,
}: PaginationCursorBasedProps) {
  const resolvedTitle = title ?? "Ressources API";
  const resolvedSubtitle = subtitle ?? "Navigation par curseur avec chargement dynamique et transitions animees.";
  const resolvedBadge = badge ?? "Cursor Pagination";

  const resolvedPages: CursorPage[] = pages ?? [
    {
      cursor: "cur_001",
      hasNext: true,
      hasPrev: false,
      items: [
        { id: "r1", name: "srv-prod-01", description: "Serveur de production principal", status: "active", date: "13 mars 2026" },
        { id: "r2", name: "srv-prod-02", description: "Serveur de production secondaire", status: "active", date: "13 mars 2026" },
        { id: "r3", name: "db-master", description: "Base de donnees PostgreSQL principale", status: "active", date: "12 mars 2026" },
        { id: "r4", name: "cache-redis-01", description: "Instance Redis pour le cache", status: "active", date: "12 mars 2026" },
      ],
    },
    {
      cursor: "cur_002",
      hasNext: true,
      hasPrev: true,
      items: [
        { id: "r5", name: "worker-queue-01", description: "Worker pour les taches asynchrones", status: "active", date: "11 mars 2026" },
        { id: "r6", name: "cdn-edge-eu", description: "Noeud CDN Europe", status: "active", date: "10 mars 2026" },
        { id: "r7", name: "staging-srv", description: "Serveur de staging", status: "pending", date: "9 mars 2026" },
        { id: "r8", name: "backup-s3", description: "Stockage de sauvegardes S3", status: "inactive", date: "8 mars 2026" },
      ],
    },
    {
      cursor: "cur_003",
      hasNext: false,
      hasPrev: true,
      items: [
        { id: "r9", name: "log-aggregator", description: "Agregateur de logs ELK", status: "active", date: "7 mars 2026" },
        { id: "r10", name: "monitoring-grafana", description: "Instance Grafana pour le monitoring", status: "active", date: "6 mars 2026" },
        { id: "r11", name: "dev-sandbox", description: "Environnement de developpement isole", status: "pending", date: "5 mars 2026" },
      ],
    },
  ];

  const [pageIdx, setPageIdx] = useState(0);
  const [isLoading, setIsLoading] = useState(false);
  const [direction, setDirection] = useState(1);

  const currentPage = resolvedPages[pageIdx];

  const navigate = useCallback((dir: 1 | -1) => {
    const nextIdx = pageIdx + dir;
    if (nextIdx < 0 || nextIdx >= resolvedPages.length) return;
    setDirection(dir);
    setIsLoading(true);
    setTimeout(() => {
      setPageIdx(nextIdx);
      setIsLoading(false);
    }, 400);
  }, [pageIdx, resolvedPages.length]);

  return (
    <section
      style={{
        padding: "var(--section-padding-y, 6rem) 0",
        background: "var(--color-background-alt)",
      }}
    >
      <div
        style={{
          maxWidth: "var(--container-max-width, 800px)",
          margin: "0 auto",
          padding: "0 var(--container-padding-x, 1.5rem)",
        }}
      >
        <motion.div
          initial={{ opacity: 0, y: 20 }}
          whileInView={{ opacity: 1, y: 0 }}
          viewport={{ once: true }}
          transition={{ duration: 0.5, ease: EASE }}
          style={{ textAlign: "center", marginBottom: "2.5rem" }}
        >
          <div style={{ display: "inline-flex", alignItems: "center", gap: "0.5rem", padding: "0.375rem 0.75rem", borderRadius: "999px", background: "color-mix(in srgb, var(--color-accent) 10%, transparent)", marginBottom: "1rem" }}>
            <Database style={{ width: 14, height: 14, color: "var(--color-accent)" }} />
            <span style={{ fontSize: "0.75rem", fontWeight: 600, color: "var(--color-accent)", textTransform: "uppercase", letterSpacing: "0.05em" }}>
              {resolvedBadge}
            </span>
          </div>
          <h2 style={{ fontSize: "clamp(1.75rem, 3vw, 2.5rem)", fontWeight: 700, color: "var(--color-foreground)", marginBottom: "0.75rem", letterSpacing: "-0.02em" }}>
            {resolvedTitle}
          </h2>
          <p style={{ fontSize: "1rem", color: "var(--color-foreground-muted)", maxWidth: "500px", margin: "0 auto", lineHeight: 1.6 }}>
            {resolvedSubtitle}
          </p>
        </motion.div>

        {/* Cursor info */}
        <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "1rem" }}>
          <code style={{ fontSize: "0.75rem", fontFamily: "monospace", color: "var(--color-foreground-muted)", padding: "0.25rem 0.625rem", borderRadius: "6px", background: "var(--color-background-card)", border: "1px solid var(--color-border)" }}>
            cursor: {currentPage.cursor}
          </code>
          <span style={{ fontSize: "0.75rem", color: "var(--color-foreground-light)" }}>
            Page {pageIdx + 1} / {resolvedPages.length}
          </span>
        </div>

        {/* Items list */}
        <div
          style={{
            borderRadius: "16px",
            border: "1px solid var(--color-border)",
            background: "var(--color-background-card)",
            overflow: "hidden",
            minHeight: "320px",
            position: "relative",
          }}
        >
          {isLoading && (
            <motion.div
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              style={{
                position: "absolute",
                inset: 0,
                display: "flex",
                alignItems: "center",
                justifyContent: "center",
                background: "color-mix(in srgb, var(--color-background-card) 80%, transparent)",
                zIndex: 10,
              }}
            >
              <Loader2 style={{ width: 24, height: 24, color: "var(--color-accent)", animation: "spin 1s linear infinite" }} />
            </motion.div>
          )}

          <AnimatePresence mode="wait">
            <motion.div
              key={currentPage.cursor}
              initial={{ opacity: 0, x: direction * 40 }}
              animate={{ opacity: 1, x: 0 }}
              exit={{ opacity: 0, x: direction * -40 }}
              transition={{ duration: 0.3, ease: EASE }}
            >
              {currentPage.items.map((item, i) => {
                const status = statusStyles[item.status];
                return (
                  <div
                    key={item.id}
                    style={{
                      display: "flex",
                      alignItems: "center",
                      justifyContent: "space-between",
                      padding: "1rem 1.25rem",
                      borderBottom: i < currentPage.items.length - 1 ? "1px solid var(--color-border)" : "none",
                      gap: "1rem",
                    }}
                  >
                    <div style={{ flex: 1, minWidth: 0 }}>
                      <div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "0.25rem" }}>
                        <code style={{ fontSize: "0.8125rem", fontWeight: 600, color: "var(--color-foreground)", fontFamily: "monospace" }}>
                          {item.name}
                        </code>
                        <span
                          style={{
                            fontSize: "0.625rem",
                            fontWeight: 600,
                            padding: "0.0625rem 0.5rem",
                            borderRadius: "999px",
                            background: `color-mix(in srgb, ${status.color} 12%, transparent)`,
                            color: status.color,
                          }}
                        >
                          {status.label}
                        </span>
                      </div>
                      <p style={{ fontSize: "0.75rem", color: "var(--color-foreground-muted)", margin: 0 }}>
                        {item.description}
                      </p>
                    </div>
                    <span style={{ fontSize: "0.6875rem", color: "var(--color-foreground-light)", whiteSpace: "nowrap" }}>
                      {item.date}
                    </span>
                  </div>
                );
              })}
            </motion.div>
          </AnimatePresence>
        </div>

        {/* Navigation */}
        <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginTop: "1.25rem" }}>
          <motion.button
            whileHover={{ scale: currentPage.hasPrev ? 1.03 : 1 }}
            whileTap={{ scale: currentPage.hasPrev ? 0.97 : 1 }}
            onClick={() => navigate(-1)}
            disabled={!currentPage.hasPrev || isLoading}
            style={{
              display: "flex",
              alignItems: "center",
              gap: "0.375rem",
              padding: "0.625rem 1.25rem",
              borderRadius: "10px",
              border: "1px solid var(--color-border)",
              background: "var(--color-background-card)",
              color: currentPage.hasPrev ? "var(--color-foreground)" : "var(--color-foreground-light)",
              fontSize: "0.8125rem",
              fontWeight: 500,
              cursor: currentPage.hasPrev ? "pointer" : "not-allowed",
              opacity: currentPage.hasPrev ? 1 : 0.5,
              fontFamily: "inherit",
            }}
          >
            <ChevronLeft style={{ width: 16, height: 16 }} />
            Precedent
          </motion.button>

          {/* Dots */}
          <div style={{ display: "flex", gap: "0.375rem" }}>
            {resolvedPages.map((_, i) => (
              <div
                key={i}
                style={{
                  width: i === pageIdx ? "24px" : "8px",
                  height: "8px",
                  borderRadius: "4px",
                  background: i === pageIdx ? "var(--color-accent)" : "var(--color-border)",
                  transition: "all 0.3s",
                }}
              />
            ))}
          </div>

          <motion.button
            whileHover={{ scale: currentPage.hasNext ? 1.03 : 1 }}
            whileTap={{ scale: currentPage.hasNext ? 0.97 : 1 }}
            onClick={() => navigate(1)}
            disabled={!currentPage.hasNext || isLoading}
            style={{
              display: "flex",
              alignItems: "center",
              gap: "0.375rem",
              padding: "0.625rem 1.25rem",
              borderRadius: "10px",
              border: currentPage.hasNext ? "none" : "1px solid var(--color-border)",
              background: currentPage.hasNext ? "var(--color-accent)" : "var(--color-background-card)",
              color: currentPage.hasNext ? "var(--color-background)" : "var(--color-foreground-light)",
              fontSize: "0.8125rem",
              fontWeight: 600,
              cursor: currentPage.hasNext ? "pointer" : "not-allowed",
              opacity: currentPage.hasNext ? 1 : 0.5,
              fontFamily: "inherit",
            }}
          >
            Suivant
            <ChevronRight style={{ width: 16, height: 16 }} />
          </motion.button>
        </div>
      </div>

      <style>{`
        @keyframes spin {
          from { transform: rotate(0deg); }
          to { transform: rotate(360deg); }
        }
      `}</style>
    </section>
  );
}

Avis

Pagination Cursor Based — React Pagination Section — Incubator