Web InnoventixFreeCode

Gallery Skeleton

Original · free

image grid skeleton

byWeb InnoventixReact + Tailwind
skelgalleryskeletons
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/skel-gallery.json
skel-gallery.tsx
"use client";

import {
  useState,
  useRef,
  useEffect,
  useCallback,
  useId,
  type KeyboardEvent as ReactKeyboardEvent,
  type ReactNode,
} from "react";
import { motion, useReducedMotion } from "motion/react";

type Cols = 2 | 3 | 4;
type Layout = "grid" | "masonry";

const MIN = 3;
const MAX = 18;
const STEP = 3;

const RING =
  "focus-visible: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-zinc-950";

const LABEL =
  "text-[0.7rem] font-medium uppercase tracking-wider text-zinc-500 dark:text-zinc-400";

const GRID_COLS: Record<Cols, string> = {
  2: "grid-cols-1 sm:grid-cols-2",
  3: "grid-cols-2 sm:grid-cols-3",
  4: "grid-cols-2 sm:grid-cols-4",
};

const MASONRY_COLS: Record<Cols, string> = {
  2: "columns-1 sm:columns-2",
  3: "columns-2 sm:columns-3",
  4: "columns-2 sm:columns-4",
};

const MASONRY_H = ["h-44", "h-64", "h-52", "h-72", "h-48", "h-60"];

const GRADIENTS = [
  "from-indigo-400 to-violet-500",
  "from-sky-400 to-indigo-500",
  "from-emerald-400 to-sky-500",
  "from-rose-400 to-amber-400",
  "from-amber-400 to-rose-400",
  "from-violet-400 to-sky-500",
];

const PHOTOS: { title: string; meta: string }[] = [
  { title: "Harbor fog at first light", meta: "Kodak Portra 400" },
  { title: "Concrete stairwell, floor 7", meta: "35mm · f/2.8" },
  { title: "Studio still life No. 3", meta: "Medium format" },
  { title: "Neon crosswalk after rain", meta: "Night series" },
  { title: "Dune ridge, golden hour", meta: "Aerial · drone" },
  { title: "Analog portrait, window light", meta: "Ilford HP5" },
  { title: "Glasshouse ferns", meta: "Botanic archive" },
  { title: "Salt flats reflection", meta: "Wide · 16mm" },
  { title: "Rooftop antenna cluster", meta: "Urban grain" },
  { title: "Cold brew, marble counter", meta: "Product set" },
  { title: "Mountain pass switchbacks", meta: "Long lens" },
  { title: "Empty theater, red seats", meta: "Available light" },
  { title: "Frost across the window", meta: "Macro · 90mm" },
  { title: "Ferry wake at dusk", meta: "Long exposure" },
  { title: "Paper textures, overhead", meta: "Flat lay" },
  { title: "Chrome diner at midnight", meta: "Neon series" },
  { title: "Cliff path, low tide", meta: "Coastal" },
  { title: "Loft with north windows", meta: "Interiors" },
];

function ImagesIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      width={18}
      height={18}
      fill="none"
      stroke="currentColor"
      strokeWidth={1.7}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <rect x="3" y="4" width="18" height="14" rx="2" />
      <circle cx="8.5" cy="9" r="1.5" />
      <path d="m21 15-5-5L5 20" />
    </svg>
  );
}

function ReloadIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      width={16}
      height={16}
      fill="none"
      stroke="currentColor"
      strokeWidth={1.8}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M21 12a9 9 0 1 1-2.64-6.36" />
      <path d="M21 4v5h-5" />
    </svg>
  );
}

function GridIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      width={15}
      height={15}
      fill="none"
      stroke="currentColor"
      strokeWidth={1.8}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <rect x="3" y="3" width="7" height="7" rx="1.5" />
      <rect x="14" y="3" width="7" height="7" rx="1.5" />
      <rect x="3" y="14" width="7" height="7" rx="1.5" />
      <rect x="14" y="14" width="7" height="7" rx="1.5" />
    </svg>
  );
}

function MasonryIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      width={15}
      height={15}
      fill="none"
      stroke="currentColor"
      strokeWidth={1.8}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <rect x="3" y="3" width="7" height="10" rx="1.5" />
      <rect x="3" y="15" width="7" height="6" rx="1.5" />
      <rect x="14" y="3" width="7" height="6" rx="1.5" />
      <rect x="14" y="11" width="7" height="10" rx="1.5" />
    </svg>
  );
}

function PlusIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      width={16}
      height={16}
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M12 5v14M5 12h14" />
    </svg>
  );
}

function MinusIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      width={16}
      height={16}
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M5 12h14" />
    </svg>
  );
}

function Segmented({
  label,
  options,
  value,
  onChange,
}: {
  label: string;
  options: readonly { value: string; label: string; icon?: ReactNode }[];
  value: string;
  onChange: (value: string) => void;
}) {
  const refs = useRef<(HTMLButtonElement | null)[]>([]);
  const groupId = useId();
  const current = Math.max(
    0,
    options.findIndex((o) => o.value === value),
  );

  const focusIndex = (i: number) => {
    const n = options.length;
    const wrapped = (i + n) % n;
    onChange(options[wrapped].value);
    refs.current[wrapped]?.focus();
  };

  const onKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
    switch (e.key) {
      case "ArrowRight":
      case "ArrowDown":
        e.preventDefault();
        focusIndex(current + 1);
        break;
      case "ArrowLeft":
      case "ArrowUp":
        e.preventDefault();
        focusIndex(current - 1);
        break;
      case "Home":
        e.preventDefault();
        focusIndex(0);
        break;
      case "End":
        e.preventDefault();
        focusIndex(options.length - 1);
        break;
      default:
        break;
    }
  };

  return (
    <div className="flex flex-col gap-1.5">
      <span id={groupId} className={LABEL}>
        {label}
      </span>
      <div
        role="radiogroup"
        aria-labelledby={groupId}
        onKeyDown={onKeyDown}
        className="inline-flex rounded-xl border border-zinc-200 bg-zinc-100/70 p-1 dark:border-zinc-800 dark:bg-zinc-900"
      >
        {options.map((o, i) => {
          const selected = o.value === value;
          return (
            <button
              key={o.value}
              ref={(el) => {
                refs.current[i] = el;
              }}
              type="button"
              role="radio"
              aria-checked={selected}
              tabIndex={selected ? 0 : -1}
              onClick={() => onChange(o.value)}
              className={[
                "inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium transition-colors",
                RING,
                selected
                  ? "bg-white text-zinc-900 shadow-sm dark:bg-zinc-700 dark:text-white"
                  : "text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100",
              ].join(" ")}
            >
              {o.icon}
              {o.label}
            </button>
          );
        })}
      </div>
    </div>
  );
}

export default function SkelGallery() {
  const reduce = useReducedMotion();

  const [loading, setLoading] = useState(true);
  const [cols, setCols] = useState<Cols>(3);
  const [layout, setLayout] = useState<Layout>("grid");
  const [shimmer, setShimmer] = useState(true);
  const [count, setCount] = useState(9);

  const timer = useRef<number | null>(null);

  const reload = useCallback(() => {
    if (timer.current !== null) window.clearTimeout(timer.current);
    setLoading(true);
    timer.current = window.setTimeout(() => setLoading(false), 1500);
  }, []);

  useEffect(() => {
    reload();
    return () => {
      if (timer.current !== null) window.clearTimeout(timer.current);
    };
  }, [reload]);

  const photos = PHOTOS.slice(0, count);

  const containerClass =
    layout === "grid"
      ? `grid gap-4 ${GRID_COLS[cols]}`
      : `${MASONRY_COLS[cols]} gap-4`;

  const itemBase =
    "group relative overflow-hidden rounded-2xl border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900";
  const itemExtra = layout === "masonry" ? "mb-4 w-full break-inside-avoid" : "";

  return (
    <section className="relative w-full overflow-hidden bg-gradient-to-b from-zinc-50 to-white px-5 py-16 sm:px-8 sm:py-20 dark:from-zinc-950 dark:to-zinc-900">
      <div
        aria-hidden="true"
        className="pointer-events-none absolute -top-24 left-1/2 h-72 w-[42rem] max-w-[90%] -translate-x-1/2 rounded-full bg-indigo-400/20 blur-3xl dark:bg-indigo-500/10"
      />

      <div className="relative mx-auto max-w-5xl">
        {/* Header */}
        <div className="mb-8 flex flex-col gap-5 sm:flex-row sm:items-end sm:justify-between">
          <div className="flex items-start gap-3">
            <span className="mt-0.5 grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-zinc-900 text-white dark:bg-white dark:text-zinc-900">
              <ImagesIcon />
            </span>
            <div>
              <h2 className="text-xl font-semibold tracking-tight text-zinc-900 sm:text-2xl dark:text-white">
                Gallery skeleton
              </h2>
              <p className="mt-1 max-w-md text-sm text-zinc-500 dark:text-zinc-400">
                A placeholder grid that shimmers while photos load, then fills
                in with a soft, staggered reveal.
              </p>
            </div>
          </div>

          <button
            type="button"
            onClick={reload}
            className={`inline-flex shrink-0 items-center gap-2 rounded-xl bg-zinc-900 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-zinc-700 dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-200 ${RING}`}
          >
            <ReloadIcon />
            Reload
          </button>
        </div>

        {/* Toolbar */}
        <div className="mb-6 flex flex-wrap items-end gap-x-6 gap-y-4 rounded-2xl border border-zinc-200 bg-white/60 p-4 backdrop-blur dark:border-zinc-800 dark:bg-zinc-900/40">
          <Segmented
            label="Columns"
            value={String(cols)}
            onChange={(v) => setCols(Number(v) as Cols)}
            options={[
              { value: "2", label: "2" },
              { value: "3", label: "3" },
              { value: "4", label: "4" },
            ]}
          />

          <Segmented
            label="Layout"
            value={layout}
            onChange={(v) => setLayout(v as Layout)}
            options={[
              { value: "grid", label: "Grid", icon: <GridIcon /> },
              { value: "masonry", label: "Masonry", icon: <MasonryIcon /> },
            ]}
          />

          {/* Shimmer switch */}
          <div className="flex flex-col gap-1.5">
            <span className={LABEL}>Shimmer</span>
            <button
              type="button"
              role="switch"
              aria-checked={shimmer}
              aria-label="Shimmer animation"
              onClick={() => setShimmer((s) => !s)}
              className={[
                "inline-flex h-9 items-center gap-2 rounded-xl border px-3 transition-colors",
                RING,
                shimmer
                  ? "border-indigo-300 bg-indigo-50 dark:border-indigo-500/40 dark:bg-indigo-500/10"
                  : "border-zinc-200 bg-zinc-100/70 dark:border-zinc-800 dark:bg-zinc-900",
              ].join(" ")}
            >
              <span
                aria-hidden="true"
                className={`relative h-5 w-9 rounded-full transition-colors ${
                  shimmer ? "bg-indigo-500" : "bg-zinc-300 dark:bg-zinc-700"
                }`}
              >
                <span
                  className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow transition-all ${
                    shimmer ? "left-4" : "left-0.5"
                  }`}
                />
              </span>
              <span className="text-sm font-medium text-zinc-700 dark:text-zinc-300">
                {shimmer ? "On" : "Off"}
              </span>
            </button>
          </div>

          {/* Tiles stepper */}
          <div className="flex flex-col gap-1.5">
            <span className={LABEL}>Tiles</span>
            <div
              role="group"
              aria-label="Number of tiles"
              className="inline-flex h-9 items-center rounded-xl border border-zinc-200 bg-zinc-100/70 dark:border-zinc-800 dark:bg-zinc-900"
            >
              <button
                type="button"
                aria-label="Fewer tiles"
                disabled={count <= MIN}
                onClick={() => setCount((c) => Math.max(MIN, c - STEP))}
                className={`grid h-9 w-9 place-items-center rounded-l-xl text-zinc-600 transition-colors hover:text-zinc-900 disabled:cursor-not-allowed disabled:opacity-40 dark:text-zinc-400 dark:hover:text-zinc-100 ${RING}`}
              >
                <MinusIcon />
              </button>
              <span
                aria-hidden="true"
                className="w-9 text-center text-sm font-semibold tabular-nums text-zinc-900 dark:text-zinc-100"
              >
                {count}
              </span>
              <button
                type="button"
                aria-label="More tiles"
                disabled={count >= MAX}
                onClick={() => setCount((c) => Math.min(MAX, c + STEP))}
                className={`grid h-9 w-9 place-items-center rounded-r-xl text-zinc-600 transition-colors hover:text-zinc-900 disabled:cursor-not-allowed disabled:opacity-40 dark:text-zinc-400 dark:hover:text-zinc-100 ${RING}`}
              >
                <PlusIcon />
              </button>
            </div>
          </div>

          {/* Status */}
          <div className="ml-auto flex items-center gap-2 self-center">
            <span
              aria-hidden="true"
              className={`h-2 w-2 rounded-full ${
                loading ? "skelgal-pulse bg-amber-400" : "bg-emerald-500"
              }`}
            />
            <p
              role="status"
              aria-live="polite"
              className="text-sm font-medium text-zinc-600 dark:text-zinc-300"
            >
              {loading
                ? `Loading ${count} photos…`
                : `${count} photos ready`}
            </p>
          </div>
        </div>

        {/* Gallery */}
        <div
          role="list"
          aria-label="Photo gallery"
          aria-busy={loading}
          className={containerClass}
        >
          {photos.map((photo, i) => {
            const imgClass =
              layout === "grid"
                ? "aspect-[4/3]"
                : MASONRY_H[i % MASONRY_H.length];
            const gradient = GRADIENTS[i % GRADIENTS.length];

            return (
              <div
                key={photo.title}
                role="listitem"
                className={`${itemBase} ${itemExtra}`}
              >
                {loading ? (
                  <>
                    <div
                      className={`relative overflow-hidden bg-zinc-200 dark:bg-zinc-800 ${imgClass}`}
                    >
                      <span
                        aria-hidden="true"
                        className="skelgal-pulse absolute inset-0 bg-zinc-300/40 dark:bg-zinc-700/40"
                      />
                      <span
                        aria-hidden="true"
                        className="pointer-events-none absolute inset-0 overflow-hidden"
                      >
                        <span
                          className={`skelgal-band absolute inset-0 bg-gradient-to-r from-transparent via-white/70 to-transparent dark:via-white/10 ${
                            shimmer ? "skelgal-shimmer" : ""
                          }`}
                        />
                      </span>
                    </div>
                    <div className="flex items-center gap-3 p-4">
                      <span
                        aria-hidden="true"
                        className="skelgal-pulse h-9 w-9 shrink-0 rounded-full bg-zinc-200 dark:bg-zinc-800"
                      />
                      <div className="flex w-full flex-col gap-2">
                        <span
                          aria-hidden="true"
                          className="skelgal-pulse h-2.5 w-3/4 rounded-full bg-zinc-200 dark:bg-zinc-800"
                        />
                        <span
                          aria-hidden="true"
                          className="skelgal-pulse h-2.5 w-2/5 rounded-full bg-zinc-200 dark:bg-zinc-800"
                        />
                      </div>
                    </div>
                  </>
                ) : (
                  <motion.div
                    initial={reduce ? false : { opacity: 0, y: 10 }}
                    animate={{ opacity: 1, y: 0 }}
                    transition={{
                      duration: 0.45,
                      delay: reduce ? 0 : i * 0.05,
                      ease: [0.22, 1, 0.36, 1],
                    }}
                  >
                    <div
                      className={`relative overflow-hidden bg-gradient-to-br ${gradient} ${imgClass}`}
                    >
                      <span className="absolute inset-0 transition-transform duration-500 ease-out group-hover:scale-[1.04]">
                        <span className="absolute inset-0 bg-gradient-to-t from-black/35 to-transparent" />
                      </span>
                      <span className="absolute left-3 top-3 rounded-full bg-white/85 px-2 py-0.5 text-[0.65rem] font-semibold tabular-nums text-zinc-800 backdrop-blur">
                        {String(i + 1).padStart(2, "0")}
                      </span>
                    </div>
                    <div className="flex items-center gap-3 p-4">
                      <span
                        className={`grid h-9 w-9 shrink-0 place-items-center rounded-full bg-gradient-to-br ${gradient} text-xs font-bold text-white`}
                      >
                        {photo.title.slice(0, 1)}
                      </span>
                      <div className="min-w-0">
                        <p className="truncate text-sm font-medium text-zinc-900 dark:text-zinc-100">
                          {photo.title}
                        </p>
                        <p className="truncate text-xs text-zinc-500 dark:text-zinc-400">
                          {photo.meta}
                        </p>
                      </div>
                    </div>
                  </motion.div>
                )}
              </div>
            );
          })}
        </div>
      </div>

      <style>{`
        @keyframes skelgal-sweep {
          0% { transform: translateX(-120%); }
          100% { transform: translateX(120%); }
        }
        @keyframes skelgal-breathe {
          0%, 100% { opacity: 0.45; }
          50% { opacity: 0.85; }
        }
        .skelgal-band { transform: translateX(-120%); }
        .skelgal-shimmer { animation: skelgal-sweep 1.8s ease-in-out infinite; }
        .skelgal-pulse { animation: skelgal-breathe 2.6s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .skelgal-shimmer, .skelgal-pulse { animation: none !important; }
        }
      `}</style>
    </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 →