Wheels

Pinned Card Fan

A layout component that pins to the viewport and fans its children out in an arc as the user scrolls. Falls back to a vertical stack on mobile or if reduced motion is enabled.

The Royal Flush

A classic demonstration of the fanning mechanics using a deck of cards. This example highlights how rotationStrength and arcStrength combine to create a natural, hand-held feel while pinning the section for a dramatic reveal.

The Royal Flush

Scroll down to reveal the hand. This demo uses standard rotation and arc strength for a classic fanning effect.

  • A
    A
  • K
    K
  • Q
    Q
  • J
    J
  • 10
    10

Round Complete

Installation

npx shadcn@latest add https://satisui.xyz/r/pinned-card-fan.json

Manual

npm install gsap @gsap/react clsx tailwind-merge
# or
yarn add gsap @gsap/react clsx tailwind-merge
# or
pnpm add gsap @gsap/react clsx tailwind-merge
'use client';

import React, {
  ReactNode,
  useRef,
  useMemo,
  forwardRef,
  Ref,
  HTMLAttributes,
} from 'react';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { useGSAP } from '@gsap/react';
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';

function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

function useMergeRefs<T>(...refs: (Ref<T> | undefined)[]) {
  return useMemo(() => {
    if (refs.every((ref) => ref == null)) return null;
    return (node: T) => {
      refs.forEach((ref) => {
        if (typeof ref === 'function') {
          ref(node);
        } else if (ref != null) {
          (ref as React.MutableRefObject<T | null>).current = node;
        }
      });
    };
  }, [refs]);
}

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

export interface PinnedCardFanProps extends HTMLAttributes<HTMLElement> {
  children: ReactNode;
  /** Total scroll distance (in pixels) to lock the section while animating. */
  pinHeight?: number;
  /** Initial vertical offset (in pixels). Positive = cards start lower. */
  entryOffset?: number;
  /** Initial scale (0 to 1). */
  entryScale?: number;
  /** Initial rotation (degrees). */
  entryRotation?: number;
  /** Initial opacity (0 to 1). */
  entryOpacity?: number;
  /** Strength of the final vertical arc. */
  arcStrength?: number;
  /** Strength of the final rotation spread. */
  rotationStrength?: number;
  /**
   * Delay between cards relative to the animation duration (1.0).
   * - 0.0 = All at once
   * - 0.5 = 50% overlap (Default)
   * - 1.0 = Sequential (One finishes, next starts)
   */
  stagger?: number;
  /**
   * Smooths the scroll linkage.
   * - 0 (or false) = Instant (tightest stagger control).
   * - 1 = 1 second lag (smoother but can blur the stagger effect).
   */
  scrubOffset?: number | boolean;
  /** Tailwind class for negative margin overlap (e.g., '-ms-24'). */
  cardOverlap?: string;
  /** ScrollTrigger start position (e.g., 'top top'). */
  triggerStart?: string;
}

const PinnedCardFan = forwardRef<HTMLElement, PinnedCardFanProps>(
  (
    {
      children,
      pinHeight = 2500,
      entryOffset = 400,
      entryScale = 0.8,
      entryRotation = 0,
      entryOpacity = 0,
      arcStrength = 40,
      rotationStrength = 15,
      stagger = 0.5,
      scrubOffset = 0,
      cardOverlap = '-ms-24',
      triggerStart = 'top top',
      className,
      ...rest
    },
    ref
  ) => {
    const internalRef = useRef<HTMLElement>(null);
    const mergedRef = useMergeRefs(ref, internalRef);

    const childrenArray = React.Children.toArray(children);
    const numChildren = childrenArray.length;

    useGSAP(
      () => {
        if (!internalRef.current || numChildren === 0) return;

        const prefersReducedMotion = window.matchMedia(
          '(prefers-reduced-motion: reduce)'
        ).matches;

        if (prefersReducedMotion) return;

        const mm = gsap.matchMedia();

        mm.add('(min-width: 768px)', () => {
          const cards = gsap.utils.toArray<HTMLElement>(
            '.card-wrapper',
            internalRef.current
          );
          if (cards.length === 0) return;

          const tl = gsap.timeline({
            scrollTrigger: {
              trigger: internalRef.current,
              pin: true,
              start: triggerStart,
              end: `+=${pinHeight}`,
              scrub: scrubOffset,
            },
          });

          tl.fromTo(
            cards,
            {
              y: entryOffset,
              scale: entryScale,
              rotation: entryRotation,
              autoAlpha: entryOpacity,
            },
            {
              y: (index) => {
                const half = (numChildren - 1) / 2;
                const distanceFromCenter = Math.abs(index - half);
                const normalizedVertical =
                  half > 0 ? distanceFromCenter / half : 0;
                return normalizedVertical * normalizedVertical * arcStrength;
              },
              rotation: (index) => {
                const half = (numChildren - 1) / 2;
                const rawHorizontal = index - half;
                const normalizedHorizontal =
                  half > 0 ? rawHorizontal / half : 0;
                return normalizedHorizontal * rotationStrength;
              },
              scale: 1,
              autoAlpha: 1,
              duration: 1,
              ease: 'power4.inOut',
              stagger: stagger,
            }
          );

          const buffer = stagger * cards.length;
          tl.to({}, { duration: buffer });
        });

        return () => mm.revert();
      },
      {
        scope: internalRef,
        dependencies: [
          pinHeight,
          entryOffset,
          entryScale,
          entryRotation,
          numChildren,
          arcStrength,
          rotationStrength,
          stagger,
          scrubOffset,
          triggerStart,
        ],
      }
    );

    if (numChildren === 0) return null;

    return (
      <section
        ref={mergedRef}
        className={cn('relative w-full isolate', className)}
        {...rest}
      >
        <ul className='flex flex-col md:flex-row items-center justify-center w-full min-h-[400px] md:min-h-screen py-20 md:py-0 overflow-hidden m-0 p-0 list-none'>
          {childrenArray.map((child, index) => {
            return (
              <li
                key={index}
                style={{ zIndex: index }}
                className={cn(
                  'card-wrapper will-change-transform',
                  'mb-4 md:mb-0',
                  `md:${cardOverlap}`,
                  'hover:z-50 md:hover:scale-110 md:hover:-translate-y-10 transition-all duration-500 ease-out'
                )}
              >
                {child}
              </li>
            );
          })}
        </ul>
      </section>
    );
  }
);

PinnedCardFan.displayName = 'PinnedCardFan';

export default PinnedCardFan;

Usage

Use PinnedCardFan to create a scroll-locked reveal sequence. The cards are pinned in place and fan out based on the user's scroll position.

import PinnedCardFan from '@/components/pinned-card-fan';

const items = [
  { title: 'Step 1', color: 'bg-red-500' },
  { title: 'Step 2', color: 'bg-blue-500' },
  { title: 'Step 3', color: 'bg-green-500' },
  { title: 'Step 4', color: 'bg-yellow-500' },
  { title: 'Step 5', color: 'bg-purple-500' },
];

export default function Demo() {
  return (
    <div className='w-full'>
      <div className='h-screen flex items-center justify-center bg-neutral-100'>
        <h2 className='text-2xl font-bold'>Scroll Down</h2>
      </div>

      <PinnedCardFan
        pinHeight={2000}
        entryOffset={400}
        stagger={0.5} // 50% overlap between animations
        scrubOffset={0} // Instant response for precise control
        rotationStrength={10}
      >
        {items.map((item, index) => (
          <div
            key={index}
            className={`w-64 h-96 rounded-2xl shadow-xl border-4 border-white ${item.color} flex items-center justify-center`}
          >
            <span className='text-xl font-bold text-white'>{item.title}</span>
          </div>
        ))}
      </PinnedCardFan>

      <div className='h-screen flex items-center justify-center bg-neutral-100'>
        <h2 className='text-2xl font-bold'>End of Section</h2>
      </div>
    </div>
  );
}

Props

PropTypeDefaultDescription
childrenReactNodeRequiredThe content elements to be fanned out.
pinHeightnumber2500Total scroll distance (in pixels) to lock the section while animating.
entryOffsetnumber400Initial vertical offset (in pixels). Positive = cards start lower.
entryScalenumber0.8Initial scale (0 to 1).
entryRotationnumber0Initial rotation (degrees).
entryOpacitynumber0Initial opacity (0 to 1).
arcStrengthnumber40Strength of the final vertical arc.
rotationStrengthnumber15Strength of the final rotation spread.
staggernumber0.5Delay between cards relative to the animation duration. 0.0 = All at once, 1.0 = Sequential.
scrubOffsetnumber0Smooths the scroll linkage. 0 = Instant, 1 = 1 second lag.
cardOverlapstring'-ms-24'Tailwind class for negative margin overlap (e.g., '-ms-24').
triggerStartstring'top top'ScrollTrigger start position (e.g., 'top top').