Load More Pagination
Original · freeload-more button with progress
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/page-load-more.json"use client";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
type Group = "Galaxy" | "Nebula" | "Cluster";
interface DeepSkyObject {
id: string;
alias: string;
name: string;
type: string;
group: Group;
constellation: string;
magnitude: number;
distance: string;
}
const OBJECTS: DeepSkyObject[] = [
{ id: "M31", alias: "Andromeda Galaxy", name: "NGC 224", type: "Spiral Galaxy", group: "Galaxy", constellation: "Andromeda", magnitude: 3.4, distance: "2.5M ly" },
{ id: "M42", alias: "Orion Nebula", name: "NGC 1976", type: "Diffuse Nebula", group: "Nebula", constellation: "Orion", magnitude: 4.0, distance: "1,344 ly" },
{ id: "M45", alias: "Pleiades", name: "The Seven Sisters", type: "Open Cluster", group: "Cluster", constellation: "Taurus", magnitude: 1.6, distance: "444 ly" },
{ id: "M13", alias: "Hercules Cluster", name: "NGC 6205", type: "Globular Cluster", group: "Cluster", constellation: "Hercules", magnitude: 5.8, distance: "22,200 ly" },
{ id: "M1", alias: "Crab Nebula", name: "NGC 1952", type: "Supernova Remnant", group: "Nebula", constellation: "Taurus", magnitude: 8.4, distance: "6,500 ly" },
{ id: "M51", alias: "Whirlpool Galaxy", name: "NGC 5194", type: "Spiral Galaxy", group: "Galaxy", constellation: "Canes Venatici", magnitude: 8.4, distance: "23M ly" },
{ id: "M27", alias: "Dumbbell Nebula", name: "NGC 6853", type: "Planetary Nebula", group: "Nebula", constellation: "Vulpecula", magnitude: 7.5, distance: "1,360 ly" },
{ id: "M57", alias: "Ring Nebula", name: "NGC 6720", type: "Planetary Nebula", group: "Nebula", constellation: "Lyra", magnitude: 8.8, distance: "2,300 ly" },
{ id: "M104", alias: "Sombrero Galaxy", name: "NGC 4594", type: "Spiral Galaxy", group: "Galaxy", constellation: "Virgo", magnitude: 8.0, distance: "29.3M ly" },
{ id: "M81", alias: "Bode's Galaxy", name: "NGC 3031", type: "Spiral Galaxy", group: "Galaxy", constellation: "Ursa Major", magnitude: 6.9, distance: "11.8M ly" },
{ id: "M82", alias: "Cigar Galaxy", name: "NGC 3034", type: "Starburst Galaxy", group: "Galaxy", constellation: "Ursa Major", magnitude: 8.4, distance: "12M ly" },
{ id: "M87", alias: "Virgo A", name: "NGC 4486", type: "Elliptical Galaxy", group: "Galaxy", constellation: "Virgo", magnitude: 8.6, distance: "53.5M ly" },
{ id: "M101", alias: "Pinwheel Galaxy", name: "NGC 5457", type: "Spiral Galaxy", group: "Galaxy", constellation: "Ursa Major", magnitude: 7.9, distance: "20.9M ly" },
{ id: "M63", alias: "Sunflower Galaxy", name: "NGC 5055", type: "Spiral Galaxy", group: "Galaxy", constellation: "Canes Venatici", magnitude: 8.6, distance: "27M ly" },
{ id: "M64", alias: "Black Eye Galaxy", name: "NGC 4826", type: "Spiral Galaxy", group: "Galaxy", constellation: "Coma Berenices", magnitude: 8.5, distance: "17M ly" },
{ id: "M8", alias: "Lagoon Nebula", name: "NGC 6523", type: "Emission Nebula", group: "Nebula", constellation: "Sagittarius", magnitude: 6.0, distance: "4,100 ly" },
{ id: "M16", alias: "Eagle Nebula", name: "NGC 6611", type: "Emission Nebula", group: "Nebula", constellation: "Serpens", magnitude: 6.0, distance: "7,000 ly" },
{ id: "M17", alias: "Omega Nebula", name: "NGC 6618", type: "Emission Nebula", group: "Nebula", constellation: "Sagittarius", magnitude: 6.0, distance: "5,500 ly" },
{ id: "M20", alias: "Trifid Nebula", name: "NGC 6514", type: "Emission Nebula", group: "Nebula", constellation: "Sagittarius", magnitude: 6.3, distance: "5,200 ly" },
{ id: "M97", alias: "Owl Nebula", name: "NGC 3587", type: "Planetary Nebula", group: "Nebula", constellation: "Ursa Major", magnitude: 9.9, distance: "2,030 ly" },
{ id: "M3", alias: "Globular in Canes", name: "NGC 5272", type: "Globular Cluster", group: "Cluster", constellation: "Canes Venatici", magnitude: 6.2, distance: "33,900 ly" },
{ id: "M4", alias: "Cat's Eye Cluster", name: "NGC 6121", type: "Globular Cluster", group: "Cluster", constellation: "Scorpius", magnitude: 5.9, distance: "7,200 ly" },
{ id: "M15", alias: "Great Pegasus Cluster", name: "NGC 7078", type: "Globular Cluster", group: "Cluster", constellation: "Pegasus", magnitude: 6.2, distance: "33,600 ly" },
{ id: "M22", alias: "Sagittarius Cluster", name: "NGC 6656", type: "Globular Cluster", group: "Cluster", constellation: "Sagittarius", magnitude: 5.1, distance: "10,600 ly" },
];
const BATCH = 6;
const TOTAL = OBJECTS.length;
const GROUP_BADGE: Record<Group, string> = {
Galaxy:
"bg-violet-100 text-violet-700 ring-violet-500/25 dark:bg-violet-500/15 dark:text-violet-300 dark:ring-violet-400/30",
Nebula:
"bg-rose-100 text-rose-700 ring-rose-500/25 dark:bg-rose-500/15 dark:text-rose-300 dark:ring-rose-400/30",
Cluster:
"bg-emerald-100 text-emerald-700 ring-emerald-500/25 dark:bg-emerald-500/15 dark:text-emerald-300 dark:ring-emerald-400/30",
};
function GroupGlyph({ group }: { group: Group }) {
if (group === "Galaxy") {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-4 w-4">
<path
d="M12 12c4-3 8-2 8 0s-5 5-8 5-8-1-8-5 4-5 8-5"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
<circle cx="12" cy="12" r="1.6" fill="currentColor" />
</svg>
);
}
if (group === "Nebula") {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-4 w-4">
<path
d="M7 16a4 4 0 0 1-.4-8 5 5 0 0 1 9.7-1.3A3.5 3.5 0 0 1 16 16Z"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinejoin="round"
/>
</svg>
);
}
return (
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-4 w-4">
<g fill="currentColor">
<circle cx="12" cy="7" r="1.4" />
<circle cx="8" cy="12" r="1.4" />
<circle cx="16" cy="12" r="1.4" />
<circle cx="10" cy="16" r="1.4" />
<circle cx="15" cy="16" r="1.4" />
</g>
</svg>
);
}
export default function PageLoadMore() {
const prefersReduced = useReducedMotion();
const [visibleCount, setVisibleCount] = useState<number>(BATCH);
const [loading, setLoading] = useState<boolean>(false);
const [saved, setSaved] = useState<Set<string>>(() => new Set<string>());
const [announcement, setAnnouncement] = useState<string>("");
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const cardRefs = useRef<Array<HTMLElement | null>>([]);
const batchStartRef = useRef<number>(0);
const focusIndexRef = useRef<number | null>(null);
const labelId = useId();
const progressId = useId();
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
useEffect(() => {
if (focusIndexRef.current === null) return;
const el = cardRefs.current[focusIndexRef.current];
focusIndexRef.current = null;
if (el) el.focus();
}, [visibleCount]);
const remaining = TOTAL - visibleCount;
const percent = Math.round((visibleCount / TOTAL) * 100);
const isComplete = visibleCount >= TOTAL;
const loadMore = useCallback(() => {
if (loading || visibleCount >= TOTAL) return;
setLoading(true);
const startFrom = visibleCount;
timerRef.current = setTimeout(() => {
const next = Math.min(startFrom + BATCH, TOTAL);
batchStartRef.current = startFrom;
focusIndexRef.current = startFrom;
setVisibleCount(next);
setLoading(false);
setAnnouncement(
`Loaded ${next - startFrom} more. Showing ${next} of ${TOTAL} objects, ${Math.round(
(next / TOTAL) * 100,
)} percent.`,
);
}, 650);
}, [loading, visibleCount]);
const toggleSave = useCallback((id: string) => {
setSaved((prev) => {
const nextSet = new Set(prev);
if (nextSet.has(id)) nextSet.delete(id);
else nextSet.add(id);
return nextSet;
});
}, []);
const clearSaved = useCallback(() => setSaved(new Set<string>()), []);
const focusRing =
"focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950";
const visible = OBJECTS.slice(0, visibleCount);
return (
<section className="relative w-full overflow-hidden bg-white px-5 py-16 text-slate-900 sm:px-8 sm:py-24 dark:bg-slate-950 dark:text-slate-100">
<style>{`
@keyframes plm-spin { to { transform: rotate(360deg); } }
@keyframes plm-shimmer { 0% { transform: translateX(-120%); } 100% { transform: translateX(320%); } }
@keyframes plm-twinkle { 0%, 100% { opacity: .25; } 50% { opacity: 1; } }
.plm-spin { animation: plm-spin .8s linear infinite; transform-origin: center; }
.plm-shimmer { animation: plm-shimmer 1.5s ease-in-out infinite; }
.plm-twinkle { animation: plm-twinkle 3.4s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.plm-spin, .plm-shimmer, .plm-twinkle { animation: none !important; }
}
`}</style>
{/* atmospheric backdrop */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(60%_50%_at_50%_-10%,rgba(99,102,241,0.12),transparent_70%)] dark:bg-[radial-gradient(60%_50%_at_50%_-10%,rgba(129,140,248,0.16),transparent_70%)]"
/>
<div className="mx-auto max-w-6xl">
{/* Header */}
<header className="mb-10 max-w-2xl">
<span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-medium uppercase tracking-[0.18em] text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400">
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-3.5 w-3.5 text-indigo-500 dark:text-indigo-400">
<path
d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"
fill="currentColor"
className="plm-twinkle"
/>
</svg>
Northern-sky log
</span>
<h2 className="mt-4 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl dark:text-slate-50">
The Messier Catalogue
</h2>
<p className="mt-3 text-base leading-relaxed text-slate-600 dark:text-slate-400">
110 deep-sky objects Charles Messier logged so comet-hunters would stop
mistaking them for comets. Browse a curated set of the brightest, six at a
time, and build an observing list for your next clear night.
</p>
</header>
{/* Progress panel */}
<div className="mb-8 rounded-2xl border border-slate-200 bg-slate-50/70 p-5 shadow-sm sm:p-6 dark:border-slate-800 dark:bg-slate-900/50">
<div className="flex flex-wrap items-end justify-between gap-x-6 gap-y-3">
<div>
<p id={labelId} className="text-sm font-medium text-slate-700 dark:text-slate-300">
Showing{" "}
<span className="tabular-nums text-slate-900 dark:text-slate-50">{visibleCount}</span> of{" "}
<span className="tabular-nums text-slate-900 dark:text-slate-50">{TOTAL}</span> objects
</p>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-500">
{isComplete
? "Full selection loaded"
: `${remaining} more in this collection`}
</p>
</div>
<div className="flex items-center gap-3">
{saved.size > 0 && (
<button
type="button"
onClick={clearSaved}
className={`rounded-lg px-2.5 py-1 text-xs font-medium text-slate-500 underline-offset-2 transition hover:text-slate-800 hover:underline dark:text-slate-400 dark:hover:text-slate-200 ${focusRing}`}
>
Clear list
</button>
)}
<span className="inline-flex items-center gap-1.5 rounded-full bg-amber-100 px-3 py-1 text-xs font-semibold text-amber-800 ring-1 ring-amber-500/25 dark:bg-amber-400/15 dark:text-amber-300 dark:ring-amber-400/30">
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-3.5 w-3.5">
<path
d="M12 3.5l2.6 5.3 5.9.9-4.3 4.1 1 5.8L12 17l-5.2 2.6 1-5.8-4.3-4.1 5.9-.9z"
fill="currentColor"
/>
</svg>
<span className="tabular-nums">{saved.size}</span> saved
</span>
<span className="text-2xl font-semibold tabular-nums text-indigo-600 dark:text-indigo-400">
{percent}
<span className="text-base font-normal text-slate-400 dark:text-slate-500">%</span>
</span>
</div>
</div>
<div
role="progressbar"
aria-labelledby={labelId}
aria-valuemin={0}
aria-valuemax={TOTAL}
aria-valuenow={visibleCount}
aria-valuetext={`${visibleCount} of ${TOTAL} objects, ${percent} percent`}
className="relative mt-4 h-2.5 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800"
>
<div
className="relative h-full rounded-full bg-gradient-to-r from-sky-500 via-indigo-500 to-violet-500 transition-[width] duration-700 ease-out motion-reduce:transition-none"
style={{ width: `${percent}%` }}
>
{loading && (
<span
aria-hidden="true"
className="plm-shimmer absolute inset-y-0 left-0 w-1/3 bg-gradient-to-r from-transparent via-white/60 to-transparent"
/>
)}
</div>
</div>
</div>
{/* Grid */}
<ul className="grid list-none grid-cols-1 gap-4 p-0 sm:grid-cols-2 lg:grid-cols-3">
{visible.map((obj, index) => {
const isNew = index >= batchStartRef.current;
const delay = prefersReduced ? 0 : Math.max(0, index - batchStartRef.current) * 0.06;
const isSaved = saved.has(obj.id);
return (
<motion.li
key={obj.id}
initial={prefersReduced || !isNew ? false : { opacity: 0, y: 14 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay, ease: [0.22, 1, 0.36, 1] }}
className="list-none"
>
<article
ref={(el: HTMLElement | null) => {
cardRefs.current[index] = el;
}}
tabIndex={-1}
aria-label={`${obj.id}, ${obj.alias}, ${obj.type} in ${obj.constellation}`}
className={`group relative flex h-full flex-col rounded-2xl border border-slate-200 bg-white p-5 shadow-sm transition-colors hover:border-indigo-300 dark:border-slate-800 dark:bg-slate-900 dark:hover:border-indigo-500/50 ${focusRing}`}
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="font-mono text-xs font-semibold tracking-widest text-indigo-600 dark:text-indigo-400">
{obj.id}
</p>
<h3 className="mt-1 text-lg font-semibold leading-snug text-slate-900 dark:text-slate-50">
{obj.alias}
</h3>
<p className="text-sm text-slate-500 dark:text-slate-500">{obj.name}</p>
</div>
<button
type="button"
onClick={() => toggleSave(obj.id)}
aria-pressed={isSaved}
aria-label={
isSaved
? `Remove ${obj.alias} from observing list`
: `Add ${obj.alias} to observing list`
}
className={`grid h-9 w-9 shrink-0 place-items-center rounded-full border transition ${
isSaved
? "border-amber-400 bg-amber-100 text-amber-600 dark:border-amber-400/40 dark:bg-amber-400/15 dark:text-amber-300"
: "border-slate-200 text-slate-400 hover:border-amber-300 hover:text-amber-500 dark:border-slate-700 dark:text-slate-500 dark:hover:border-amber-400/40 dark:hover:text-amber-300"
} ${focusRing}`}
>
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className="h-5 w-5"
fill={isSaved ? "currentColor" : "none"}
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
>
<path d="M12 3.5l2.6 5.3 5.9.9-4.3 4.1 1 5.8L12 17l-5.2 2.6 1-5.8-4.3-4.1 5.9-.9z" />
</svg>
</button>
</div>
<span
className={`mt-4 inline-flex w-fit items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium ring-1 ring-inset ${GROUP_BADGE[obj.group]}`}
>
<GroupGlyph group={obj.group} />
{obj.type}
</span>
<dl className="mt-4 grid grid-cols-3 gap-2 border-t border-slate-100 pt-4 dark:border-slate-800">
<div>
<dt className="text-[11px] uppercase tracking-wide text-slate-400 dark:text-slate-500">
Const.
</dt>
<dd className="mt-0.5 truncate text-sm font-medium text-slate-700 dark:text-slate-300">
{obj.constellation}
</dd>
</div>
<div>
<dt className="text-[11px] uppercase tracking-wide text-slate-400 dark:text-slate-500">
Mag.
</dt>
<dd className="mt-0.5 text-sm font-medium tabular-nums text-slate-700 dark:text-slate-300">
{obj.magnitude.toFixed(1)}
</dd>
</div>
<div>
<dt className="text-[11px] uppercase tracking-wide text-slate-400 dark:text-slate-500">
Dist.
</dt>
<dd className="mt-0.5 truncate text-sm font-medium tabular-nums text-slate-700 dark:text-slate-300">
{obj.distance}
</dd>
</div>
</dl>
</article>
</motion.li>
);
})}
</ul>
{/* Footer control */}
<div className="mt-10 flex flex-col items-center gap-4">
{!isComplete ? (
<button
type="button"
onClick={loadMore}
disabled={loading}
aria-busy={loading}
className={`inline-flex items-center gap-2.5 rounded-full bg-slate-900 px-7 py-3.5 text-sm font-semibold text-white shadow-lg shadow-slate-900/10 transition hover:bg-slate-700 disabled:cursor-wait disabled:opacity-80 dark:bg-white dark:text-slate-900 dark:shadow-black/30 dark:hover:bg-slate-200 ${focusRing}`}
>
{loading ? (
<>
<svg viewBox="0 0 24 24" aria-hidden="true" className="plm-spin h-4 w-4">
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" />
<path d="M12 3a9 9 0 0 1 9 9" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
</svg>
Charting the sky…
</>
) : (
<>
Load {Math.min(BATCH, remaining)} more
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-4 w-4">
<path d="M12 5v14M6 13l6 6 6-6" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</>
)}
</button>
) : (
<div
ref={(el: HTMLElement | null) => {
cardRefs.current[TOTAL] = el;
}}
tabIndex={-1}
className={`inline-flex items-center gap-2 rounded-full border border-emerald-200 bg-emerald-50 px-6 py-3 text-sm font-medium text-emerald-800 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-300 ${focusRing}`}
>
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-4 w-4">
<path d="M5 13l4 4L19 7" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
You’ve reached the end of the catalogue
</div>
)}
{!isComplete && (
<p className="text-xs text-slate-400 dark:text-slate-500">
{remaining} object{remaining === 1 ? "" : "s"} remaining
</p>
)}
</div>
{/* Screen-reader live status */}
<p id={progressId} className="sr-only" role="status" aria-live="polite">
{announcement}
</p>
</div>
</section>
);
}Dependencies
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Basic Pagination
Originalbasic numbered pagination

Rounded Pagination
Originalrounded pagination buttons

Arrows Pagination
Originalprev/next arrow pagination

Compact Pagination
Originalcompact pagination with ellipsis

Input Pagination
Originalpagination with a go-to-page input

Dots Pagination
Originaldot pagination indicators

With Info Pagination
Originalpagination with results info

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

Split Hero
OriginalA two-column hero pairing a headline and CTAs with a product mock and social proof.

Gradient Spotlight Hero
OriginalA minimal centred hero with a soft gradient-mesh backdrop, announcement pill and dual call-to-action buttons.

App Preview Hero
OriginalA centred hero that pairs headline copy with a realistic product dashboard mock built entirely from markup, complete with browser chrome and a floating notification card.

Waitlist Capture Hero
OriginalA dark, focused hero with an inline email capture form and avatar social proof, ready for pre-launch waitlists.

