Retour au catalogue
Pagination Infinite Scroll
Scroll infini avec IntersectionObserver, animations d'apparition et indicateur de chargement.
paginationmedium Both Responsive a11y
minimalcorporatesaasecommerceuniversalstacked
Theme
"use client";
import { useRef, useEffect, useState, useCallback } from "react";
import { motion } from "framer-motion";
import { Loader2 } from "lucide-react";
interface InfiniteItem {
id: number;
title: string;
excerpt: string;
}
interface PaginationInfiniteScrollProps {
initialItems?: InfiniteItem[];
hasMore?: boolean;
}
export default function PaginationInfiniteScroll({
initialItems = [],
hasMore: initialHasMore = true,
}: PaginationInfiniteScrollProps) {
const [items, setItems] = useState(initialItems);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(initialHasMore);
const sentinelRef = useRef<HTMLDivElement>(null);
const loadMore = useCallback(() => {
if (loading || !hasMore) return;
setLoading(true);
setTimeout(() => {
const newItems: InfiniteItem[] = Array.from({ length: 4 }, (_, i) => ({
id: items.length + i + 1,
title: `Article ${items.length + i + 1}`,
excerpt: "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
}));
setItems((prev) => [...prev, ...newItems]);
if (items.length + 4 >= 24) setHasMore(false);
setLoading(false);
}, 600);
}, [loading, hasMore, items.length]);
useEffect(() => {
const el = sentinelRef.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => { if (entry.isIntersecting) loadMore(); },
{ threshold: 0.1 }
);
observer.observe(el);
return () => observer.disconnect();
}, [loadMore]);
return (
<div className="py-8 px-6" style={{ background: "var(--color-background)" }}>
<div className="mx-auto max-w-2xl flex flex-col gap-4">
{items.map((item, i) => (
<motion.div
key={item.id}
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: i > items.length - 5 ? (i % 4) * 0.05 : 0 }}
className="p-4 rounded-xl"
style={{
background: "var(--color-background-card)",
border: "1px solid var(--color-border)",
}}
>
<h3 className="text-sm font-semibold" style={{ color: "var(--color-foreground)" }}>{item.title}</h3>
<p className="mt-1 text-sm" style={{ color: "var(--color-foreground-muted)" }}>{item.excerpt}</p>
</motion.div>
))}
<div ref={sentinelRef} className="flex items-center justify-center py-6">
{loading && (
<Loader2 size={20} className="animate-spin" style={{ color: "var(--color-accent)" }} />
)}
{!hasMore && (
<span className="text-sm" style={{ color: "var(--color-foreground-light)" }}>
Tous les elements ont ete charges
</span>
)}
</div>
</div>
</div>
);
}