Back to blog
Filip Skoczylas
motionreact

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}}
>

Was this page helpful?