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 component
  • src/components/Examples/PhotoStack/PhotoStackExample.tsx - the demo wrapper
  • src/data/demo.ts - the sample photo list

Then drop five square images into public/. The demo points at /portfolioHeader/photo1.webp through photo5.webp.


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 0 is the avatar/toggle; indices 14 make 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 order and 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 collapsed transform to its fanned one.

  • 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 -10px and 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 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> with aria-expanded and 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-hidden while 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?

Was this page helpful?