Photo Stack Component
A profile card with a deck of photos tucked behind the avatar. Hovering the avatar fans the deck out; clicking it promotes the deck into a row above the card, where each photo scales up on hover and nudges its neighbours aside.
John Doe
Cool dude
Hover the avatar to fan the stack, click it to expand, then hover a photo to scale it.
Everything is driven by three pieces of state and Motion's layout prop - there are no manual measurements and no absolute pixel choreography between the two arrangements.
Installation
The component needs motion and Next.js' built-in Image:
npm install motion
Copy these files into your project:
src/components/Examples/PhotoStack/index.tsx- the componentsrc/components/Examples/PhotoStack/PhotoStackExample.tsx- the demo wrappersrc/data/demo.ts- the sample photo list
Then drop five square images into public/. The demo points at /portfolioHeader/photo1.webp through photo5.webp.
The first photo in the array becomes the avatar/toggle. The next four fan out
behind it - that count is fixed by the POSITIONS table, so extra entries are
ignored.
Usage
import PhotoStack from '@/components/Examples/PhotoStack'
import { demoData } from '@/data/demo'
<PhotoStack photos={demoData} name="John Doe" title="Cool dude" />
The photo list is a plain array, so it can come from a CMS, the filesystem, or props:
export const demoData = [
{ imagePath: '/portfolioHeader/photo1.webp', alt: 'Photo 1' },
{ imagePath: '/portfolioHeader/photo2.webp', alt: 'Photo 2' },
{ imagePath: '/portfolioHeader/photo3.webp', alt: 'Photo 3' },
{ imagePath: '/portfolioHeader/photo4.webp', alt: 'Photo 4' },
{ imagePath: '/portfolioHeader/photo5.webp', alt: 'Photo 5' },
]
Props
- Name
photos- Type
- Array<{ imagePath: string; alt: string }>
- Description
The photos to render. Index
0is the avatar/toggle; indices1–4make up the stack.
- Name
name- Type
- string
- Description
The primary label rendered next to the avatar.
- Name
title- Type
- string
- Description
The secondary label. It re-animates whenever the card expands or collapses.
- Name
className- Type
- string
- Description
Extra classes for the outer wrapper. Useful for padding the demo surface.
How the animation works
The whole interaction is three booleans and an index:
- Name
expanded- Type
- boolean
- Description
Toggled by clicking the avatar. Switches the layout between the stacked card and the expanded row by swapping
orderand flex classes.
- Name
fanned- Type
- boolean
- Description
Set while the avatar is hovered or focused and the card is collapsed. Moves each photo from its
collapsedtransform to itsfannedone.
- Name
scaledImageIndex- Type
- number | null
- Description
The photo currently hovered in the expanded row. That photo scales to
1.2; the ones before it shift-10pxand the ones after it+10px.
The two arrangements never animate between each other by hand. Each photo sits in a box carrying layout, so when the class names flip from absolute … top-18 to a flex child, Motion measures both positions and interpolates the difference itself. The shared spring - { type: 'spring', bounce: 0.22, duration: 0.32 } - is what makes the avatar resize, the labels reflow and the photos travel as one movement rather than four.
The fan and hover transforms live on an inner element, not on the box that
carries layout. Motion's layout projection writes transform on the element
it animates, so x, rotate and scale set in animate on that same element
get overridden - rotate in particular also corrupts the layout measurements.
Splitting them across two nested elements is the supported way to combine the
two.
The resting and fanned transforms live in a lookup table so the deck looks hand-placed rather than evenly rotated:
const POSITIONS = [
{ collapsed: { x: 0, y: 0, rotate: 12 }, fanned: { x: 34, y: -10, rotate: 20 } },
{ collapsed: { x: 0, y: 0, rotate: -12 }, fanned: { x: -32, y: -11, rotate: -18 } },
{ collapsed: { x: 0, y: 0, rotate: 22 }, fanned: { x: 28, y: -47, rotate: 25 } },
{ collapsed: { x: 0, y: 0, rotate: -22 }, fanned: { x: -26, y: -42, rotate: -13 } },
]
initial={false} on each photo matters: without it the deck would animate in from its collapsed transform on first paint, which reads as a glitch on page load.
Component Source
PhotoStack/index.tsx
'use client'
import { motion } from 'motion/react'
import Image from 'next/image'
import { useState } from 'react'
export interface PhotoStackPhoto {
imagePath: string
alt: string
}
export interface PhotoStackProps {
/** The first photo becomes the avatar/toggle, the next four fan out behind it. */
photos: PhotoStackPhoto[]
name: string
title: string
className?: string
}
const POSITIONS = [
{
collapsed: { x: 0, y: 0, rotate: 12 },
fanned: { x: 34, y: -10, rotate: 20 },
},
{
collapsed: { x: 0, y: 0, rotate: -12 },
fanned: { x: -32, y: -11, rotate: -18 },
},
{
collapsed: { x: 0, y: 0, rotate: 22 },
fanned: { x: 28, y: -47, rotate: 25 },
},
{
collapsed: { x: 0, y: 0, rotate: -22 },
fanned: { x: -26, y: -42, rotate: -13 },
},
]
const TRANSITION = { type: 'spring', bounce: 0.22, duration: 0.32 } as const
export default function PhotoStack({
photos,
name,
title,
className = '',
}: PhotoStackProps) {
const [expanded, setExpanded] = useState(false)
const [fanned, setFanned] = useState(false)
const [scaledImageIndex, setScaledImageIndex] = useState<number | null>(null)
const avatar = photos[0]
const stack = photos.slice(1, 1 + POSITIONS.length)
if (!avatar) return null
return (
<div
className={`flex items-center justify-center font-sans ${className}`}
>
<div
data-state={expanded ? 'expanded' : 'collapsed'}
className={`flex h-52 w-78 flex-col justify-end ${expanded ? 'gap-6' : 'gap-0'}`}
>
<motion.div
layout
aria-hidden={!expanded}
transition={TRANSITION}
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'
}
>
{stack.map(({ imagePath, alt }, i) => (
// `layout` owns `transform` on the element it sits on, so the
// fan/scale transforms live on an inner element. Animating both
// on one node makes Motion's projection discard the transforms.
<motion.div
layout
key={imagePath}
style={{
zIndex: photos.length - i,
}}
transition={TRANSITION}
onMouseEnter={() => setScaledImageIndex(i)}
className={
expanded
? 'relative size-18'
: 'pointer-events-none absolute left-0 top-18 size-16'
}
>
<motion.div
initial={false}
animate={
expanded
? {
x:
scaledImageIndex === null ||
scaledImageIndex === i
? 0
: i < scaledImageIndex
? -10
: 10,
rotate: 0,
scale:
scaledImageIndex === i ? 1.2 : 1,
}
: {
...(fanned
? POSITIONS[i].fanned
: POSITIONS[i].collapsed),
}
}
transition={TRANSITION}
className="size-full overflow-hidden rounded-lg"
>
<Image
src={imagePath}
alt={alt}
width={144}
height={144}
draggable={false}
className="size-full rounded-lg object-cover"
/>
</motion.div>
</motion.div>
))}
</motion.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'
}
>
<motion.button
layout
type="button"
transition={TRANSITION}
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={avatar.imagePath}
alt={avatar.alt}
width={144}
height={144}
priority
draggable={false}
className="size-full rounded-lg object-cover"
/>
</motion.button>
<motion.div
layout
initial={{ opacity: 0, filter: 'blur(4px)' }}
animate={{ opacity: 1, filter: 'blur(0px)' }}
transition={TRANSITION}
className="flex flex-col gap-1"
>
<p className="text-base font-medium text-zinc-900 dark:text-white">
{name}
</p>
<motion.p
key={expanded ? 'expanded' : 'collapsed'}
initial={{ opacity: 0, x: -8, filter: 'blur(4px)' }}
animate={{ opacity: 1, x: 0, filter: 'blur(0px)' }}
transition={{
type: 'spring',
bounce: 0.22,
duration: 0.22,
delay: 0.17,
}}
className="text-sm text-zinc-600 dark:text-zinc-400"
>
{title}
</motion.p>
</motion.div>
</div>
</div>
</div>
)
}
Accessibility
- The avatar is a real
<button>witharia-expandedand a label that flips between "Unfold photos" and "Stack photos". - Focusing the avatar with the keyboard fans the deck, the same as hovering - the effect isn't pointer-only.
- The photo container is
aria-hiddenwhile collapsed, so the decorative deck isn't announced twice. - Collapsed photos are
pointer-events-none, which keeps the avatar's hover target intact underneath them.
What's next?
- 3D Button Component - Interactive button with sound effects
- Playing Card Component - Animated card with flip transitions
- Wheel Component - Animated prize wheel



