Web InnoventixFreeCode

Stacked Card

Original · free

A stack of cards that fans out on hover.

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

import { useCallback, useId, useMemo, useState } from "react";
import { motion, useReducedMotion } from "motion/react";

type Accent = "indigo" | "violet" | "emerald" | "rose" | "amber" | "sky";

type Shot = {
  id: string;
  title: string;
  place: string;
  author: string;
  img: string;
  accent: Accent;
};

const ACCENTS: Record<
  Accent,
  { dot: string; chip: string; ring: string }
> = {
  indigo: {
    dot: "bg-indigo-500",
    chip: "bg-indigo-50 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300",
    ring: "focus-visible:ring-indigo-500",
  },
  violet: {
    dot: "bg-violet-500",
    chip: "bg-violet-50 text-violet-700 dark:bg-violet-500/15 dark:text-violet-300",
    ring: "focus-visible:ring-violet-500",
  },
  emerald: {
    dot: "bg-emerald-500",
    chip: "bg-emerald-50 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300",
    ring: "focus-visible:ring-emerald-500",
  },
  rose: {
    dot: "bg-rose-500",
    chip: "bg-rose-50 text-rose-700 dark:bg-rose-500/15 dark:text-rose-300",
    ring: "focus-visible:ring-rose-500",
  },
  amber: {
    dot: "bg-amber-500",
    chip: "bg-amber-50 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300",
    ring: "focus-visible:ring-amber-500",
  },
  sky: {
    dot: "bg-sky-500",
    chip: "bg-sky-50 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300",
    ring: "focus-visible:ring-sky-500",
  },
};

const DECK_A: Shot[] = [
  {
    id: "kyoto",
    title: "Kyoto at Dawn",
    place: "Fushimi Inari, JP",
    author: "Aiko Tanaka",
    img: "/img/gallery/g01.webp",
    accent: "rose",
  },
  {
    id: "aurora",
    title: "Reykjavík Aurora",
    place: "Þingvellir, IS",
    author: "Erik Lindqvist",
    img: "/img/gallery/g02.webp",
    accent: "emerald",
  },
  {
    id: "souk",
    title: "Marrakech Souk",
    place: "Medina, MA",
    author: "Yara Benali",
    img: "/img/gallery/g03.webp",
    accent: "amber",
  },
  {
    id: "patagonia",
    title: "Patagonia Ridge",
    place: "Torres del Paine, CL",
    author: "Diego Alarcón",
    img: "/img/gallery/g04.webp",
    accent: "sky",
  },
  {
    id: "lofoten",
    title: "Lofoten Blue Hour",
    place: "Reine, NO",
    author: "Ingrid Solberg",
    img: "/img/gallery/g05.webp",
    accent: "indigo",
  },
];

const DECK_B: Shot[] = [
  {
    id: "dunes",
    title: "Erg Chebbi Dunes",
    place: "Sahara, MA",
    author: "Nadia El Fassi",
    img: "/img/gallery/g06.webp",
    accent: "amber",
  },
  {
    id: "harbor",
    title: "Nyhavn Harbor",
    place: "Copenhagen, DK",
    author: "Lucas Meyer",
    img: "/img/gallery/g07.webp",
    accent: "sky",
  },
  {
    id: "terraces",
    title: "Banaue Terraces",
    place: "Ifugao, PH",
    author: "Mateo Reyes",
    img: "/img/gallery/g08.webp",
    accent: "emerald",
  },
  {
    id: "canyon",
    title: "Antelope Canyon",
    place: "Page, AZ",
    author: "Sara Whitcomb",
    img: "/img/gallery/g09.webp",
    accent: "violet",
  },
];

type SizeKey = "lg" | "sm";

const SIZES: Record<SizeKey, { w: number; h: number; fan: number; pad: number }> = {
  lg: { w: 246, h: 322, fan: 152, pad: 104 },
  sm: { w: 172, h: 226, fan: 108, pad: 88 },
};

type Slot = { x: number; y: number; rotate: number; scale: number; zIndex: number };

function slotStyle(
  slot: number,
  count: number,
  expanded: boolean,
  fan: number,
): Slot {
  if (!expanded) {
    return {
      x: slot * 4,
      y: slot * -12,
      rotate: slot === 0 ? 0 : slot % 2 === 0 ? -slot * 1.8 : slot * 1.8,
      scale: 1 - slot * 0.05,
      zIndex: count - slot,
    };
  }
  const mid = (count - 1) / 2;
  const offset = slot - mid;
  return {
    x: offset * fan,
    y: Math.abs(offset) * 16 - 6,
    rotate: offset * 7,
    scale: 1,
    zIndex: count - Math.abs(Math.round(offset)),
  };
}

function HeartIcon({ filled }: { filled: boolean }) {
  return (
    <svg
      viewBox="0 0 24 24"
      className="h-4 w-4"
      fill={filled ? "currentColor" : "none"}
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.6l-1-1a5.5 5.5 0 0 0-7.8 7.8l1 1L12 21l7.8-7.6 1-1a5.5 5.5 0 0 0 0-7.8Z" />
    </svg>
  );
}

function ShuffleIcon() {
  return (
    <svg
      viewBox="0 0 24 24"
      className="h-4 w-4"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M16 3h5v5" />
      <path d="M4 20 21 3" />
      <path d="M21 16v5h-5" />
      <path d="m15 15 6 6" />
      <path d="M4 4l5 5" />
    </svg>
  );
}

function LayersIcon({ open }: { open: boolean }) {
  return (
    <svg
      viewBox="0 0 24 24"
      className="h-4 w-4"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      style={{ transform: open ? "rotate(180deg)" : "none", transition: "transform .2s" }}
    >
      <path d="m12 2 9 5-9 5-9-5 9-5Z" />
      <path d="m3 12 9 5 9-5" />
      <path d="m3 17 9 5 9-5" />
    </svg>
  );
}

function CardStack({
  shots,
  size,
  label,
  hint,
}: {
  shots: Shot[];
  size: SizeKey;
  label: string;
  hint: string;
}) {
  const reduce = useReducedMotion();
  const dims = SIZES[size];
  const baseId = useId();

  const byId = useMemo(() => {
    const map: Record<string, Shot> = {};
    for (const s of shots) map[s.id] = s;
    return map;
  }, [shots]);

  const [order, setOrder] = useState<string[]>(() => shots.map((s) => s.id));
  const [hovered, setHovered] = useState(false);
  const [pinned, setPinned] = useState(false);
  const [focused, setFocused] = useState(false);
  const [liked, setLiked] = useState<Record<string, boolean>>({});

  const expanded = hovered || pinned || focused;

  const bringToFront = useCallback((id: string) => {
    setOrder((prev) => [id, ...prev.filter((x) => x !== id)]);
  }, []);

  const shuffle = useCallback(() => {
    setOrder((prev) => (prev.length < 2 ? prev : [...prev.slice(1), prev[0]]));
  }, []);

  const toggleLike = useCallback((id: string) => {
    setLiked((prev) => ({ ...prev, [id]: !prev[id] }));
  }, []);

  const top = byId[order[0]];
  const topAccent = ACCENTS[top.accent];

  return (
    <div className="flex flex-col items-center">
      <div
        className="relative flex w-full items-end justify-center"
        style={{ height: dims.h + dims.pad }}
        onMouseEnter={() => setHovered(true)}
        onMouseLeave={() => setHovered(false)}
        onFocusCapture={() => setFocused(true)}
        onBlurCapture={(e) => {
          if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
            setFocused(false);
          }
        }}
      >
        {order.map((id, slot) => {
          const shot = byId[id];
          const st = slotStyle(slot, order.length, expanded, dims.fan);
          const accent = ACCENTS[shot.accent];
          const isLiked = !!liked[id];
          return (
            <motion.div
              key={id}
              className="absolute origin-bottom cstk-card"
              style={{
                width: dims.w,
                height: dims.h,
                left: "50%",
                marginLeft: -dims.w / 2,
                bottom: dims.pad - 40,
                zIndex: st.zIndex,
              }}
              initial={false}
              animate={{ x: st.x, y: st.y, rotate: st.rotate, scale: st.scale }}
              transition={
                reduce
                  ? { duration: 0 }
                  : {
                      type: "spring",
                      stiffness: 240,
                      damping: 24,
                      delay: expanded ? slot * 0.03 : 0,
                    }
              }
            >
              <div className="relative h-full w-full overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-[0_10px_30px_-12px_rgba(24,24,27,0.45)] ring-1 ring-black/5 dark:border-zinc-800 dark:bg-zinc-900 dark:shadow-[0_16px_40px_-14px_rgba(0,0,0,0.8)] dark:ring-white/5">
                {/* eslint-disable-next-line @next/next/no-img-element */}
                <img
                  src={shot.img}
                  alt={`${shot.title} — photograph by ${shot.author}`}
                  loading="lazy"
                  draggable={false}
                  className="absolute inset-0 h-full w-full select-none object-cover"
                />
                <div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/80 via-black/25 to-transparent" />

                <button
                  type="button"
                  onClick={() => bringToFront(id)}
                  className={`absolute inset-0 z-10 flex flex-col justify-end p-4 text-left outline-none transition-[box-shadow] focus-visible:ring-2 focus-visible:ring-inset ${accent.ring}`}
                  aria-label={`Bring “${shot.title}” to the front of the deck`}
                >
                  <span className="pointer-events-none">
                    <span
                      className={`mb-2 inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium ${accent.chip}`}
                    >
                      <span className={`h-1.5 w-1.5 rounded-full ${accent.dot}`} />
                      {shot.place}
                    </span>
                    <span
                      className={`block font-semibold leading-tight text-white ${
                        size === "lg" ? "text-lg" : "text-sm"
                      }`}
                    >
                      {shot.title}
                    </span>
                    <span className="mt-0.5 block text-xs text-zinc-300">
                      {shot.author}
                    </span>
                  </span>
                </button>

                <button
                  type="button"
                  onClick={() => toggleLike(id)}
                  aria-pressed={isLiked}
                  aria-label={
                    isLiked ? `Unlike ${shot.title}` : `Like ${shot.title}`
                  }
                  className={`absolute right-3 top-3 z-20 grid h-9 w-9 place-items-center rounded-full border backdrop-blur outline-none transition-all focus-visible:ring-2 focus-visible:ring-offset-0 focus-visible:ring-rose-400 active:scale-90 ${
                    isLiked
                      ? "border-rose-300/60 bg-rose-500/90 text-white dark:border-rose-400/50"
                      : "border-white/30 bg-black/25 text-white hover:bg-black/40"
                  }`}
                >
                  <HeartIcon filled={isLiked} />
                </button>
              </div>
            </motion.div>
          );
        })}

        <span
          aria-hidden="true"
          className={`pointer-events-none absolute top-2 left-1/2 -ml-1 h-2 w-2 rounded-full ${topAccent.dot} cstk-pulse ${
            expanded ? "opacity-0" : "opacity-100"
          } transition-opacity`}
        />
      </div>

      <div className="mt-2 flex w-full max-w-[20rem] items-center justify-between gap-3">
        <div className="min-w-0">
          <p className="truncate text-sm font-semibold text-zinc-900 dark:text-zinc-100">
            {label}
          </p>
          <p className="truncate text-xs text-zinc-500 dark:text-zinc-400">
            {expanded ? `Now showing “${top.title}”` : hint}
          </p>
        </div>
        <div className="flex shrink-0 items-center gap-2">
          <button
            type="button"
            onClick={shuffle}
            aria-label="Send the top photo to the back of the deck"
            className="grid h-9 w-9 place-items-center rounded-xl border border-zinc-200 bg-white text-zinc-600 outline-none transition-colors hover:bg-zinc-50 hover:text-zinc-900 focus-visible:ring-2 focus-visible:ring-indigo-500 active:scale-95 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:bg-zinc-800 dark:hover:text-white"
          >
            <ShuffleIcon />
          </button>
          <button
            type="button"
            onClick={() => setPinned((p) => !p)}
            aria-pressed={pinned}
            id={`${baseId}-toggle`}
            className={`inline-flex h-9 items-center gap-1.5 rounded-xl border px-3 text-xs font-semibold outline-none transition-colors focus-visible:ring-2 focus-visible:ring-indigo-500 active:scale-95 ${
              pinned
                ? "border-indigo-500 bg-indigo-500 text-white hover:bg-indigo-600 dark:border-indigo-400 dark:bg-indigo-500"
                : "border-zinc-200 bg-white text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:bg-zinc-800"
            }`}
          >
            <LayersIcon open={pinned} />
            {pinned ? "Collapse" : "Fan out"}
          </button>
        </div>
      </div>
    </div>
  );
}

export default function CardStacked() {
  return (
    <section className="relative w-full overflow-hidden bg-zinc-50 px-6 py-20 sm:py-28 dark:bg-zinc-950">
      <style>{`
        @keyframes cstk-pulse {
          0%, 100% { transform: scale(1); opacity: .9; }
          50% { transform: scale(1.9); opacity: .25; }
        }
        .cstk-pulse { animation: cstk-pulse 2.4s ease-in-out infinite; }
        .cstk-card { will-change: transform; }
        @media (prefers-reduced-motion: reduce) {
          .cstk-pulse { animation: none; }
        }
      `}</style>

      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 -z-0 opacity-[0.04] dark:opacity-[0.06]"
        style={{
          backgroundImage:
            "radial-gradient(currentColor 1px, transparent 1px)",
          backgroundSize: "22px 22px",
          color: "currentColor",
        }}
      />

      <div className="relative z-10 mx-auto max-w-5xl">
        <div className="mx-auto mb-16 max-w-2xl text-center">
          <span className="inline-flex items-center gap-2 rounded-full border border-zinc-200 bg-white px-3 py-1 text-xs font-medium text-zinc-600 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-300">
            <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
            Field Notebook · Vol. 07
          </span>
          <h2 className="mt-5 text-3xl font-bold tracking-tight text-zinc-900 sm:text-4xl dark:text-zinc-50">
            A deck that fans on hover
          </h2>
          <p className="mx-auto mt-3 max-w-xl text-pretty text-base text-zinc-600 dark:text-zinc-400">
            Hover or focus a stack to spread the cards. Pin it open, shuffle the
            top shot to the back, or tap any card to pull it forward.
          </p>
        </div>

        <div className="grid items-start gap-y-16 gap-x-8 sm:grid-cols-2">
          <CardStack
            shots={DECK_A}
            size="lg"
            label="Five continents, one lens"
            hint="Hover to fan · tap a card to raise it"
          />
          <CardStack
            shots={DECK_B}
            size="sm"
            label="Compact set"
            hint="Same deck, smaller footprint"
          />
        </div>
      </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 →