Web InnoventixFreeCode

Image Card Hover Effect

Original · free

An image card with a title overlay that reveals on hover.

byWeb InnoventixReact + Tailwind
hoverimagecard
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/hover-image-card.json
hover-image-card.tsx
"use client";

import { useId } from "react";

type Card = {
  img: string;
  eyebrow: string;
  title: string;
  blurb: string;
  href: string;
};

const CARDS: Card[] = [
  {
    img: "/img/gallery/g07.webp",
    eyebrow: "Field Notes",
    title: "Desert Highway at Dusk",
    blurb: "A 40-minute detour that turned into the best shot of the trip.",
    href: "#desert-highway",
  },
  {
    img: "/img/gallery/g19.webp",
    eyebrow: "Studio",
    title: "Slow Coffee, Warm Light",
    blurb: "Behind the scenes of the morning still-life series.",
    href: "#slow-coffee",
  },
  {
    img: "/img/gallery/g23.webp",
    eyebrow: "Travel",
    title: "Above the Cloud Line",
    blurb: "Six hours up, one clear window, thirty seconds of gold.",
    href: "#cloud-line",
  },
];

export default function HoverImageCard() {
  const uid = useId();
  const prefix = `hic-${uid.replace(/[^a-zA-Z0-9]/g, "")}`;

  return (
    <section className="relative w-full bg-slate-50 px-4 py-16 sm:px-6 sm:py-20 dark:bg-slate-950">
      <style>{`
        @keyframes ${prefix}-rise {
          from { opacity: 0; transform: translateY(10px); }
          to { opacity: 1; transform: translateY(0); }
        }
        .${prefix}-card:hover .${prefix}-img,
        .${prefix}-card:focus-within .${prefix}-img {
          transform: scale(1.06);
        }
        .${prefix}-card:hover .${prefix}-veil,
        .${prefix}-card:focus-within .${prefix}-veil {
          opacity: 1;
        }
        .${prefix}-card:hover .${prefix}-reveal,
        .${prefix}-card:focus-within .${prefix}-reveal {
          opacity: 1;
          transform: translateY(0);
        }
        @media (prefers-reduced-motion: reduce) {
          .${prefix}-img,
          .${prefix}-veil,
          .${prefix}-reveal {
            transition: none !important;
          }
          .${prefix}-card:hover .${prefix}-img,
          .${prefix}-card:focus-within .${prefix}-img {
            transform: none;
          }
        }
      `}</style>

      <div className="mx-auto max-w-6xl">
        <div className="mx-auto mb-10 max-w-2xl text-center sm:mb-14">
          <h2 className="text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
            Selected Work
          </h2>
          <p className="mt-3 text-base text-slate-600 dark:text-slate-400">
            Hover or focus a card to reveal the story behind the frame.
          </p>
        </div>

        <ul className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
          {CARDS.map((card, i) => (
            <li key={card.href}>
              <a
                href={card.href}
                aria-label={`${card.title} — ${card.eyebrow}`}
                className={`${prefix}-card group relative block aspect-[4/5] overflow-hidden rounded-2xl bg-slate-200 shadow-sm ring-1 ring-slate-900/5 transition-shadow duration-300 hover:shadow-xl focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:bg-slate-800 dark:ring-white/10 dark:focus-visible:ring-offset-slate-950`}
              >
                {/* eslint-disable-next-line @next/next/no-img-element */}
                <img
                  src={card.img}
                  alt={card.title}
                  loading="lazy"
                  draggable={false}
                  className={`${prefix}-img absolute inset-0 h-full w-full object-cover transition-transform duration-[600ms] ease-out will-change-transform`}
                />

                <div
                  className={`${prefix}-veil pointer-events-none absolute inset-0 bg-gradient-to-t from-slate-950/85 via-slate-950/30 to-transparent opacity-70 transition-opacity duration-300`}
                  aria-hidden="true"
                />

                <div className="absolute inset-x-0 top-0 flex items-start justify-between p-4">
                  <span className="inline-flex items-center rounded-full bg-white/85 px-3 py-1 text-xs font-medium tracking-wide text-slate-800 backdrop-blur-sm dark:bg-slate-900/70 dark:text-slate-100">
                    {card.eyebrow}
                  </span>
                  <span
                    className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-white/85 text-slate-800 opacity-0 transition-opacity duration-300 group-hover:opacity-100 group-focus-within:opacity-100 dark:bg-slate-900/70 dark:text-slate-100"
                    aria-hidden="true"
                  >
                    <svg
                      viewBox="0 0 20 20"
                      fill="none"
                      className="h-4 w-4"
                      stroke="currentColor"
                      strokeWidth="1.75"
                      strokeLinecap="round"
                      strokeLinejoin="round"
                    >
                      <path d="M5 15 15 5" />
                      <path d="M7 5h8v8" />
                    </svg>
                  </span>
                </div>

                <div className="absolute inset-x-0 bottom-0 p-5">
                  <h3 className="text-lg font-semibold text-white drop-shadow-sm">
                    {card.title}
                  </h3>
                  <p
                    className={`${prefix}-reveal mt-1.5 translate-y-2 text-sm text-slate-200 opacity-0 transition-all duration-300 ease-out`}
                    style={{ transitionDelay: "60ms" }}
                  >
                    {card.blurb}
                  </p>
                  <span
                    className={`${prefix}-reveal mt-3 inline-flex items-center gap-1.5 text-sm font-medium text-indigo-300 opacity-0`}
                    style={{ transitionDelay: "120ms" }}
                  >
                    View story
                    <svg
                      viewBox="0 0 20 20"
                      fill="none"
                      className="h-3.5 w-3.5"
                      stroke="currentColor"
                      strokeWidth="2"
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      aria-hidden="true"
                    >
                      <path d="M4 10h11" />
                      <path d="M11 5l5 5-5 5" />
                    </svg>
                  </span>
                </div>

                {i === 0 && (
                  <span
                    className="absolute left-4 top-16 inline-flex items-center rounded-full bg-emerald-500/90 px-2.5 py-0.5 text-xs font-medium text-white"
                    style={{
                      animation: `${prefix}-rise 0.5s ease-out both`,
                    }}
                  >
                    Featured
                  </span>
                )}
              </a>
            </li>
          ))}
        </ul>
      </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 →