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>
);
}