{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"perspective-card-stack","type":"registry:component","title":"Perspective Card Stack","description":"A pile of photos that swings open into a 3D fan.","author":"DiMaac (HankTheTank) <https://dimaac.com>","dependencies":[],"files":[{"path":"registry/dimaac/perspective-card-stack/perspective-card-stack.tsx","type":"registry:component","target":"components/dimaac/perspective-card-stack.tsx","content":"\"use client\";\n\nimport { useEffect, useRef, useState, useSyncExternalStore, type PointerEvent } from \"react\";\n\n// A small deck of photos in perspective. At rest the cards sit in a tidy pile, each one peeking\n// out above the card in front. Hover the pile (or tap it, or focus it with the keyboard) and the\n// cards swing out sideways like pages of a book, turned toward you, and the card under the cursor\n// lifts. It is plain CSS transitions on a transform, so there is no animation library to load.\n\nexport type PerspectiveCard = {\n  /** The photo's address. */\n  image: string;\n  /** Describes the photo for screen readers. */\n  alt?: string;\n};\n\nexport type PerspectiveCardStackProps = {\n  /** The photos, front card first. Up to five are used; three look best. */\n  cards: PerspectiveCard[];\n  /** Width of each card, in pixels. On a narrow screen the cards shrink to fit. */\n  cardWidth?: number;\n  /** Height of each card, in pixels. */\n  cardHeight?: number;\n  /** How strong the 3D is, in pixels: the distance of the \"camera\". Smaller is more dramatic. */\n  perspective?: number;\n  /** How far the front card turns when the stack fans out, in degrees. The cards behind it turn less. */\n  tilt?: number;\n  /** How far the cards spread sideways when fanned. 1 is the normal spread. */\n  spread?: number;\n  /** How far the card under the cursor rises, in pixels. */\n  lift?: number;\n  /** How long the fan takes, in seconds. */\n  duration?: number;\n  /** Which side the cards fan out toward. */\n  direction?: \"left\" | \"right\";\n  /** What opens the stack: the cursor resting on it, or a click or tap. Touch screens always use taps. */\n  trigger?: \"hover\" | \"click\";\n  /** Open and close the stack by itself while nobody is using it. */\n  autoplay?: boolean;\n  /** Extra classes for the outer box. Give it a size (it fills it). */\n  className?: string;\n  /** Extra classes for each card, e.g. a radius or a shadow. */\n  cardClassName?: string;\n};\n\nfunction subscribeToMotion(onChange: () => void) {\n  const query = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n}\nconst prefersReducedMotion = () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\nconst MAX_CARDS = 5;\n\n// Where card number `index` sits. `t` runs from 0 (front card) to 1 (last card), so the whole\n// pile always covers the same area, however many cards there are. For three cards these are\n// the numbers of the original design. Percentages are shares of the card's own size.\nfunction cardTransform(index: number, count: number, open: boolean, tilt: number, spread: number, lift: number, direction: 1 | -1) {\n  const t = count > 1 ? index / (count - 1) : 0;\n  if (!open) {\n    // A pile: each card a step higher and a step smaller than the one in front\n    return `translate(-50%, ${-35 - 40 * t}%) scale(${1 - 0.2 * t})`;\n  }\n  // A fan: the front card swings out furthest and is the biggest, the rest follow, smaller and\n  // turned less, so the row seems to run away from you\n  // (measured from the middle of the box; the mirror image for a fan to the right)\n  const x = -50 + (-22 + 40 * t) * spread * direction;\n  const turn = -(tilt - tilt * 0.5 * t) * direction;\n  return `translate(${x}%, -50%) translateY(${-lift}px) scale(${0.8 - 0.2 * t}) rotateY(${turn}deg)`;\n}\n\nexport function PerspectiveCardStack({\n  cards,\n  cardWidth = 360,\n  cardHeight = 280,\n  perspective = 500,\n  tilt = 48,\n  spread = 1,\n  lift = 40,\n  duration = 0.4,\n  direction = \"left\",\n  trigger = \"hover\",\n  autoplay = false,\n  className = \"\",\n  cardClassName = \"rounded-[10px] shadow-[0_26px_50px_-20px_rgba(0,0,0,0.5)]\",\n}: PerspectiveCardStackProps) {\n  const reducedMotion = useSyncExternalStore(subscribeToMotion, prefersReducedMotion, () => false);\n  const [hovering, setHovering] = useState(false);\n  const [focused, setFocused] = useState(false);\n  const [pinned, setPinned] = useState(false);\n  const [auto, setAuto] = useState(false);\n  const [liftedCard, setLiftedCard] = useState<number | null>(null);\n  // \"mouse\", \"touch\" or \"pen\": a mouse can hover, a finger can only tap\n  const pointerType = useRef(\"mouse\");\n\n  const shown = cards.slice(0, MAX_CARDS);\n  const inUse = hovering || focused || pinned;\n\n  // Opened by the cursor or the keyboard in hover mode, by a click in click mode, by the\n  // autoplay loop when nobody is around. With reduced motion there is nothing to open: the fan\n  // is simply how it looks.\n  const open = reducedMotion || (trigger === \"hover\" ? inUse : pinned) || (autoplay && !inUse && auto);\n\n  // Autoplay is just a timer flipping a flag, and it gives way as soon as someone uses the stack\n  useEffect(() => {\n    if (!autoplay || reducedMotion) return;\n    const timer = window.setInterval(() => setAuto((on) => !on), 2400);\n    return () => window.clearInterval(timer);\n  }, [autoplay, reducedMotion]);\n\n  const sign = direction === \"left\" ? 1 : -1;\n  const seconds = reducedMotion ? 0 : duration;\n\n  const onHoverChange = (event: PointerEvent, on: boolean) => {\n    pointerType.current = event.pointerType;\n    // Touch fires enter and leave around every tap; only a real mouse should open on hover\n    if (event.pointerType !== \"mouse\" || trigger !== \"hover\") return;\n    setHovering(on);\n    if (!on) setLiftedCard(null);\n  };\n\n  const onCardClick = () => {\n    // A mouse in hover mode already sees the fan; everything else toggles it\n    if (trigger === \"click\" || pointerType.current !== \"mouse\") setPinned((on) => !on);\n  };\n\n  return (\n    <div\n      className={`relative w-full [container-type:inline-size] ${className}`}\n      style={{ perspective, minHeight: cardHeight * 1.4 }}\n      onPointerEnter={(event) => onHoverChange(event, true)}\n      onPointerLeave={(event) => onHoverChange(event, false)}\n      onKeyDown={(event) => {\n        if (event.key === \"Escape\") setPinned(false);\n      }}\n    >\n      {shown.map((card, index) => (\n        <button\n          key={index}\n          type=\"button\"\n          // One stop for the keyboard is enough: the front card\n          tabIndex={index === 0 ? 0 : -1}\n          aria-label={card.alt ?? `Card ${index + 1}`}\n          aria-expanded={open}\n          onClick={onCardClick}\n          onFocus={(event) => {\n            if (event.currentTarget.matches(\":focus-visible\")) setFocused(true);\n          }}\n          onBlur={() => setFocused(false)}\n          onPointerEnter={(event) => {\n            if (event.pointerType === \"mouse\") setLiftedCard(index);\n          }}\n          onPointerLeave={() => setLiftedCard((current) => (current === index ? null : current))}\n          className=\"group absolute top-1/2 left-1/2 m-0 block cursor-pointer border-0 bg-transparent p-0 [backface-visibility:hidden] will-change-transform focus:outline-none\"\n          style={{\n            width: `min(${cardWidth}px, 74cqw)`,\n            aspectRatio: `${cardWidth} / ${cardHeight}`,\n            zIndex: shown.length - index,\n            transform: cardTransform(index, shown.length, open, tilt, spread, liftedCard === index && !reducedMotion ? lift : 0, sign),\n            transition: `transform ${seconds}s cubic-bezier(0.22, 1, 0.36, 1), filter ${seconds}s ease`,\n            // The cards at the back sit in a little shade, so the pile has depth\n            filter: `brightness(${1 - (shown.length > 1 ? index / (shown.length - 1) : 0) * (open ? 0.1 : 0.18)})`,\n          }}\n        >\n          {/* The look of the card (radius, shadow) lives on this inner box, so the button itself stays a plain rectangle */}\n          <span className={`pointer-events-none block h-full w-full overflow-hidden group-focus-visible:outline-2 group-focus-visible:outline-offset-4 group-focus-visible:outline-current ${cardClassName}`}>\n            {/* The button already carries the name, so the picture inside stays quiet */}\n            <img src={card.image} alt=\"\" draggable={false} className=\"block h-full w-full object-cover select-none\" />\n          </span>\n          {/* When the card rises it leaves a gap under the cursor. This invisible strip fills it, so the card doesn't flicker up and down at its edge */}\n          <span aria-hidden className=\"absolute inset-x-0 top-full\" style={{ height: lift }} />\n        </button>\n      ))}\n    </div>\n  );\n}\n"}],"categories":["cards","3d","perspective","hover","css","stack"],"docs":"import { PerspectiveCardStack } from \"@/components/dimaac/perspective-card-stack\". Give its parent a size: it fills it. Docs, playground and props: https://ui.dimaac.com/components/perspective-card-stack","meta":{"tier":"free","preview":"https://ui.dimaac.com/components/perspective-card-stack","credits":[{"by":"Binks AI","url":"https://www.lummi.ai/photo/serene-minimalist-scene-with-palm-trees-pba-c","what":"Photo","source":"Lummi","licence":"Lummi License"},{"by":"Siren Media","url":"https://www.lummi.ai/photo/modern-interior-with-red-wall-6k-hl","what":"Photo","source":"Lummi","licence":"Lummi License"},{"by":"Ricardo Matos","url":"https://www.lummi.ai/photo/minimalist-urban-design-abiri","what":"Photo","source":"Lummi","licence":"Lummi License"}]}}