Web InnoventixFreeCode

Load More Pagination

Original · free

load-more button with progress

byWeb InnoventixReact + Tailwind
pageloadmorepagination
Open

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
page-load-more.tsx
"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&rsquo;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

motion

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 quote

Similar components

Browse all →