---
title: Card Fan
description: A hand of photo cards that fans out, lifts and turns over.
category: Cards
tier: free
url: https://ui.dimaac.com/components/card-fan
registry: https://ui.dimaac.com/r/card-fan.json
---

# Card Fan

A stack of photo cards held like a hand of playing cards. Point at the stack and it opens into an arc around a pivot far below it; the card under the pointer rises while its neighbours lean away. Click a card and GSAP Flip carries it to the middle at full size while it turns over in 3D to show a title and a line of text on its back; click again, click outside or press Escape to put it back. Every card can be dragged out of the hand and springs home with an elastic ease. The cards are real buttons with a single tab stop: arrow keys walk along the fan, Enter turns a card over. On touch screens the hand opens whenever it is on screen. With reduced motion the hand sits open, nothing travels and the back simply fades in over the front.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/card-fan.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/card-fan.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/card-fan.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/card-fan`

Dependencies:

```bash
pnpm add gsap @gsap/react
npm install gsap @gsap/react
```

By hand:

1. Install the packages it uses: gsap, @gsap/react.
2. Copy the component file below into components/dimaac/card-fan.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { CardFan } from "@/components/dimaac/card-fan" and give its parent a size: it fills it.

## Usage

```tsx
import { CardFan } from "@/components/dimaac/card-fan";

<CardFan cards={cards} />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `cards` (required) | `CardFanCard[]` | - | The cards, left to right. Four to eight look best. |
| `spread` | `number` | `64` | How wide the open fan is, from the first card to the last, in degrees. |
| `radius` | `number` | `560` | How far below the cards the fan's pivot sits, in pixels. Bigger is a flatter arc. |
| `lift` | `number` | `36` | How far a card rises when you point at it, in pixels. |
| `flipDuration` | `number` | `0.9` | How long the trip to the middle and the turn take, in seconds. |
| `cardWidth` | `number` | `210` | Width of a card in the hand, in pixels (smaller on narrow screens). |
| `className` | `string` | `""` | Extra classes for the outer box. Give it a size (it fills it). |
| `cardClassName` | `string` | `"rounded-[14px] shadow-[0_24px_48px_-20px_rgba(0,0,0,0.45),0_2px_6px_-2px_rgba(0,0,0,0.2)]"` | Extra classes for both faces of every card, e.g. a radius or a shadow. |
| `backdropClassName` | `string` | `"bg-black/25"` | Classes for the layer behind an open card, e.g. a tint or a blur. |

## Credits

- Photo: West Kast, Lummi (https://www.lummi.ai/photo/fashion-portrait-with-orange-visor-damql), Lummi License
- Photo: rena, Lummi (https://www.lummi.ai/photo/modern-architectural-design-aozka), Lummi License
- Photo: Beth Design Studio, Lummi (https://www.lummi.ai/photo/desert-wanderer-9puhu), Lummi License
- Photo: Kuba, Lummi (https://www.lummi.ai/photo/abstract-eye-close-up-uamae), Lummi License
- Photo: Dona Mara, Lummi (https://www.lummi.ai/photo/elegant-perfume-bottle-j5t5p), Lummi License
- Photo: SHIHO, Lummi (https://www.lummi.ai/photo/vibrant-cylindrical-arrangement-pj9pp), Lummi License

## Source

### card-fan.tsx → `components/dimaac/card-fan.tsx`

```tsx
"use client";

import { useRef, useState } from "react";
import { gsap } from "gsap";
import { Flip } from "gsap/Flip";
import { Draggable } from "gsap/Draggable";
import { useGSAP } from "@gsap/react";

// A hand of photo cards. At rest they sit in a tight stack; hover (or focus) opens them into an arc
// around a pivot far below the stack, like a hand of playing cards. Pointing at a card lifts it.
// Clicking a card sends it to the middle at full size with GSAP Flip and turns it over in 3D to
// show its back. Any card can be dragged out of the hand and springs back when you let go.

export type CardFanCard = {
  /** The photo on the front. */
  src: string;
  /** Describes the photo for screen readers. */
  alt: string;
  /** The title on the back. */
  title: string;
  /** One line of text on the back. */
  text: string;
};

export type CardFanProps = {
  /** The cards, left to right. Four to eight look best. */
  cards: CardFanCard[];
  /** How wide the open fan is, from the first card to the last, in degrees. */
  spread?: number;
  /** How far below the cards the fan's pivot sits, in pixels. Bigger is a flatter arc. */
  radius?: number;
  /** How far a card rises when you point at it, in pixels. */
  lift?: number;
  /** How long the trip to the middle and the turn take, in seconds. */
  flipDuration?: number;
  /** Width of a card in the hand, in pixels (smaller on narrow screens). */
  cardWidth?: number;
  /** Extra classes for the outer box. Give it a size (it fills it). */
  className?: string;
  /** Extra classes for both faces of every card, e.g. a radius or a shadow. */
  cardClassName?: string;
  /** Classes for the layer behind an open card, e.g. a tint or a blur. */
  backdropClassName?: string;
};

// How open the hand is at rest, as a share of the full spread
const REST = 0.16;
// Cards are 4:5, like the photos
const ASPECT = 1.25;

export function CardFan({
  cards,
  spread = 64,
  radius = 560,
  lift = 36,
  flipDuration = 0.9,
  cardWidth = 210,
  className = "",
  cardClassName = "rounded-[14px] shadow-[0_24px_48px_-20px_rgba(0,0,0,0.45),0_2px_6px_-2px_rgba(0,0,0,0.2)]",
  backdropClassName = "bg-black/25",
}: CardFanProps) {
  const boxRef = useRef<HTMLDivElement>(null);
  const backdropRef = useRef<HTMLDivElement>(null);
  const cardRefs = useRef<(HTMLButtonElement | null)[]>([]);
  // True once the deal-in has played, so a prop change re-lays the hand instead of dealing again
  const dealt = useRef(false);
  // Which card shows its back (drives the blurred back photo and what screen readers hear)
  const [turned, setTurned] = useState(-1);
  // Roving tab stop: one card is in the tab order, arrows move between them
  const [tabStop, setTabStop] = useState(0);

  useGSAP(
    (_context, contextSafe) => {
      const box = boxRef.current;
      const backdrop = backdropRef.current;
      const shells = cardRefs.current.slice(0, cards.length).filter((card): card is HTMLButtonElement => card !== null);
      if (!box || !backdrop || shells.length === 0 || !contextSafe) return;
      gsap.registerPlugin(Flip, Draggable);

      const lifters = shells.map((shell) => shell.querySelector<HTMLElement>("[data-fan-lift]")!);
      const flippers = shells.map((shell) => shell.querySelector<HTMLElement>("[data-fan-flip]")!);
      const backs = shells.map((shell) => shell.querySelector<HTMLElement>("[data-fan-back]")!);
      const count = shells.length;
      const middle = (count - 1) / 2;
      const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
      const touch = window.matchMedia("(hover: none)").matches;
      const doc = box.ownerDocument;

      // Everything the layout needs, measured from the box
      const size = { boxW: 0, boxH: 0, cardW: 0, cardH: 0, pivot: radius, centreY: 0, bigW: 0, spread };
      const measure = () => {
        size.boxW = box.clientWidth;
        size.boxH = box.clientHeight;
        // Phones get wider cards (a share of a small box), so the inner cards stay big enough to tap
        const narrow = size.boxW < 600;
        size.cardW = Math.min(cardWidth, size.boxW * (narrow ? 0.4 : 0.34), (size.boxH * 0.46) / ASPECT);
        size.cardH = size.cardW * ASPECT;
        // On a narrow box the pivot gets pulled in hard, so open the fan wider to keep the cards apart
        size.spread = narrow ? Math.min(spread * 1.35, 120) : spread;
        // Pull the pivot in when the open fan would spill past the sides of the box
        const half = (size.spread / 2) * (Math.PI / 180);
        // The end card leans out a little further when a neighbour is pointed at (see seat())
        const edge = half + Math.min(4, (size.spread / count) * 0.4) * (Math.PI / 180);
        // How far the end card's outer corner reaches past its centre once it is rotated
        const reach = (size.cardW / 2) * Math.cos(edge) + (size.cardH / 2) * Math.sin(edge);
        const room = size.boxW / 2 - 16 - reach;
        size.pivot = edge > 0 && Math.sin(edge) * radius > room ? Math.max(room / Math.sin(edge), 0) : radius;
        // Centre the whole open hand (raised middle card down to the dipped end corners), so big
        // cards on a short box don't sink into whatever sits below the hand
        const above = size.cardH / 2 + lift;
        const below = (1 - Math.cos(half)) * size.pivot + (size.cardW / 2) * Math.sin(half) + (size.cardH / 2) * Math.cos(half);
        size.centreY = size.boxH * 0.5 + (above - below) / 2;
        size.bigW = Math.min(440, size.boxW - 48, (size.boxH - 72) / ASPECT);
      };

      // The state of the hand. Plain variables: they change on every pointer move and React
      // doesn't need to hear about it.
      let fanned = reduced;
      let hovered = -1;
      let open = -1;
      let dragging = -1;
      let ready = false;
      // A prop change rebuilds the context while the pointer may be resting over the hand
      let pointerInside = box.matches(":hover");
      // A prop change rebuilds the context while a card may still have focus
      let focusInside = box.contains(doc.activeElement);
      let visible = true;
      let justDragged = false;
      // Cards in the middle of a Flip are left alone by the layout
      const flying = new Set<number>();

      // Where card i sits: on a circle around a pivot below the hand, rotated to face out from it
      const seat = (i: number, amount: number, around: number) => {
        const t = count > 1 ? (i - middle) / (count - 1) : 0;
        let degrees = t * size.spread * amount;
        // Neighbours lean away from the card you point at, so it has room to rise
        if (around >= 0 && around !== i) degrees += (i < around ? -1 : 1) * Math.min(4, (size.spread / count) * 0.4) * amount;
        const angle = degrees * (Math.PI / 180);
        // x and y place the card's top-left corner (Flip works in plain pixels, so no xPercent)
        return {
          x: size.boxW / 2 + Math.sin(angle) * size.pivot - size.cardW / 2,
          y: size.centreY + (1 - Math.cos(angle)) * size.pivot - size.cardH / 2,
          rotation: degrees,
        };
      };

      // Where an open card goes: big, in the middle of the box
      const openSpot = () => ({
        x: (size.boxW - size.bigW) / 2,
        y: (size.boxH - size.bigW * ASPECT) / 2,
        width: size.bigW,
        height: size.bigW * ASPECT,
      });

      // contextSafe: layout also runs from tween and observer callbacks, and every tween it makes
      // must belong to this context so a prop change reverts it
      const layout = contextSafe((duration = 0.8) => {
        if (!ready) return;
        const amount = fanned ? 1 : REST;
        shells.forEach((shell, i) => {
          if (i === open || i === dragging || flying.has(i)) return;
          // The lifted card comes to the front so its neighbours don't cover it
          gsap.set(shell, { zIndex: i === hovered && open < 0 ? count + 1 : i + 1 });
          gsap.to(shell, {
            ...seat(i, amount, fanned ? hovered : -1),
            opacity: open >= 0 ? 0.35 : 1,
            duration: reduced ? 0 : duration,
            ease: "power3.out",
            overwrite: "auto",
          });
          const rising = i === hovered && open < 0;
          gsap.to(lifters[i]!, {
            y: rising ? -lift : open >= 0 ? 28 : 0,
            scale: rising ? 1.03 : 1,
            duration: reduced ? 0.15 : 0.55,
            ease: rising ? "back.out(2)" : "power3.out",
            overwrite: "auto",
          });
        });
      });

      const setFanned = (next: boolean) => {
        if (reduced) next = true;
        if (next === fanned) return;
        fanned = next;
        layout();
      };

      measure();
      gsap.set(shells, { width: size.cardW, height: size.cardH, zIndex: (i: number) => i + 1, force3D: true });
      gsap.set(flippers, { transformPerspective: 1400, rotationY: 0 });
      // Reduced motion: the back sits flat on top of the front and fades in instead of turning round
      gsap.set(backs, reduced ? { rotationY: 0, opacity: 0 } : { rotationY: 180, opacity: 1 });
      gsap.set(backdrop, { autoAlpha: 0 });

      if (touch && !reduced) fanned = visible;

      if (reduced || dealt.current) {
        // Already dealt (or no motion): put the hand straight down
        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 });
        ready = true;
        if (!reduced) {
          // A prop just changed: open the hand and raise the middle card for a moment so the change shows
          fanned = true;
          hovered = Math.round(middle);
          layout(0.7);
          gsap.delayedCall(1.6, () => {
            // Keep the card under a resting pointer raised, else the focused one
            const under = shells.findIndex((shell) => shell.matches(":hover"));
            hovered = under >= 0 && !touch ? under : shells.indexOf(doc.activeElement as HTMLButtonElement);
            pointerInside = pointerInside || box.matches(":hover");
            setFanned(pointerInside || focusInside || (touch && visible));
            layout();
          });
        }
      } else {
        // The deal: cards rise from below the box one after another and settle into the stack
        gsap.set(shells, {
          x: (i: number) => seat(i, REST, -1).x + (i - middle) * 30,
          y: size.boxH + size.cardH,
          rotation: (i: number) => (i - middle) * -14,
          opacity: 1,
        });
        gsap.to(shells, {
          x: (i: number) => seat(i, REST, -1).x,
          y: (i: number) => seat(i, REST, -1).y,
          rotation: (i: number) => seat(i, REST, -1).rotation,
          duration: 1.3,
          ease: "expo.out",
          stagger: 0.07,
          delay: 0.15,
          onComplete: () => {
            dealt.current = true;
            ready = true;
            layout();
          },
        });
      }

      const openCard = (i: number) => {
        const shell = shells[i]!;
        open = i;
        hovered = -1;
        flying.add(i);
        setTurned(i);
        draggables[i]?.disable();
        Flip.killFlipsOf(shell);
        // A hover tween or a drag spring may still be moving this card: stop it, or it keeps
        // pulling the open card back toward its seat (rotated, off-centre) while the Flip runs
        gsap.killTweensOf(shell, "x,y,rotation,opacity,zIndex");
        // Flip: note where the card is now, jump it to its open spot, then animate from the old spot
        const state = Flip.getState(shell);
        gsap.set(shell, { ...openSpot(), rotation: 0, zIndex: 50, opacity: 1 });
        gsap.to(backdrop, { autoAlpha: 1, duration: reduced ? 0.2 : flipDuration * 0.7, ease: "power2.out" });
        if (reduced) {
          gsap.fromTo(shell, { opacity: 0 }, { opacity: 1, duration: 0.25 });
          gsap.set(lifters[i]!, { y: 0, scale: 1 });
          gsap.to(backs[i]!, { opacity: 1, duration: 0.25 });
          flying.delete(i);
        } else {
          Flip.from(state, {
            duration: flipDuration,
            ease: "expo.inOut",
            scale: true,
            onComplete: contextSafe(() => {
              flying.delete(i);
              // Backstop: if the box changed size during the flight, land on the new centre
              if (open === i) gsap.set(shell, { ...openSpot(), rotation: 0, scaleX: 1, scaleY: 1 });
            }),
          });
          gsap.to(lifters[i]!, { y: 0, scale: 1, duration: flipDuration * 0.6, ease: "power2.inOut", overwrite: "auto" });
          // The turn starts a beat after the flight so the card leaves the hand face up
          gsap.to(flippers[i]!, { rotationY: 180, duration: flipDuration * 0.9, delay: flipDuration * 0.1, ease: "power3.inOut", overwrite: "auto" });
        }
        layout(0.7);
      };

      const closeCard = () => {
        const i = open;
        if (i < 0) return;
        const shell = shells[i]!;
        open = -1;
        flying.add(i);
        Flip.killFlipsOf(shell);
        gsap.killTweensOf(shell, "x,y,rotation,opacity,zIndex");
        const state = Flip.getState(shell);
        gsap.set(shell, { ...seat(i, fanned ? 1 : REST, -1), width: size.cardW, height: size.cardH });
        gsap.to(backdrop, { autoAlpha: 0, duration: reduced ? 0.2 : flipDuration * 0.6, ease: "power2.out" });
        const land = contextSafe(() => {
          flying.delete(i);
          gsap.set(shell, { zIndex: i + 1 });
          draggables[i]?.enable();
          // Focus came back to this card (Escape or Enter), so it stays raised like any focused card
          if (doc.activeElement === shell && open < 0) hovered = i;
          setTurned((current) => (current === i ? -1 : current));
          layout(0.4);
        });
        if (reduced) {
          gsap.set(backs[i]!, { opacity: 0 });
          gsap.fromTo(shell, { opacity: 0 }, { opacity: 1, duration: 0.25 });
          land();
        } else {
          Flip.from(state, { duration: flipDuration * 0.85, ease: "expo.inOut", scale: true, onComplete: land });
          gsap.to(flippers[i]!, { rotationY: 0, duration: flipDuration * 0.75, ease: "power3.inOut", overwrite: "auto" });
        }
        layout(0.8);
      };

      // Dragging: a card follows the pointer, tilts with its speed and springs back to its seat.
      // The work lives in contextSafe functions (Draggable calls its callbacks outside the context,
      // and contextSafe replaces `this`, so the Draggable's numbers are passed in instead).
      const dragStart = contextSafe((i: number) => {
        dragging = i;
        justDragged = true;
        gsap.set(shells[i]!, { zIndex: 45 });
        gsap.to(lifters[i]!, { y: 0, scale: 1.05, duration: 0.3, ease: "power3.out", overwrite: "auto" });
      });
      const dragMove = contextSafe((i: number, deltaX: number) => {
        const lean = gsap.utils.clamp(-16, 16, deltaX * 1.2);
        gsap.to(shells[i]!, { rotation: lean, duration: 0.5, ease: "power3.out", overwrite: "auto" });
      });
      const dragRelease = contextSafe((i: number) => {
        const shell = shells[i]!;
        // The click that follows a drag must not open the card
        gsap.delayedCall(0.05, () => {
          justDragged = false;
        });
        if (dragging !== i) return;
        dragging = -1;
        // The spring home is its own tween, so the layout leaves this card alone until it lands
        flying.add(i);
        gsap.to(shell, {
          ...seat(i, fanned ? 1 : REST, fanned ? hovered : -1),
          opacity: 1,
          duration: reduced ? 0 : 1.1,
          ease: "elastic.out(1, 0.6)",
          overwrite: "auto",
          onComplete: contextSafe(() => {
            // If the card was opened while springing, this tween was killed; still, never touch an open card
            if (open === i) return;
            flying.delete(i);
            gsap.set(shell, { zIndex: i + 1 });
            layout(0.4);
          }),
        });
        gsap.to(lifters[i]!, { scale: 1, duration: 0.4, ease: "power3.out", overwrite: "auto" });
        layout();
      });
      const draggables = shells.map((shell, i) => {
        const [draggable] = Draggable.create(shell, {
          type: "x,y",
          minimumMovement: 6,
          zIndexBoost: false,
          cursor: "grab",
          activeCursor: "grabbing",
          onDragStart: () => dragStart(i),
          onDrag() {
            dragMove(i, this.deltaX);
          },
          onRelease: () => dragRelease(i),
        });
        return draggable;
      });

      // Where the last finger came down. Chrome's touch adjustment can hand a tap on a thin visible
      // sliver of one card to the neighbour whose box overlaps it, so a tap opens the card that is
      // actually drawn under the finger instead
      const press = { x: 0, y: 0, touch: false };
      const onPress = (event: PointerEvent) => {
        press.x = event.clientX;
        press.y = event.clientY;
        press.touch = event.pointerType !== "mouse";
      };

      const onClick = contextSafe((event: MouseEvent) => {
        if (justDragged) return;
        let i = shells.indexOf(event.currentTarget as HTMLButtonElement);
        if (press.touch && open < 0) {
          const drawn = shells.indexOf(doc.elementFromPoint(press.x, press.y)?.closest("button") as HTMLButtonElement);
          if (drawn >= 0) i = drawn;
        }
        if (i < 0) return;
        if (i === open) closeCard();
        else if (open < 0) openCard(i);
      });

      const onCardEnter = contextSafe((event: PointerEvent) => {
        if (event.pointerType !== "mouse" || dragging >= 0) return;
        hovered = shells.indexOf(event.currentTarget as HTMLButtonElement);
        if (fanned) layout(0.6);
      });
      const onCardLeave = contextSafe((event: PointerEvent) => {
        if (event.pointerType !== "mouse") return;
        if (hovered === shells.indexOf(event.currentTarget as HTMLButtonElement)) hovered = -1;
        layout(0.6);
      });

      // The hand opens while the pointer is near it: inside the cards' combined outline plus a margin
      const onBoxMove = contextSafe((event: PointerEvent) => {
        if (event.pointerType !== "mouse" || open >= 0) return;
        let left = Infinity;
        let top = Infinity;
        let right = -Infinity;
        let bottom = -Infinity;
        for (const shell of shells) {
          const rect = shell.getBoundingClientRect();
          left = Math.min(left, rect.left);
          top = Math.min(top, rect.top);
          right = Math.max(right, rect.right);
          bottom = Math.max(bottom, rect.bottom);
        }
        const margin = 48;
        pointerInside = event.clientX > left - margin && event.clientX < right + margin && event.clientY > top - margin && event.clientY < bottom + margin;
        if (dragging < 0) setFanned(pointerInside || focusInside);
      });
      const onBoxLeave = contextSafe(() => {
        pointerInside = false;
        hovered = -1;
        if (dragging < 0 && open < 0) setFanned(focusInside || (touch && visible));
      });

      // Keyboard: focus opens the hand and lifts the focused card; arrows walk along it
      const onFocusIn = contextSafe((event: FocusEvent) => {
        const i = shells.indexOf(event.target as HTMLButtonElement);
        if (i < 0) return;
        focusInside = true;
        setTabStop(i);
        if (open < 0) {
          hovered = i;
          fanned = true;
          layout(0.6);
        }
      });
      const onFocusOut = contextSafe((event: FocusEvent) => {
        if (box.contains(event.relatedTarget as Node | null)) return;
        focusInside = false;
        hovered = -1;
        setFanned(pointerInside || (touch && visible));
        layout(0.6);
      });
      const onKeyDown = contextSafe((event: KeyboardEvent) => {
        // Enter or Space clicks the focused card itself, not whatever was last under a finger
        press.touch = false;
        const i = shells.indexOf(doc.activeElement as HTMLButtonElement);
        // While a card is open, Tab stays on it rather than leaving the card open behind its backdrop
        if (event.key === "Tab" && open >= 0 && box.contains(doc.activeElement)) {
          event.preventDefault();
          shells[open]?.focus({ preventScroll: true });
          return;
        }
        if (event.key === "Escape" && open >= 0) {
          event.preventDefault();
          const was = open;
          closeCard();
          shells[was]?.focus({ preventScroll: true });
          return;
        }
        if (i < 0 || open >= 0) return;
        let next = -1;
        if (event.key === "ArrowRight" || event.key === "ArrowDown") next = (i + 1) % count;
        else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = (i - 1 + count) % count;
        else if (event.key === "Home") next = 0;
        else if (event.key === "End") next = count - 1;
        if (next < 0) return;
        event.preventDefault();
        shells[next]!.focus({ preventScroll: true });
      });
      const onBackdropClick = contextSafe(() => closeCard());

      shells.forEach((shell) => {
        shell.addEventListener("click", onClick);
        shell.addEventListener("pointerenter", onCardEnter);
        shell.addEventListener("pointerleave", onCardLeave);
      });
      box.addEventListener("pointerdown", onPress, true);
      box.addEventListener("pointermove", onBoxMove);
      box.addEventListener("pointerleave", onBoxLeave);
      box.addEventListener("focusin", onFocusIn);
      box.addEventListener("focusout", onFocusOut);
      backdrop.addEventListener("click", onBackdropClick);
      // Escape works wherever focus is on the page, as long as a card is open
      doc.addEventListener("keydown", onKeyDown);

      // Touch screens have no hover, so the hand opens whenever it's on screen
      const watcher = new IntersectionObserver(
        contextSafe((entries: IntersectionObserverEntry[]) => {
          visible = entries[0]?.isIntersecting ?? true;
          if (touch && open < 0 && dragging < 0) setFanned(visible);
        }),
      );
      watcher.observe(box);

      const resizer = new ResizeObserver(
        contextSafe(() => {
          measure();
          shells.forEach((shell, i) => {
            if (i === open) {
              // A Flip in flight would still land on the centre it measured before the resize:
              // finish it now, then put the card on the new centre (the turn keeps going)
              Flip.killFlipsOf(shell, true);
              // Anything else still moving the card would drag it off the new centre
              gsap.killTweensOf(shell, "x,y,rotation,scaleX,scaleY,width,height");
              flying.delete(i);
              gsap.set(shell, { ...openSpot(), rotation: 0, scaleX: 1, scaleY: 1 });
            } else gsap.set(shell, { width: size.cardW, height: size.cardH });
          });
          layout(0);
        }),
      );
      resizer.observe(box);

      return () => {
        shells.forEach((shell) => {
          shell.removeEventListener("click", onClick);
          shell.removeEventListener("pointerenter", onCardEnter);
          shell.removeEventListener("pointerleave", onCardLeave);
          Flip.killFlipsOf(shell);
        });
        box.removeEventListener("pointerdown", onPress, true);
        box.removeEventListener("pointermove", onBoxMove);
        box.removeEventListener("pointerleave", onBoxLeave);
        box.removeEventListener("focusin", onFocusIn);
        box.removeEventListener("focusout", onFocusOut);
        backdrop.removeEventListener("click", onBackdropClick);
        doc.removeEventListener("keydown", onKeyDown);
        draggables.forEach((draggable) => draggable?.kill());
        watcher.disconnect();
        resizer.disconnect();
        setTurned(-1);
      };
    },
    { scope: boxRef, dependencies: [cards.length, spread, radius, lift, flipDuration, cardWidth], revertOnUpdate: true },
  );

  return (
    <div ref={boxRef} role="group" aria-label="Photo cards" className={`relative h-full w-full overflow-hidden ${className}`}>
      <div ref={backdropRef} aria-hidden="true" className={`invisible absolute inset-0 z-[35] opacity-0 ${backdropClassName}`} />
      {cards.map((card, index) => (
        <button
          key={`${index}-${card.src}`}
          ref={(button) => {
            cardRefs.current[index] = button;
          }}
          type="button"
          tabIndex={index === tabStop ? 0 : -1}
          aria-pressed={turned === index}
          className="group absolute top-0 left-0 block cursor-pointer appearance-none border-0 bg-transparent p-0 text-left outline-none select-none"
          style={{ width: cardWidth, height: cardWidth * ASPECT, WebkitTapHighlightColor: "transparent" }}
        >
          <span data-fan-lift className="block h-full w-full will-change-transform">
            <span data-fan-flip className="relative block h-full w-full" style={{ transformStyle: "preserve-3d" }}>
              {/* Front: the photo */}
              <span
                className={`absolute inset-0 overflow-hidden outline-offset-4 group-focus-visible:outline-2 group-focus-visible:outline-current ${cardClassName}`}
                style={{ backfaceVisibility: "hidden", WebkitBackfaceVisibility: "hidden" }}
              >
                <img src={card.src} alt={card.alt} className="pointer-events-none block h-full w-full object-cover" draggable={false} decoding="async" />
              </span>
              {/* Back: the same photo, blurred and dark, under the title and the line of text */}
              <span
                data-fan-back
                aria-hidden={turned !== index}
                className={`absolute inset-0 flex flex-col justify-between overflow-hidden bg-[#141210] p-[7%] text-white ${cardClassName}`}
                style={{ backfaceVisibility: "hidden", WebkitBackfaceVisibility: "hidden" }}
              >
                {turned === index ? (
                  <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} />
                ) : null}
                <span className="pointer-events-none absolute inset-0 bg-gradient-to-b from-black/25 via-transparent to-black/75" />
                <span className="relative flex items-center justify-between text-[11px] tracking-[0.16em] text-white/70 uppercase tabular-nums">
                  <span>
                    {String(index + 1).padStart(2, "0")} / {String(cards.length).padStart(2, "0")}
                  </span>
                  <span>Photograph</span>
                </span>
                <span className="relative block border-t border-white/20 pt-5">
                  <span className="block text-[30px] sm:text-[36px] leading-[0.95] font-medium tracking-[-0.03em]">{card.title}</span>
                  <span className="mt-3 block max-w-[30ch] text-[14px] leading-[1.45] text-white/75">{card.text}</span>
                </span>
              </span>
            </span>
          </span>
        </button>
      ))}
    </div>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { CardFan, type CardFanCard, type CardFanProps } from "@/components/dimaac/card-fan";

// Six photographs held like a hand of cards, on a paper stage that follows the site theme.
// Photos: Lummi (Lummi License), see meta.ts for each photographer.

const cards: CardFanCard[] = [
  {
    src: "https://ui.dimaac.com/media/fashion/orange-visor-4x5.jpg",
    alt: "A woman in an orange visor and navy dress with red lips against a teal background",
    title: "Orange Visor",
    text: "Navy, citrus and a red lip, shot against a wall the colour of the sea.",
  },
  {
    src: "https://ui.dimaac.com/media/architecture/yellow-planes-4x5.jpg",
    alt: "Yellow and beige walls meeting at sharp angles under a clear blue sky",
    title: "Yellow Planes",
    text: "Two walls and one sky. The shadow does all the drawing.",
  },
  {
    src: "https://ui.dimaac.com/media/nature/desert-wanderer-4x5.jpg",
    alt: "A person in a flowing garment walking across a desert under a golden sky",
    title: "Desert Walk",
    text: "Late light on the dunes and a long garment caught by the wind.",
  },
  {
    src: "https://ui.dimaac.com/media/portraits/light-streaks-4x5.jpg",
    alt: "Close-up of a face with blue eyes crossed by colourful streaks of light",
    title: "Light Streaks",
    text: "A close-up painted with long exposures of coloured light.",
  },
  {
    src: "https://ui.dimaac.com/media/objects/amber-perfume-4x5.jpg",
    alt: "A glass perfume bottle with amber liquid on green, with shadows of leaves",
    title: "Amber Glass",
    text: "A bottle of scent on green, lit like an old still life.",
  },
  {
    src: "https://ui.dimaac.com/media/art/colour-cylinders-4x5.jpg",
    alt: "Colourful cylinders standing on a bold geometric background",
    title: "Colour Study",
    text: "Painted cylinders stood on a grid of flat orange, red and blue.",
  },
];

export default function Demo(props: Partial<CardFanProps>) {
  return (
    <div
      className="relative h-full w-full overflow-hidden bg-[#ebe7df] text-[#1a1714] dark:bg-[#0f0e0c] dark:text-[#ece6dc]"
      style={{ fontFamily: '"Helvetica Neue", Helvetica, Arial, sans-serif' }}
    >
      {/* A soft pool of light under the hand */}
      <div className="pointer-events-none absolute inset-0 bg-[radial-gradient(60%_55%_at_50%_58%,rgba(255,255,255,0.75),transparent_70%)] dark:bg-[radial-gradient(60%_55%_at_50%_58%,rgba(255,240,220,0.07),transparent_70%)]" />

      <footer className="pointer-events-none absolute inset-x-5 bottom-6 flex flex-col gap-3 select-none md:inset-x-10 md:bottom-9 md:flex-row md:items-end md:justify-between">
        <h1 className="text-[clamp(40px,7vw,96px)] leading-[0.9] font-medium tracking-[-0.045em]">Pick a card</h1>
        <p className="max-w-[34ch] text-[14px] leading-[1.45] opacity-60 md:text-right">
          Six photographs held like a hand, each with a note on its back. <span className="hidden sm:inline">Hover to fan them and click one to turn it over.</span>
          <span className="sm:hidden">Tap a card to turn it over.</span>
        </p>
      </footer>

      <CardFan
        cards={cards}
        backdropClassName="bg-[#ebe7df]/70 backdrop-blur-[3px] dark:bg-[#0f0e0c]/75"
        {...props}
        className={`absolute inset-0 z-10 ${props.className ?? ""}`}
      />
    </div>
  );
}
```

## Agent prompt

```text
Add the Card Fan component (<CardFan />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/card-fan
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/card-fan.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/card-fan.json

Then use it like this:
import { CardFan } from "@/components/dimaac/card-fan";
<CardFan cards={cards} />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/card-fan.tsx and install gsap, @gsap/react.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```
