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.
Installation
CLI (Recommended)
npx shadcn@latest add https://satisui.xyz/r/pinned-card-fan.jsonManual
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
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | Required | The content elements to be fanned out. |
pinHeight | number | 2500 | Total scroll distance (in pixels) to lock the section while animating. |
entryOffset | number | 400 | Initial vertical offset (in pixels). Positive = cards start lower. |
entryScale | number | 0.8 | Initial scale (0 to 1). |
entryRotation | number | 0 | Initial rotation (degrees). |
entryOpacity | number | 0 | Initial opacity (0 to 1). |
arcStrength | number | 40 | Strength of the final vertical arc. |
rotationStrength | number | 15 | Strength of the final rotation spread. |
stagger | number | 0.5 | Delay between cards relative to the animation duration. 0.0 = All at once, 1.0 = Sequential. |
scrubOffset | number | 0 | Smooths the scroll linkage. 0 = Instant, 1 = 1 second lag. |
cardOverlap | string | '-ms-24' | Tailwind class for negative margin overlap (e.g., '-ms-24'). |
triggerStart | string | 'top top' | ScrollTrigger start position (e.g., 'top top'). |