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

Avis

Pagination Infinite Scroll — React Pagination Section — Incubator