Web InnoventixFreeCode

Hover Swap Effect

Original · free

The image swaps to an alternate shot on hover.

byWeb InnoventixReact + Tailwind
hoverswapimageeffect
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-hover-swap.json
imgfx-hover-swap.tsx
"use client";

import { useState } from "react";

type SwapPair = {
  base: string;
  baseAlt: string;
  swap: string;
  swapAlt: string;
  label: string;
  hint: string;
};

const PAIRS: SwapPair[] = [
  {
    base: "/img/gallery/g07.webp",
    baseAlt: "Studio portrait in soft daylight",
    swap: "/img/gallery/g08.webp",
    swapAlt: "The same subject captured mid-laugh",
    label: "Candid",
    hint: "Hover to catch the second frame",
  },
  {
    base: "/img/gallery/g11.webp",
    baseAlt: "Product shot on a clean backdrop",
    swap: "/img/gallery/g12.webp",
    swapAlt: "The product from a low hero angle",
    label: "Angle",
    hint: "Hover for the alternate take",
  },
  {
    base: "/img/gallery/g19.webp",
    baseAlt: "City street at golden hour",
    swap: "/img/gallery/g20.webp",
    swapAlt: "The same street after the lights come on",
    label: "Golden → Blue",
    hint: "Hover to shift the hour",
  },
];

export default function ImgfxHoverSwap() {
  const [live, setLive] = useState<string | null>(null);

  return (
    <section className="relative w-full overflow-hidden bg-neutral-50 px-6 py-20 text-neutral-900 sm:px-8 dark:bg-neutral-950 dark:text-neutral-50">
      <style>{`
        @keyframes hoverswap_glint {
          0% { transform: translateX(-120%) skewX(-18deg); opacity: 0; }
          40% { opacity: .55; }
          100% { transform: translateX(220%) skewX(-18deg); opacity: 0; }
        }
        .hoverswap_card:hover .hoverswap_glint { animation: hoverswap_glint 900ms ease-out; }
        @media (prefers-reduced-motion: reduce) {
          .hoverswap_base, .hoverswap_swap { transition: none !important; }
          .hoverswap_card:hover .hoverswap_glint { animation: none; }
        }
      `}</style>

      <div className="mx-auto max-w-6xl">
        <div className="mb-10 flex flex-col gap-3">
          <span className="inline-flex w-fit items-center gap-2 rounded-full border border-indigo-200 bg-indigo-50 px-3 py-1 text-xs font-medium uppercase tracking-widest text-indigo-700 dark:border-indigo-400/20 dark:bg-indigo-400/10 dark:text-indigo-300">
            <span className="h-1.5 w-1.5 rounded-full bg-indigo-500 dark:bg-indigo-400" />
            Image effect · Hover swap
          </span>
          <h2 className="text-3xl font-semibold tracking-tight sm:text-4xl">
            One card, two frames
          </h2>
          <p className="max-w-2xl text-base text-neutral-600 dark:text-neutral-400">
            Rest on any photo and it cross-fades to an alternate shot — a
            second angle, a later hour, a different expression. Move away and it
            settles back.
          </p>
        </div>

        <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
          {PAIRS.map((pair) => {
            const isLive = live === pair.base;
            return (
              <figure
                key={pair.base}
                className="hoverswap_card group relative flex flex-col gap-3"
                onMouseEnter={() => setLive(pair.base)}
                onMouseLeave={() => setLive((v) => (v === pair.base ? null : v))}
              >
                <div className="relative aspect-[4/5] w-full overflow-hidden rounded-2xl bg-neutral-200 shadow-sm ring-1 ring-black/5 dark:bg-neutral-800 dark:ring-white/10">
                  {/* eslint-disable-next-line @next/next/no-img-element */}
                  <img
                    src={pair.base}
                    alt={pair.baseAlt}
                    loading="lazy"
                    draggable={false}
                    className="hoverswap_base absolute inset-0 h-full w-full object-cover transition-[opacity,transform] duration-500 ease-out group-hover:scale-[1.03] group-hover:opacity-0"
                  />
                  {/* eslint-disable-next-line @next/next/no-img-element */}
                  <img
                    src={pair.swap}
                    alt={pair.swapAlt}
                    loading="lazy"
                    draggable={false}
                    className="hoverswap_swap absolute inset-0 h-full w-full scale-105 object-cover opacity-0 transition-[opacity,transform] duration-500 ease-out group-hover:scale-100 group-hover:opacity-100"
                  />

                  <span className="hoverswap_glint pointer-events-none absolute inset-y-0 -left-1/3 w-1/3 bg-gradient-to-r from-transparent via-white/60 to-transparent dark:via-white/25" />

                  <span className="absolute left-3 top-3 rounded-full bg-white/85 px-2.5 py-1 text-[11px] font-semibold text-neutral-800 backdrop-blur-sm dark:bg-neutral-900/80 dark:text-neutral-100">
                    {pair.label}
                  </span>

                  <span
                    className={`absolute bottom-3 right-3 rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors duration-300 ${
                      isLive
                        ? "bg-indigo-500 text-white"
                        : "bg-black/45 text-white/90"
                    }`}
                  >
                    {isLive ? "Frame 02" : "Frame 01"}
                  </span>
                </div>

                <figcaption className="flex items-center gap-2 px-1 text-sm text-neutral-500 dark:text-neutral-400">
                  <svg
                    viewBox="0 0 24 24"
                    className="h-4 w-4 flex-none text-indigo-500 dark:text-indigo-400"
                    fill="none"
                    stroke="currentColor"
                    strokeWidth={1.8}
                    strokeLinecap="round"
                    strokeLinejoin="round"
                    aria-hidden="true"
                  >
                    <path d="M4 9h13l-3-3" />
                    <path d="M20 15H7l3 3" />
                  </svg>
                  {pair.hint}
                </figcaption>
              </figure>
            );
          })}
        </div>
      </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 →