Carousels

Fanned Card Stack

Renders a stack of items as fanned-out cards. Users can drag the top card to "swipe" it to the bottom of the stack.

Interactive Card Deck

A classic implementation resembling physical playing cards. This demonstrates the stack's ability to handle standard rotation and stacking contexts while maintaining a playful, tactile feel perfect for gamified interfaces.

A
A
K
K
Q
Q
J
J
10
10

Profile Discovery

A rich media implementation featuring full-bleed background images and gradient overlays. With a larger rotation factor (rotateFactor={6}), it creates a dramatic "fan" effect suitable for discovery or dating app interfaces where visual impact is key.

Sarah, 24

Brooklyn, NY

Coffee addict. UX Designer. Dog lover.

David, 28

Austin, TX

Musician and avid hiker.

Emily, 26

Portland, OR

Bookworm & plant mom 🌱

Installation

npx shadcn@latest add https://satisui.xyz/r/fanned-card-stack.json

Manual

npm install gsap @gsap/react
# or
yarn add gsap @gsap/react
# or
pnpm add gsap @gsap/react
components/ui/fanned-card-stack.tsx
'use client';

import * as React from 'react';
import { cn } from '@/lib/utils';
import gsap from 'gsap';
import { Draggable } from 'gsap/Draggable';
import { useGSAP } from '@gsap/react';

if (typeof window !== 'undefined') {
  gsap.registerPlugin(Draggable);
}

/**
 * Props for the FannedCardStack component.
 */
interface FannedCardStackProps<T> {
  /** The array of data items to render in the stack. */
  items: T[];
  /** Function to render the content of each card. */
  renderItem: (item: T, index: number) => React.ReactNode;
  /** Rotation degree difference between stacked cards (default: 4). */
  rotateFactor?: number;
  /** Scale reduction difference between stacked cards (default: 0.05). */
  scaleFactor?: number;
  /** The transform origin point (percentage) for rotation (default: { x: 50, y: 100 }). */
  pivot?: { x: number; y: number };
  /** Callback fired when the stack order changes after a swipe. */
  onReorder?: (newItems: T[]) => void;
  /** Optional class names for the container. */
  className?: string;
}

/**
 * Renders a stack of items as fanned-out cards.
 * Users can drag the top card to "swipe" it to the bottom of the stack.
 */
export function FannedCardStack<T>({
  items: initialItems,
  renderItem,
  rotateFactor = 4,
  scaleFactor = 0.05,
  pivot = { x: 50, y: 100 },
  onReorder,
  className,
}: FannedCardStackProps<T>) {
  const [items, setItems] = React.useState(initialItems);
  const cardRefs = React.useRef<(HTMLDivElement | null)[]>([]);
  const isAnimating = React.useRef(false);
  const hasLoaded = React.useRef(false);

  React.useEffect(() => {
    setItems(initialItems);
  }, [initialItems]);

  const getCardStyle = React.useCallback(
    (index: number) => {
      return {
        rotation: index * rotateFactor,
        scale: 1 - index * scaleFactor,
        zIndex: items.length - index,
        x: 0,
        y: 0,
        opacity: 1,
      };
    },
    [items.length, rotateFactor, scaleFactor]
  );

  useGSAP(() => {
    // DECISION: We split logic into "Entrance" (initial load) and "Maintenance" (re-renders).
    // The entrance ensures a clean 'deal' animation, while maintenance updates positions instantly
    // to keep the stack visually consistent during React state updates.
    if (!hasLoaded.current) {
      items.forEach((_, index) => {
        const el = cardRefs.current[index];
        if (el) {
          gsap.set(el, {
            transformOrigin: `${pivot.x}% ${pivot.y}%`,
            rotation: 0,
            x: 0,
            y: 50,
            scale: 0.9,
            opacity: 0,
            zIndex: items.length - index,
          });
        }
      });

      gsap.to(cardRefs.current, {
        rotation: (i) => getCardStyle(i).rotation,
        scale: (i) => getCardStyle(i).scale,
        y: 0,
        x: 0,
        opacity: 1,
        duration: 0.8,
        stagger: 0.08,
        ease: 'back.out(1.2)',
        onComplete: () => {
          hasLoaded.current = true;
        },
      });
    } else {
      items.forEach((_, index) => {
        const el = cardRefs.current[index];
        if (!el) return;
        const style = getCardStyle(index);

        gsap.set(el, {
          transformOrigin: `${pivot.x}% ${pivot.y}%`,
          rotation: style.rotation,
          scale: style.scale,
          x: 0,
          y: 0,
          zIndex: style.zIndex,
          opacity: 1,
          overwrite: 'auto',
        });
      });
    }

    const topCard = cardRefs.current[0];
    if (!topCard) return;

    const draggable = Draggable.create(topCard, {
      type: 'x,y',
      zIndexBoost: false,
      onPress: function () {
        if (isAnimating.current) {
          this.endDrag();
          return;
        }
        // STOP: Kill any ongoing "snap back" animations if the user grabs the card mid-air.
        gsap.killTweensOf(this.target);
      },
      onRelease: function () {
        const dist = Math.sqrt(this.x * this.x + this.y * this.y);
        const THRESHOLD = 60;

        if (dist > THRESHOLD) {
          isAnimating.current = true;
          const lastIndex = items.length - 1;
          const targetStyle = getCardStyle(lastIndex);
          // HACK: Multiply the drag distance to create a visual "kick" or momentum effect
          // before the card loops back to the bottom of the stack.
          const kickX = this.x * 1.5;
          const kickY = this.y * 1.5;

          const timeline = gsap.timeline({
            onComplete: () => {
              const newItems = [...items];
              const movedItem = newItems.shift();
              if (movedItem) newItems.push(movedItem);
              setItems(newItems);
              if (onReorder) onReorder(newItems);
              isAnimating.current = false;
            },
          });

          // Choreography: Throw card out -> Move to back (z-index) -> Slide back into stack
          timeline
            .to(this.target, {
              x: kickX,
              y: kickY,
              scale: 0.8,
              duration: 0.2,
              ease: 'power1.out',
            })
            .set(this.target, { zIndex: 0 })
            .to(this.target, {
              x: 0,
              y: 0,
              rotation: targetStyle.rotation,
              scale: targetStyle.scale,
              duration: 0.5,
              ease: 'back.out(1.2)',
            });

          items.forEach((_, i) => {
            if (i === 0) return;
            const el = cardRefs.current[i];
            const nextStyle = getCardStyle(i - 1);
            timeline.to(
              el,
              {
                rotation: nextStyle.rotation,
                scale: nextStyle.scale,
                duration: 0.5,
                ease: 'power2.out',
              },
              0.15
            );
          });
        } else {
          gsap.to(this.target, {
            x: 0,
            y: 0,
            duration: 0.4,
            ease: 'back.out(1.5)',
          });
        }
      },
    })[0];

    return () => {
      draggable.kill();
    };
  }, [items, rotateFactor, scaleFactor, pivot]);

  return (
    <div
      className={cn(
        'relative flex items-center justify-center',
        'aspect-[3/4] w-64',
        className
      )}
    >
      {items.map((item, index) => {
        return (
          <div
            key={JSON.stringify(item)}
            ref={(el) => {
              cardRefs.current[index] = el;
            }}
            className={cn(
              'absolute inset-0 flex items-center justify-center',
              'bg-card text-card-foreground border border-border shadow-xl',
              'rounded-2xl',
              // HACK: Prevent FOUC (Flash of Unstyled Content) by starting opacity-0.
              // GSAP handles the fade-in during the initial entrance animation.
              'opacity-0',
              index === 0
                ? 'cursor-grab active:cursor-grabbing'
                : 'pointer-events-none'
            )}
            style={{
              zIndex: items.length - index,
            }}
          >
            <div className='h-full w-full overflow-hidden rounded-2xl select-none pointer-events-none'>
              {renderItem(item, index)}
            </div>
          </div>
        );
      })}
    </div>
  );
}

Usage

The FannedCardStack displays a list of items as a deck of cards. Below is an example of a simple colorful deck.

import { FannedCardStack } from '@/components/ui/fanned-card-stack';

const CARDS = [
  { id: 1, text: 'Ace of Spades', color: 'bg-neutral-900' },
  { id: 2, text: 'Queen of Hearts', color: 'bg-red-600' },
  { id: 3, text: 'King of Diamonds', color: 'bg-blue-600' },
];

export function CardStackDemo() {
  return (
    <div className='flex h-[500px] w-full items-center justify-center bg-gray-50'>
      <FannedCardStack
        items={CARDS}
        renderItem={(item) => (
          <div
            className={`flex h-full w-full items-center justify-center ${item.color} text-white`}
          >
            <span className='text-xl font-bold'>{item.text}</span>
          </div>
        )}
      />
    </div>
  );
}

Props

PropTypeDefaultDescription
itemsT[]RequiredThe array of data items to render in the stack.
renderItem(item: T, index: number) => React.ReactNodeRequiredFunction to render the content of each card.
rotateFactornumber4Rotation degree difference between stacked cards.
scaleFactornumber0.05Scale reduction difference between stacked cards.
pivot{ x: number; y: number }{ x: 50, y: 100 }The transform origin point (percentage) for rotation.
onReorder(newItems: T[]) => void-Callback fired when the stack order changes after a swipe.
classNamestring-Optional class names for the container.