Card Fan
A hand of photo cards that fans out, lifts and turns over.
Customize
<CardFan cards={cards} />Changes show live and stay in the address, so a link keeps them.
Code
"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>
);
}
The component. Installs to components/dimaac/card-fan.tsx.
Install
With the shadcn CLI
npx shadcn@latest add https://ui.dimaac.com/r/card-fan.jsonIt adds components/dimaac/card-fan.tsx and installs what it imports. With the @dimaac namespace in components.json (Docs) it's just npx shadcn@latest add @dimaac/card-fan.
By hand
npm install gsap @gsap/react- Install the packages it uses: gsap, @gsap/react.
- Copy the component file below into components/dimaac/card-fan.tsx.
- It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
- Import it with import { CardFan } from "@/components/dimaac/card-fan" and give its parent a size: it fills it.
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. |
Use with AI
Agent prompt
Paste it into Claude Code, Cursor or any coding agent, or open it straight in a chat. It carries your Customize settings.
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.txtFiles agents read
- llms.txt
- Every component and how to install it
- card-fan.md
- This page as Markdown: install, props and the full source
- card-fan.json
- The shadcn registry item
About
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.
- Installs to
components/dimaac/card-fan.tsx- Uses
- gsap, @gsap/react
- Tags
- gsap, flip, draggable, cards, 3d, images
Photo by West Kast, rena, Beth Design Studio, Kuba, Dona Mara and SHIHO on Lummi (Lummi License).