Building the Photo Stack
The Photo Stack is a profile card with a deck of photos hiding behind the avatar. Hover the avatar and the deck fans out. Click it and the deck is promoted into a row above the card while the avatar shrinks and the labels reflow beside it.
John Doe
Cool dude
Hover the avatar to fan the stack, click it to expand, then hover a photo to scale it.
It looks like one continuous movement. It is actually two completely different layouts, and most of the work went into never having to describe the path between them.
HTML layout to start animation from
The whole animation starts from this html layout which is crucial for the animation to work. The simpler the layout is, the easier it is to animate between it and the next layout.
John Doe
Cool dude
HTML layout to start animation from
PhotoStack/index.tsx
'use client'
import { demoData } from '@/data/demo'
import Image from 'next/image';
import { useState } from 'react';
function PhotoStack() {
const [expanded, setExpanded] = useState(false);
const [fanned, setFanned] = useState(false);
const [scaledImageIndex, setScaledImageIndex] = useState<number | null>(null);
return (
<div className="flex items-center justify-center font-sans">
<div
data-state={expanded ? "expanded" : "collapsed"}
className={`flex h-52 w-78 flex-col justify-end ${expanded ? "gap-6" : "gap-0"}`}
>
<div
aria-hidden={!expanded}
onMouseLeave={() => setScaledImageIndex(null)}
className={
expanded
? "order-2 z-1 flex h-18 w-full items-center justify-center gap-2"
: "order-1 relative z-1 h-18 w-full"
}
>
{demoData.slice(1, 5).map(({ imagePath, alt }, i) => (
<div
key={i}
style={{
zIndex: 4 - i,
}}
onMouseEnter={() => setScaledImageIndex(i)}
className={
expanded
? "relative size-18 overflow-hidden rounded-lg"
: "pointer-events-none absolute left-0 top-18 size-16 overflow-hidden rounded-lg"
}
>
<Image
src={imagePath}
alt={alt}
width={144}
height={144}
draggable={false}
className="size-full object-cover rounded-lg"
/>
</div>
))}
</div>
<div
className={
expanded
? "order-1 z-5 flex flex-row items-center gap-3"
: "order-2 z-5 flex flex-col items-start gap-5"
}
>
<button
aria-expanded={expanded}
aria-label={expanded ? "Stack photos" : "Unfold photos"}
onClick={() => {
setExpanded((v) => !v);
setFanned(false);
setScaledImageIndex(null);
}}
onMouseEnter={() => !expanded && setFanned(true)}
onMouseLeave={() => setFanned(false)}
onFocus={() => !expanded && setFanned(true)}
onBlur={() => setFanned(false)}
className={`relative shrink-0 cursor-pointer overflow-hidden rounded-lg ${expanded ? "size-12" : "size-16"}`}
>
<Image
src={demoData[0].imagePath}
alt={demoData[0].alt}
width={144}
height={144}
priority
draggable={false}
className="size-full object-cover rounded-lg"
/>
</button>
<div
className="flex flex-col gap-1"
>
<p className="text-base font-medium text-zinc-900 dark:text-white">John Doe</p>
<p
className="text-sm text-zinc-600 dark:text-zinc-400"
key={expanded ? "expanded" : "collapsed"}
>
Cool dude
</p>
</div>
</div>
</div>
</div>
)
}
export default PhotoStack
Building the animation in four steps
Each demo below is a real, standalone component. The markup and the state are identical in all of them; only the motion changes from one step to the next.
Step 1 - the layout animation
John Doe
Cool dude
Step 1 - layout on every moving box: the two arrangements now morph instead of snapping
layout goes on every box that moves: the photo row, each photo, the avatar button and the label block. Motion measures each one and animates it to the new position.
const TRANSITION = { type: 'spring', bounce: 0.22, duration: 0.32 } as const
<motion.div layout transition={TRANSITION} className={expanded ? '…' : '…'}>
Step 2 - the fan
John Doe
Cool dude
Step 2 - the deck rests at an angle and fans out while the avatar is hovered or focused
The resting angles and the fanned offsets come from a POSITIONS lookup table. They live on an inner element, because the box carrying layout has its transform written by the projection system.
<motion.div layout className={expanded ? 'relative size-18' : 'absolute … size-16'}>
<motion.div
initial={false}
animate={expanded ? { x: 0, y: 0, rotate: 0 } : fanned ? POSITIONS[i].fanned : POSITIONS[i].collapsed}
transition={TRANSITION}
className="size-full overflow-hidden rounded-lg"
>
Step 3 - the hover scale
John Doe
Cool dude
Step 3 - hovering a photo in the row scales it to 1.2 and nudges its neighbours aside
In the expanded row, the hovered photo scales to 1.2 and its neighbours step aside - a sign test rather than a distance falloff.
x: scaledImageIndex === null || scaledImageIndex === i
? 0
: i < scaledImageIndex ? -10 : 10,
scale: scaledImageIndex === i ? 1.2 : 1,
Step 4 - the labels
John Doe
Cool dude
Step 4 - the labels blur in and the subtitle replays its slide whenever the card changes shape
The subtitle is re-keyed on expanded, so it remounts and replays its blur-and-slide entrance every time the card changes shape. The delay lets the layout settle before the text moves.
<motion.p
key={expanded ? 'expanded' : 'collapsed'}
initial={{ opacity: 0, x: -8, filter: 'blur(4px)' }}
animate={{ opacity: 1, x: 0, filter: 'blur(0px)' }}
transition={{...TRANSITION, delay: 0.17}}
>



