Web InnoventixFreeCode

Mosaic Reveal Effect

Original · free

Image resolves from mosaic tiles on hover.

byWeb InnoventixReact + Tailwind
mosaicrevealimageeffect
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/imgfx-mosaic-reveal.json
imgfx-mosaic-reveal.tsx
"use client";

import { useMemo } from "react";

const COLS = 9;
const ROWS = 6;
const PHOTO = "/img/gallery/g07.webp";

type Tile = {
  key: string;
  bgPos: string;
  delayIn: number;
  delayOut: number;
};

export default function ImgfxMosaicReveal() {
  const tiles = useMemo<Tile[]>(() => {
    const cx = (COLS - 1) / 2;
    const cy = (ROWS - 1) / 2;
    const maxDist = Math.hypot(cx, cy) || 1;
    const out: Tile[] = [];
    for (let r = 0; r < ROWS; r++) {
      for (let c = 0; c < COLS; c++) {
        const dist = Math.hypot(c - cx, r - cy) / maxDist;
        const bgPos = `${(c / (COLS - 1)) * 100}% ${(r / (ROWS - 1)) * 100}%`;
        out.push({
          key: `${r}-${c}`,
          bgPos,
          // reveal outward from the centre on hover
          delayOut: Math.round(dist * 320),
          // re-assemble inward when the pointer leaves
          delayIn: Math.round((1 - dist) * 260),
        });
      }
    }
    return out;
  }, []);

  return (
    <section className="relative w-full overflow-hidden bg-neutral-50 px-6 py-20 text-neutral-900 sm:px-10 sm:py-28 dark:bg-neutral-950 dark:text-neutral-100">
      <style>{`
        @keyframes mzr-rise {
          from { opacity: 0; transform: translateY(18px); }
          to   { opacity: 1; transform: translateY(0); }
        }
        @keyframes mzr-hint {
          0%, 100% { opacity: .55; transform: translateX(0); }
          50%      { opacity: 1;   transform: translateX(3px); }
        }
        .mzr-rise { animation: mzr-rise .7s cubic-bezier(.22,1,.36,1) both; }
        .mzr-hint-dot { animation: mzr-hint 2.4s ease-in-out infinite; }

        /* Each tile carries the photo, sliced to its own cell. */
        .mzr-tile {
          background-image: var(--mzr-src);
          background-size: ${COLS * 100}% ${ROWS * 100}%;
          background-repeat: no-repeat;
          transform: scale(.9);
          filter: blur(1.4px) saturate(1.15) contrast(1.08);
          opacity: 1;
          transition:
            opacity .55s cubic-bezier(.22,1,.36,1),
            transform .55s cubic-bezier(.22,1,.36,1),
            filter .55s cubic-bezier(.22,1,.36,1);
          transition-delay: var(--mzr-in);
        }
        .mzr-stage:hover .mzr-tile,
        .mzr-stage:focus-visible .mzr-tile,
        .mzr-stage:focus-within .mzr-tile {
          opacity: 0;
          transform: scale(1.08);
          filter: blur(0) saturate(1) contrast(1);
          transition-delay: var(--mzr-out);
        }

        .mzr-photo {
          transform: scale(1.06);
          filter: blur(6px) saturate(.9);
          opacity: .78;
          transition: transform .8s cubic-bezier(.22,1,.36,1),
                      filter .8s cubic-bezier(.22,1,.36,1),
                      opacity .8s cubic-bezier(.22,1,.36,1);
        }
        .mzr-stage:hover .mzr-photo,
        .mzr-stage:focus-visible .mzr-photo,
        .mzr-stage:focus-within .mzr-photo {
          transform: scale(1);
          filter: blur(0) saturate(1);
          opacity: 1;
        }

        @media (prefers-reduced-motion: reduce) {
          .mzr-rise, .mzr-hint-dot { animation: none !important; }
          .mzr-tile {
            transition: none !important;
            opacity: 0 !important;
            transform: none !important;
            filter: none !important;
          }
          .mzr-photo {
            transition: none !important;
            transform: none !important;
            filter: none !important;
            opacity: 1 !important;
          }
        }
      `}</style>

      {/* soft ambient glow */}
      <div
        aria-hidden
        className="pointer-events-none absolute -top-24 right-[-10%] h-80 w-80 rounded-full bg-indigo-300/25 blur-3xl dark:bg-indigo-600/20"
      />

      <div className="relative mx-auto max-w-3xl">
        <div className="mzr-rise">
          <p className="text-xs font-semibold uppercase tracking-[0.28em] text-indigo-600 dark:text-indigo-400">
            Image Effect / Mosaic Reveal
          </p>
          <h2 className="mt-3 text-3xl font-semibold tracking-tight sm:text-4xl">
            From tiles to a photograph.
          </h2>
          <p className="mt-3 max-w-xl text-sm leading-relaxed text-neutral-600 dark:text-neutral-400">
            Hundreds of blurred fragments hold the frame together until you look
            closer, then they dissolve outward and the picture snaps into focus.
          </p>
        </div>

        <figure className="mzr-rise mt-10" style={{ animationDelay: "120ms" }}>
          <div
            tabIndex={0}
            role="img"
            aria-label="Photograph that resolves from a grid of mosaic tiles on hover or focus"
            className="mzr-stage group relative aspect-[3/2] w-full cursor-pointer overflow-hidden rounded-2xl bg-neutral-200 shadow-xl shadow-neutral-900/10 ring-1 ring-neutral-900/5 outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:bg-neutral-800 dark:shadow-black/40 dark:ring-white/10"
          >
            {/* eslint-disable-next-line @next/next/no-img-element */}
            <img
              src={PHOTO}
              alt="A crafted studio photograph revealed beneath the mosaic tiles"
              loading="lazy"
              draggable={false}
              className="mzr-photo absolute inset-0 h-full w-full object-cover"
            />

            <div
              className="pointer-events-none absolute inset-0 grid p-[3px]"
              style={{
                gridTemplateColumns: `repeat(${COLS}, 1fr)`,
                gridTemplateRows: `repeat(${ROWS}, 1fr)`,
                gap: "3px",
                ["--mzr-src" as string]: `url(${PHOTO})`,
              }}
              aria-hidden
            >
              {tiles.map((t) => (
                <span
                  key={t.key}
                  className="mzr-tile rounded-[3px]"
                  style={{
                    backgroundPosition: t.bgPos,
                    ["--mzr-in" as string]: `${t.delayIn}ms`,
                    ["--mzr-out" as string]: `${t.delayOut}ms`,
                  }}
                />
              ))}
            </div>

            {/* readability veil that lifts on interaction */}
            <div
              aria-hidden
              className="pointer-events-none absolute inset-0 bg-gradient-to-t from-neutral-950/25 to-transparent opacity-100 transition-opacity duration-700 group-hover:opacity-0 group-focus-within:opacity-0"
            />
          </div>

          <figcaption className="mt-4 flex items-center gap-2 text-xs font-medium text-neutral-500 dark:text-neutral-400">
            <span className="mzr-hint-dot inline-block h-1.5 w-1.5 rounded-full bg-indigo-500" />
            Hover or focus the frame to resolve the image
          </figcaption>
        </figure>
      </div>
    </section>
  );
}

Dependencies

None (React + Tailwind only).

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 →