Retour au catalogue

Pagination Infinite

Infinite scroll avec sentinelle et spinner de chargement.

paginationsimple Both Responsive a11y
minimaluniversalstacked
Theme
"use client";

import { useRef, useEffect, useState, useCallback } from "react";
import { Loader2 } from "lucide-react";

interface InfiniteItem {
  id: number;
  title: string;
  excerpt: string;
}

interface PaginationInfiniteProps {
  maxItems?: number;
  batchSize?: number;
}

function generateItems(start: number, count: number): InfiniteItem[] {
  return Array.from({ length: count }, (_, i) => ({
    id: start + i + 1,
    title: `Article ${start + i + 1}`,
    excerpt: "Apercu du contenu de cet article avec un court resume descriptif.",
  }));
}

export default function PaginationInfinite({
  maxItems = 24,
  batchSize = 6,
}: PaginationInfiniteProps) {
  const [items, setItems] = useState<InfiniteItem[]>(() => generateItems(0, batchSize));
  const [loading, setLoading] = useState(false);
  const [hasMore, setHasMore] = useState(true);
  const sentinelRef = useRef<HTMLDivElement>(null);

  const loadMore = useCallback(() => {
    if (loading || !hasMore) return;
    setLoading(true);
    setTimeout(() => {
      setItems((prev) => {
        const newItems = generateItems(prev.length, batchSize);
        const merged = [...prev, ...newItems];
        if (merged.length >= maxItems) setHasMore(false);
        return merged;
      });
      setLoading(false);
    }, 600);
  }, [loading, hasMore, batchSize, maxItems]);

  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-3">
        {items.map((item) => (
          <div
            key={item.id}
            className="p-4 rounded-xl transition-opacity duration-300"
            style={{
              background: "var(--color-background-card)",
              border: "1px solid var(--color-border)",
              animation: "infinite-fade-in 0.3s ease-out",
            }}
          >
            <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>
          </div>
        ))}

        <div ref={sentinelRef} className="flex items-center justify-center py-8">
          {loading && (
            <div className="flex flex-col items-center gap-2">
              <Loader2
                size={20}
                className="animate-spin"
                style={{ color: "var(--color-accent)" }}
              />
              <span
                className="text-xs"
                style={{ color: "var(--color-foreground-light)" }}
              >
                Chargement...
              </span>
            </div>
          )}
          {!hasMore && (
            <span
              className="text-sm"
              style={{ color: "var(--color-foreground-light)" }}
            >
              Tous les elements ont ete charges
            </span>
          )}
        </div>
      </div>

      <style>{`
        @keyframes infinite-fade-in {
          from { opacity: 0; transform: translateY(8px); }
          to   { opacity: 1; transform: translateY(0); }
        }
      `}</style>
    </div>
  );
}

Avis

Pagination Infinite — React Pagination Section — Incubator