{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"card-fan","type":"registry:component","title":"Card Fan","description":"A hand of photo cards that fans out, lifts and turns over.","author":"DiMaac (HankTheTank) <https://dimaac.com>","dependencies":["gsap","@gsap/react"],"files":[{"path":"registry/dimaac/card-fan/card-fan.tsx","type":"registry:component","target":"components/dimaac/card-fan.tsx","content":"\"use client\";\n\nimport { useRef, useState } from \"react\";\nimport { gsap } from \"gsap\";\nimport { Flip } from \"gsap/Flip\";\nimport { Draggable } from \"gsap/Draggable\";\nimport { useGSAP } from \"@gsap/react\";\n\n// A hand of photo cards. At rest they sit in a tight stack; hover (or focus) opens them into an arc\n// around a pivot far below the stack, like a hand of playing cards. Pointing at a card lifts it.\n// Clicking a card sends it to the middle at full size with GSAP Flip and turns it over in 3D to\n// show its back. Any card can be dragged out of the hand and springs back when you let go.\n\nexport type CardFanCard = {\n  /** The photo on the front. */\n  src: string;\n  /** Describes the photo for screen readers. */\n  alt: string;\n  /** The title on the back. */\n  title: string;\n  /** One line of text on the back. */\n  text: string;\n};\n\nexport type CardFanProps = {\n  /** The cards, left to right. Four to eight look best. */\n  cards: CardFanCard[];\n  /** How wide the open fan is, from the first card to the last, in degrees. */\n  spread?: number;\n  /** How far below the cards the fan's pivot sits, in pixels. Bigger is a flatter arc. */\n  radius?: number;\n  /** How far a card rises when you point at it, in pixels. */\n  lift?: number;\n  /** How long the trip to the middle and the turn take, in seconds. */\n  flipDuration?: number;\n  /** Width of a card in the hand, in pixels (smaller on narrow screens). */\n  cardWidth?: number;\n  /** Extra classes for the outer box. Give it a size (it fills it). */\n  className?: string;\n  /** Extra classes for both faces of every card, e.g. a radius or a shadow. */\n  cardClassName?: string;\n  /** Classes for the layer behind an open card, e.g. a tint or a blur. */\n  backdropClassName?: string;\n};\n\n// How open the hand is at rest, as a share of the full spread\nconst REST = 0.16;\n// Cards are 4:5, like the photos\nconst ASPECT = 1.25;\n\nexport function CardFan({\n  cards,\n  spread = 64,\n  radius = 560,\n  lift = 36,\n  flipDuration = 0.9,\n  cardWidth = 210,\n  className = \"\",\n  cardClassName = \"rounded-[14px] shadow-[0_24px_48px_-20px_rgba(0,0,0,0.45),0_2px_6px_-2px_rgba(0,0,0,0.2)]\",\n  backdropClassName = \"bg-black/25\",\n}: CardFanProps) {\n  const boxRef = useRef<HTMLDivElement>(null);\n  const backdropRef = useRef<HTMLDivElement>(null);\n  const cardRefs = useRef<(HTMLButtonElement | null)[]>([]);\n  // True once the deal-in has played, so a prop change re-lays the hand instead of dealing again\n  const dealt = useRef(false);\n  // Which card shows its back (drives the blurred back photo and what screen readers hear)\n  const [turned, setTurned] = useState(-1);\n  // Roving tab stop: one card is in the tab order, arrows move between them\n  const [tabStop, setTabStop] = useState(0);\n\n  useGSAP(\n    (_context, contextSafe) => {\n      const box = boxRef.current;\n      const backdrop = backdropRef.current;\n      const shells = cardRefs.current.slice(0, cards.length).filter((card): card is HTMLButtonElement => card !== null);\n      if (!box || !backdrop || shells.length === 0 || !contextSafe) return;\n      gsap.registerPlugin(Flip, Draggable);\n\n      const lifters = shells.map((shell) => shell.querySelector<HTMLElement>(\"[data-fan-lift]\")!);\n      const flippers = shells.map((shell) => shell.querySelector<HTMLElement>(\"[data-fan-flip]\")!);\n      const backs = shells.map((shell) => shell.querySelector<HTMLElement>(\"[data-fan-back]\")!);\n      const count = shells.length;\n      const middle = (count - 1) / 2;\n      const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n      const touch = window.matchMedia(\"(hover: none)\").matches;\n      const doc = box.ownerDocument;\n\n      // Everything the layout needs, measured from the box\n      const size = { boxW: 0, boxH: 0, cardW: 0, cardH: 0, pivot: radius, centreY: 0, bigW: 0, spread };\n      const measure = () => {\n        size.boxW = box.clientWidth;\n        size.boxH = box.clientHeight;\n        // Phones get wider cards (a share of a small box), so the inner cards stay big enough to tap\n        const narrow = size.boxW < 600;\n        size.cardW = Math.min(cardWidth, size.boxW * (narrow ? 0.4 : 0.34), (size.boxH * 0.46) / ASPECT);\n        size.cardH = size.cardW * ASPECT;\n        // On a narrow box the pivot gets pulled in hard, so open the fan wider to keep the cards apart\n        size.spread = narrow ? Math.min(spread * 1.35, 120) : spread;\n        // Pull the pivot in when the open fan would spill past the sides of the box\n        const half = (size.spread / 2) * (Math.PI / 180);\n        // The end card leans out a little further when a neighbour is pointed at (see seat())\n        const edge = half + Math.min(4, (size.spread / count) * 0.4) * (Math.PI / 180);\n        // How far the end card's outer corner reaches past its centre once it is rotated\n        const reach = (size.cardW / 2) * Math.cos(edge) + (size.cardH / 2) * Math.sin(edge);\n        const room = size.boxW / 2 - 16 - reach;\n        size.pivot = edge > 0 && Math.sin(edge) * radius > room ? Math.max(room / Math.sin(edge), 0) : radius;\n        // Centre the whole open hand (raised middle card down to the dipped end corners), so big\n        // cards on a short box don't sink into whatever sits below the hand\n        const above = size.cardH / 2 + lift;\n        const below = (1 - Math.cos(half)) * size.pivot + (size.cardW / 2) * Math.sin(half) + (size.cardH / 2) * Math.cos(half);\n        size.centreY = size.boxH * 0.5 + (above - below) / 2;\n        size.bigW = Math.min(440, size.boxW - 48, (size.boxH - 72) / ASPECT);\n      };\n\n      // The state of the hand. Plain variables: they change on every pointer move and React\n      // doesn't need to hear about it.\n      let fanned = reduced;\n      let hovered = -1;\n      let open = -1;\n      let dragging = -1;\n      let ready = false;\n      // A prop change rebuilds the context while the pointer may be resting over the hand\n      let pointerInside = box.matches(\":hover\");\n      // A prop change rebuilds the context while a card may still have focus\n      let focusInside = box.contains(doc.activeElement);\n      let visible = true;\n      let justDragged = false;\n      // Cards in the middle of a Flip are left alone by the layout\n      const flying = new Set<number>();\n\n      // Where card i sits: on a circle around a pivot below the hand, rotated to face out from it\n      const seat = (i: number, amount: number, around: number) => {\n        const t = count > 1 ? (i - middle) / (count - 1) : 0;\n        let degrees = t * size.spread * amount;\n        // Neighbours lean away from the card you point at, so it has room to rise\n        if (around >= 0 && around !== i) degrees += (i < around ? -1 : 1) * Math.min(4, (size.spread / count) * 0.4) * amount;\n        const angle = degrees * (Math.PI / 180);\n        // x and y place the card's top-left corner (Flip works in plain pixels, so no xPercent)\n        return {\n          x: size.boxW / 2 + Math.sin(angle) * size.pivot - size.cardW / 2,\n          y: size.centreY + (1 - Math.cos(angle)) * size.pivot - size.cardH / 2,\n          rotation: degrees,\n        };\n      };\n\n      // Where an open card goes: big, in the middle of the box\n      const openSpot = () => ({\n        x: (size.boxW - size.bigW) / 2,\n        y: (size.boxH - size.bigW * ASPECT) / 2,\n        width: size.bigW,\n        height: size.bigW * ASPECT,\n      });\n\n      // contextSafe: layout also runs from tween and observer callbacks, and every tween it makes\n      // must belong to this context so a prop change reverts it\n      const layout = contextSafe((duration = 0.8) => {\n        if (!ready) return;\n        const amount = fanned ? 1 : REST;\n        shells.forEach((shell, i) => {\n          if (i === open || i === dragging || flying.has(i)) return;\n          // The lifted card comes to the front so its neighbours don't cover it\n          gsap.set(shell, { zIndex: i === hovered && open < 0 ? count + 1 : i + 1 });\n          gsap.to(shell, {\n            ...seat(i, amount, fanned ? hovered : -1),\n            opacity: open >= 0 ? 0.35 : 1,\n            duration: reduced ? 0 : duration,\n            ease: \"power3.out\",\n            overwrite: \"auto\",\n          });\n          const rising = i === hovered && open < 0;\n          gsap.to(lifters[i]!, {\n            y: rising ? -lift : open >= 0 ? 28 : 0,\n            scale: rising ? 1.03 : 1,\n            duration: reduced ? 0.15 : 0.55,\n            ease: rising ? \"back.out(2)\" : \"power3.out\",\n            overwrite: \"auto\",\n          });\n        });\n      });\n\n      const setFanned = (next: boolean) => {\n        if (reduced) next = true;\n        if (next === fanned) return;\n        fanned = next;\n        layout();\n      };\n\n      measure();\n      gsap.set(shells, { width: size.cardW, height: size.cardH, zIndex: (i: number) => i + 1, force3D: true });\n      gsap.set(flippers, { transformPerspective: 1400, rotationY: 0 });\n      // Reduced motion: the back sits flat on top of the front and fades in instead of turning round\n      gsap.set(backs, reduced ? { rotationY: 0, opacity: 0 } : { rotationY: 180, opacity: 1 });\n      gsap.set(backdrop, { autoAlpha: 0 });\n\n      if (touch && !reduced) fanned = visible;\n\n      if (reduced || dealt.current) {\n        // Already dealt (or no motion): put the hand straight down\n        gsap.set(shells, { x: (i: number) => seat(i, fanned ? 1 : REST, -1).x, y: (i: number) => seat(i, fanned ? 1 : REST, -1).y, rotation: (i: number) => seat(i, fanned ? 1 : REST, -1).rotation, opacity: 1 });\n        ready = true;\n        if (!reduced) {\n          // A prop just changed: open the hand and raise the middle card for a moment so the change shows\n          fanned = true;\n          hovered = Math.round(middle);\n          layout(0.7);\n          gsap.delayedCall(1.6, () => {\n            // Keep the card under a resting pointer raised, else the focused one\n            const under = shells.findIndex((shell) => shell.matches(\":hover\"));\n            hovered = under >= 0 && !touch ? under : shells.indexOf(doc.activeElement as HTMLButtonElement);\n            pointerInside = pointerInside || box.matches(\":hover\");\n            setFanned(pointerInside || focusInside || (touch && visible));\n            layout();\n          });\n        }\n      } else {\n        // The deal: cards rise from below the box one after another and settle into the stack\n        gsap.set(shells, {\n          x: (i: number) => seat(i, REST, -1).x + (i - middle) * 30,\n          y: size.boxH + size.cardH,\n          rotation: (i: number) => (i - middle) * -14,\n          opacity: 1,\n        });\n        gsap.to(shells, {\n          x: (i: number) => seat(i, REST, -1).x,\n          y: (i: number) => seat(i, REST, -1).y,\n          rotation: (i: number) => seat(i, REST, -1).rotation,\n          duration: 1.3,\n          ease: \"expo.out\",\n          stagger: 0.07,\n          delay: 0.15,\n          onComplete: () => {\n            dealt.current = true;\n            ready = true;\n            layout();\n          },\n        });\n      }\n\n      const openCard = (i: number) => {\n        const shell = shells[i]!;\n        open = i;\n        hovered = -1;\n        flying.add(i);\n        setTurned(i);\n        draggables[i]?.disable();\n        Flip.killFlipsOf(shell);\n        // A hover tween or a drag spring may still be moving this card: stop it, or it keeps\n        // pulling the open card back toward its seat (rotated, off-centre) while the Flip runs\n        gsap.killTweensOf(shell, \"x,y,rotation,opacity,zIndex\");\n        // Flip: note where the card is now, jump it to its open spot, then animate from the old spot\n        const state = Flip.getState(shell);\n        gsap.set(shell, { ...openSpot(), rotation: 0, zIndex: 50, opacity: 1 });\n        gsap.to(backdrop, { autoAlpha: 1, duration: reduced ? 0.2 : flipDuration * 0.7, ease: \"power2.out\" });\n        if (reduced) {\n          gsap.fromTo(shell, { opacity: 0 }, { opacity: 1, duration: 0.25 });\n          gsap.set(lifters[i]!, { y: 0, scale: 1 });\n          gsap.to(backs[i]!, { opacity: 1, duration: 0.25 });\n          flying.delete(i);\n        } else {\n          Flip.from(state, {\n            duration: flipDuration,\n            ease: \"expo.inOut\",\n            scale: true,\n            onComplete: contextSafe(() => {\n              flying.delete(i);\n              // Backstop: if the box changed size during the flight, land on the new centre\n              if (open === i) gsap.set(shell, { ...openSpot(), rotation: 0, scaleX: 1, scaleY: 1 });\n            }),\n          });\n          gsap.to(lifters[i]!, { y: 0, scale: 1, duration: flipDuration * 0.6, ease: \"power2.inOut\", overwrite: \"auto\" });\n          // The turn starts a beat after the flight so the card leaves the hand face up\n          gsap.to(flippers[i]!, { rotationY: 180, duration: flipDuration * 0.9, delay: flipDuration * 0.1, ease: \"power3.inOut\", overwrite: \"auto\" });\n        }\n        layout(0.7);\n      };\n\n      const closeCard = () => {\n        const i = open;\n        if (i < 0) return;\n        const shell = shells[i]!;\n        open = -1;\n        flying.add(i);\n        Flip.killFlipsOf(shell);\n        gsap.killTweensOf(shell, \"x,y,rotation,opacity,zIndex\");\n        const state = Flip.getState(shell);\n        gsap.set(shell, { ...seat(i, fanned ? 1 : REST, -1), width: size.cardW, height: size.cardH });\n        gsap.to(backdrop, { autoAlpha: 0, duration: reduced ? 0.2 : flipDuration * 0.6, ease: \"power2.out\" });\n        const land = contextSafe(() => {\n          flying.delete(i);\n          gsap.set(shell, { zIndex: i + 1 });\n          draggables[i]?.enable();\n          // Focus came back to this card (Escape or Enter), so it stays raised like any focused card\n          if (doc.activeElement === shell && open < 0) hovered = i;\n          setTurned((current) => (current === i ? -1 : current));\n          layout(0.4);\n        });\n        if (reduced) {\n          gsap.set(backs[i]!, { opacity: 0 });\n          gsap.fromTo(shell, { opacity: 0 }, { opacity: 1, duration: 0.25 });\n          land();\n        } else {\n          Flip.from(state, { duration: flipDuration * 0.85, ease: \"expo.inOut\", scale: true, onComplete: land });\n          gsap.to(flippers[i]!, { rotationY: 0, duration: flipDuration * 0.75, ease: \"power3.inOut\", overwrite: \"auto\" });\n        }\n        layout(0.8);\n      };\n\n      // Dragging: a card follows the pointer, tilts with its speed and springs back to its seat.\n      // The work lives in contextSafe functions (Draggable calls its callbacks outside the context,\n      // and contextSafe replaces `this`, so the Draggable's numbers are passed in instead).\n      const dragStart = contextSafe((i: number) => {\n        dragging = i;\n        justDragged = true;\n        gsap.set(shells[i]!, { zIndex: 45 });\n        gsap.to(lifters[i]!, { y: 0, scale: 1.05, duration: 0.3, ease: \"power3.out\", overwrite: \"auto\" });\n      });\n      const dragMove = contextSafe((i: number, deltaX: number) => {\n        const lean = gsap.utils.clamp(-16, 16, deltaX * 1.2);\n        gsap.to(shells[i]!, { rotation: lean, duration: 0.5, ease: \"power3.out\", overwrite: \"auto\" });\n      });\n      const dragRelease = contextSafe((i: number) => {\n        const shell = shells[i]!;\n        // The click that follows a drag must not open the card\n        gsap.delayedCall(0.05, () => {\n          justDragged = false;\n        });\n        if (dragging !== i) return;\n        dragging = -1;\n        // The spring home is its own tween, so the layout leaves this card alone until it lands\n        flying.add(i);\n        gsap.to(shell, {\n          ...seat(i, fanned ? 1 : REST, fanned ? hovered : -1),\n          opacity: 1,\n          duration: reduced ? 0 : 1.1,\n          ease: \"elastic.out(1, 0.6)\",\n          overwrite: \"auto\",\n          onComplete: contextSafe(() => {\n            // If the card was opened while springing, this tween was killed; still, never touch an open card\n            if (open === i) return;\n            flying.delete(i);\n            gsap.set(shell, { zIndex: i + 1 });\n            layout(0.4);\n          }),\n        });\n        gsap.to(lifters[i]!, { scale: 1, duration: 0.4, ease: \"power3.out\", overwrite: \"auto\" });\n        layout();\n      });\n      const draggables = shells.map((shell, i) => {\n        const [draggable] = Draggable.create(shell, {\n          type: \"x,y\",\n          minimumMovement: 6,\n          zIndexBoost: false,\n          cursor: \"grab\",\n          activeCursor: \"grabbing\",\n          onDragStart: () => dragStart(i),\n          onDrag() {\n            dragMove(i, this.deltaX);\n          },\n          onRelease: () => dragRelease(i),\n        });\n        return draggable;\n      });\n\n      // Where the last finger came down. Chrome's touch adjustment can hand a tap on a thin visible\n      // sliver of one card to the neighbour whose box overlaps it, so a tap opens the card that is\n      // actually drawn under the finger instead\n      const press = { x: 0, y: 0, touch: false };\n      const onPress = (event: PointerEvent) => {\n        press.x = event.clientX;\n        press.y = event.clientY;\n        press.touch = event.pointerType !== \"mouse\";\n      };\n\n      const onClick = contextSafe((event: MouseEvent) => {\n        if (justDragged) return;\n        let i = shells.indexOf(event.currentTarget as HTMLButtonElement);\n        if (press.touch && open < 0) {\n          const drawn = shells.indexOf(doc.elementFromPoint(press.x, press.y)?.closest(\"button\") as HTMLButtonElement);\n          if (drawn >= 0) i = drawn;\n        }\n        if (i < 0) return;\n        if (i === open) closeCard();\n        else if (open < 0) openCard(i);\n      });\n\n      const onCardEnter = contextSafe((event: PointerEvent) => {\n        if (event.pointerType !== \"mouse\" || dragging >= 0) return;\n        hovered = shells.indexOf(event.currentTarget as HTMLButtonElement);\n        if (fanned) layout(0.6);\n      });\n      const onCardLeave = contextSafe((event: PointerEvent) => {\n        if (event.pointerType !== \"mouse\") return;\n        if (hovered === shells.indexOf(event.currentTarget as HTMLButtonElement)) hovered = -1;\n        layout(0.6);\n      });\n\n      // The hand opens while the pointer is near it: inside the cards' combined outline plus a margin\n      const onBoxMove = contextSafe((event: PointerEvent) => {\n        if (event.pointerType !== \"mouse\" || open >= 0) return;\n        let left = Infinity;\n        let top = Infinity;\n        let right = -Infinity;\n        let bottom = -Infinity;\n        for (const shell of shells) {\n          const rect = shell.getBoundingClientRect();\n          left = Math.min(left, rect.left);\n          top = Math.min(top, rect.top);\n          right = Math.max(right, rect.right);\n          bottom = Math.max(bottom, rect.bottom);\n        }\n        const margin = 48;\n        pointerInside = event.clientX > left - margin && event.clientX < right + margin && event.clientY > top - margin && event.clientY < bottom + margin;\n        if (dragging < 0) setFanned(pointerInside || focusInside);\n      });\n      const onBoxLeave = contextSafe(() => {\n        pointerInside = false;\n        hovered = -1;\n        if (dragging < 0 && open < 0) setFanned(focusInside || (touch && visible));\n      });\n\n      // Keyboard: focus opens the hand and lifts the focused card; arrows walk along it\n      const onFocusIn = contextSafe((event: FocusEvent) => {\n        const i = shells.indexOf(event.target as HTMLButtonElement);\n        if (i < 0) return;\n        focusInside = true;\n        setTabStop(i);\n        if (open < 0) {\n          hovered = i;\n          fanned = true;\n          layout(0.6);\n        }\n      });\n      const onFocusOut = contextSafe((event: FocusEvent) => {\n        if (box.contains(event.relatedTarget as Node | null)) return;\n        focusInside = false;\n        hovered = -1;\n        setFanned(pointerInside || (touch && visible));\n        layout(0.6);\n      });\n      const onKeyDown = contextSafe((event: KeyboardEvent) => {\n        // Enter or Space clicks the focused card itself, not whatever was last under a finger\n        press.touch = false;\n        const i = shells.indexOf(doc.activeElement as HTMLButtonElement);\n        // While a card is open, Tab stays on it rather than leaving the card open behind its backdrop\n        if (event.key === \"Tab\" && open >= 0 && box.contains(doc.activeElement)) {\n          event.preventDefault();\n          shells[open]?.focus({ preventScroll: true });\n          return;\n        }\n        if (event.key === \"Escape\" && open >= 0) {\n          event.preventDefault();\n          const was = open;\n          closeCard();\n          shells[was]?.focus({ preventScroll: true });\n          return;\n        }\n        if (i < 0 || open >= 0) return;\n        let next = -1;\n        if (event.key === \"ArrowRight\" || event.key === \"ArrowDown\") next = (i + 1) % count;\n        else if (event.key === \"ArrowLeft\" || event.key === \"ArrowUp\") next = (i - 1 + count) % count;\n        else if (event.key === \"Home\") next = 0;\n        else if (event.key === \"End\") next = count - 1;\n        if (next < 0) return;\n        event.preventDefault();\n        shells[next]!.focus({ preventScroll: true });\n      });\n      const onBackdropClick = contextSafe(() => closeCard());\n\n      shells.forEach((shell) => {\n        shell.addEventListener(\"click\", onClick);\n        shell.addEventListener(\"pointerenter\", onCardEnter);\n        shell.addEventListener(\"pointerleave\", onCardLeave);\n      });\n      box.addEventListener(\"pointerdown\", onPress, true);\n      box.addEventListener(\"pointermove\", onBoxMove);\n      box.addEventListener(\"pointerleave\", onBoxLeave);\n      box.addEventListener(\"focusin\", onFocusIn);\n      box.addEventListener(\"focusout\", onFocusOut);\n      backdrop.addEventListener(\"click\", onBackdropClick);\n      // Escape works wherever focus is on the page, as long as a card is open\n      doc.addEventListener(\"keydown\", onKeyDown);\n\n      // Touch screens have no hover, so the hand opens whenever it's on screen\n      const watcher = new IntersectionObserver(\n        contextSafe((entries: IntersectionObserverEntry[]) => {\n          visible = entries[0]?.isIntersecting ?? true;\n          if (touch && open < 0 && dragging < 0) setFanned(visible);\n        }),\n      );\n      watcher.observe(box);\n\n      const resizer = new ResizeObserver(\n        contextSafe(() => {\n          measure();\n          shells.forEach((shell, i) => {\n            if (i === open) {\n              // A Flip in flight would still land on the centre it measured before the resize:\n              // finish it now, then put the card on the new centre (the turn keeps going)\n              Flip.killFlipsOf(shell, true);\n              // Anything else still moving the card would drag it off the new centre\n              gsap.killTweensOf(shell, \"x,y,rotation,scaleX,scaleY,width,height\");\n              flying.delete(i);\n              gsap.set(shell, { ...openSpot(), rotation: 0, scaleX: 1, scaleY: 1 });\n            } else gsap.set(shell, { width: size.cardW, height: size.cardH });\n          });\n          layout(0);\n        }),\n      );\n      resizer.observe(box);\n\n      return () => {\n        shells.forEach((shell) => {\n          shell.removeEventListener(\"click\", onClick);\n          shell.removeEventListener(\"pointerenter\", onCardEnter);\n          shell.removeEventListener(\"pointerleave\", onCardLeave);\n          Flip.killFlipsOf(shell);\n        });\n        box.removeEventListener(\"pointerdown\", onPress, true);\n        box.removeEventListener(\"pointermove\", onBoxMove);\n        box.removeEventListener(\"pointerleave\", onBoxLeave);\n        box.removeEventListener(\"focusin\", onFocusIn);\n        box.removeEventListener(\"focusout\", onFocusOut);\n        backdrop.removeEventListener(\"click\", onBackdropClick);\n        doc.removeEventListener(\"keydown\", onKeyDown);\n        draggables.forEach((draggable) => draggable?.kill());\n        watcher.disconnect();\n        resizer.disconnect();\n        setTurned(-1);\n      };\n    },\n    { scope: boxRef, dependencies: [cards.length, spread, radius, lift, flipDuration, cardWidth], revertOnUpdate: true },\n  );\n\n  return (\n    <div ref={boxRef} role=\"group\" aria-label=\"Photo cards\" className={`relative h-full w-full overflow-hidden ${className}`}>\n      <div ref={backdropRef} aria-hidden=\"true\" className={`invisible absolute inset-0 z-[35] opacity-0 ${backdropClassName}`} />\n      {cards.map((card, index) => (\n        <button\n          key={`${index}-${card.src}`}\n          ref={(button) => {\n            cardRefs.current[index] = button;\n          }}\n          type=\"button\"\n          tabIndex={index === tabStop ? 0 : -1}\n          aria-pressed={turned === index}\n          className=\"group absolute top-0 left-0 block cursor-pointer appearance-none border-0 bg-transparent p-0 text-left outline-none select-none\"\n          style={{ width: cardWidth, height: cardWidth * ASPECT, WebkitTapHighlightColor: \"transparent\" }}\n        >\n          <span data-fan-lift className=\"block h-full w-full will-change-transform\">\n            <span data-fan-flip className=\"relative block h-full w-full\" style={{ transformStyle: \"preserve-3d\" }}>\n              {/* Front: the photo */}\n              <span\n                className={`absolute inset-0 overflow-hidden outline-offset-4 group-focus-visible:outline-2 group-focus-visible:outline-current ${cardClassName}`}\n                style={{ backfaceVisibility: \"hidden\", WebkitBackfaceVisibility: \"hidden\" }}\n              >\n                <img src={card.src} alt={card.alt} className=\"pointer-events-none block h-full w-full object-cover\" draggable={false} decoding=\"async\" />\n              </span>\n              {/* Back: the same photo, blurred and dark, under the title and the line of text */}\n              <span\n                data-fan-back\n                aria-hidden={turned !== index}\n                className={`absolute inset-0 flex flex-col justify-between overflow-hidden bg-[#141210] p-[7%] text-white ${cardClassName}`}\n                style={{ backfaceVisibility: \"hidden\", WebkitBackfaceVisibility: \"hidden\" }}\n              >\n                {turned === index ? (\n                  <img src={card.src} alt=\"\" aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 h-full w-full scale-125 object-cover opacity-90 blur-2xl brightness-110 saturate-[1.6]\" draggable={false} />\n                ) : null}\n                <span className=\"pointer-events-none absolute inset-0 bg-gradient-to-b from-black/25 via-transparent to-black/75\" />\n                <span className=\"relative flex items-center justify-between text-[11px] tracking-[0.16em] text-white/70 uppercase tabular-nums\">\n                  <span>\n                    {String(index + 1).padStart(2, \"0\")} / {String(cards.length).padStart(2, \"0\")}\n                  </span>\n                  <span>Photograph</span>\n                </span>\n                <span className=\"relative block border-t border-white/20 pt-5\">\n                  <span className=\"block text-[30px] sm:text-[36px] leading-[0.95] font-medium tracking-[-0.03em]\">{card.title}</span>\n                  <span className=\"mt-3 block max-w-[30ch] text-[14px] leading-[1.45] text-white/75\">{card.text}</span>\n                </span>\n              </span>\n            </span>\n          </span>\n        </button>\n      ))}\n    </div>\n  );\n}\n"}],"categories":["cards","gsap","flip","draggable","3d","images"],"docs":"import { CardFan } from \"@/components/dimaac/card-fan\". Give its parent a size: it fills it. Docs, playground and props: https://ui.dimaac.com/components/card-fan","meta":{"tier":"free","preview":"https://ui.dimaac.com/components/card-fan","credits":[{"by":"West Kast","url":"https://www.lummi.ai/photo/fashion-portrait-with-orange-visor-damql","what":"Photo","source":"Lummi","licence":"Lummi License"},{"by":"rena","url":"https://www.lummi.ai/photo/modern-architectural-design-aozka","what":"Photo","source":"Lummi","licence":"Lummi License"},{"by":"Beth Design Studio","url":"https://www.lummi.ai/photo/desert-wanderer-9puhu","what":"Photo","source":"Lummi","licence":"Lummi License"},{"by":"Kuba","url":"https://www.lummi.ai/photo/abstract-eye-close-up-uamae","what":"Photo","source":"Lummi","licence":"Lummi License"},{"by":"Dona Mara","url":"https://www.lummi.ai/photo/elegant-perfume-bottle-j5t5p","what":"Photo","source":"Lummi","licence":"Lummi License"},{"by":"SHIHO","url":"https://www.lummi.ai/photo/vibrant-cylindrical-arrangement-pj9pp","what":"Photo","source":"Lummi","licence":"Lummi License"}]}}