Components
A load-more button with automatic infinite-scroll via IntersectionObserver, animated idle/loading/error/end states, and a bounded auto-load limit before it hands control back to the user.

"use client";
import { LoadMore } from "@/components/ui/load-more";
import { useEffect, useRef, useState } from "react";
import { motion } from "motion/react";
const TOTAL = 40;
const PAGE = 8;
const EASE = [0.23, 1, 0.32, 1] as const;
export default function LoadMoreDemo() {
const [count, setCount] = useState(PAGE);
const scroller = useRef<HTMLDivElement>(null);
const loaded = useRef(PAGE);
const timers = useRef(new Set<ReturnType<typeof setTimeout>>());
useEffect(() => {
const running = timers.current;
return () => {
running.forEach(clearTimeout);
running.clear();
};
}, []);
const load = () =>
new Promise<boolean>((resolve) => {
const next = Math.min(TOTAL, loaded.current + PAGE);
const id = setTimeout(() => {
timers.current.delete(id);
loaded.current = next;
setCount(next);
resolve(next < TOTAL);
}, 700);
timers.current.add(id);
});
return (
<div
ref={scroller}
className="mx-auto h-[360px] w-full max-w-sm overflow-y-auto overscroll-contain rounded-[14px] border border-stone-200 bg-white p-3 dark:border-white/[0.16] dark:bg-[#1d1d1a]"
>
<ul className="space-y-1">
{Array.from({ length: count }, (_, i) => (
<motion.li
key={i}
initial={i < PAGE ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.26,
ease: EASE,
delay: (i % PAGE) * 0.03,
}}
className="mat-panel rounded-[9px] px-3 py-2.5 text-[12.5px] text-ink"
>
Item {String(i + 1).padStart(2, "0")}
</motion.li>
))}
</ul>
<div className="pt-3">
<LoadMore
onLoad={load}
hasMore={count < TOTAL}
rootRef={scroller}
rootMargin="120px 0px"
maxAutoLoads={2}
/>
</div>
</div>
);
}
Part of interior.dev — browse the full library on 21st.dev.