reactanimationmotion

Building the Polaroid Stack on My Homepage

How the draggable-feeling photo stack on my homepage works: stacking cards with a depth offset, a two-phase animation state machine, and Motion for React handling the fling.


Ariesta Putra
Ariesta Putra
15 Agustus 2026Diperbarui 2 September 2026
3 min read

If you've landed on my homepage, you've probably clicked the little photo stack next to my name. Click the front card and it flings off to a corner, the stack reshuffles underneath it, and the card lands back at the bottom with a fresh little tilt. This post covers how it's actually built: the real component, not a simplified version.

The data model

Each card is just a photo, a caption, and a base rotation:

type PolaroidCard = {
  src: string;
  caption: string;
  rotation: number;
};

const cards: PolaroidCard[] = [
  { src: '/images/me2.jpeg', caption: 'ITS Tower II', rotation: -6 },
  { src: '/images/s1.jpg', caption: 'Graduated S.Kom.', rotation: 8 },
  {
    src: '/images/bayucaraka.jpeg',
    caption: 'Bayucaraka UAV Reserach Team',
    rotation: -3,
  },
  { src: '/images/s2.jpeg', caption: 'Graduated M.Kom.', rotation: 5 },
];

cards never reorders. It's the source of truth for each card's photo and caption. What changes over time is a separate order array (which index is currently on top) and a rotations array (each card's current tilt, since a card gets a fresh random rotation every time it cycles to the back).

Stacking with a depth offset

The resting position of a card is just its stack position multiplied by a small pixel step, applied to both x and y:

const STACK_DEPTH_STEP_PX = 8;

const stackPos = order.indexOf(i);
const resting = {
  x: stackPos * STACK_DEPTH_STEP_PX,
  y: stackPos * STACK_DEPTH_STEP_PX,
  rotate: rotation,
  scale: 1,
};

Card at the front (stackPos === 0) sits at (0, 0). Every card behind it nudges 8px further down and to the right, which reads as a physical pile rather than a flat overlap.

Two-phase state machine

Clicking the front card flips its phase between start and end:

type Step = 'start' | 'end';

function handleClick(cardIndex: number) {
  if (phases[cardIndex]) return;
  if (frontIndex !== cardIndex) return;
  nextDirection.current = nextDirection.current === 'left' ? 'right' : 'left';
  setDirections((prev) => ({ ...prev, [cardIndex]: nextDirection.current }));
  setPhases((prev) => ({ ...prev, [cardIndex]: 'start' }));
}

start flies the card out to a corner. Once that animation finishes, the card needs to move to the back of the order array and get a new random rotation, then animate straight back into its new resting spot:

function handleAnimationComplete(cardIndex: number) {
  const step = phases[cardIndex];
  if (!step) return;

  if (step === 'start') {
    // corner reached - send it to the back of the stack, reroll its
    // tilt, and continue straight into the return leg. order/zIndex
    // update in the same tick as the phase change, so it drops behind
    // the front card immediately as it flies back in.
    setOrder((prev) => [...prev.filter((i) => i !== cardIndex), cardIndex]);
    setRotations((prev) =>
      prev.map((r, i) => (i === cardIndex ? randomRotation() : r)),
    );
    setPhases((prev) => ({ ...prev, [cardIndex]: 'end' }));
  } else if (step === 'end') {
    setPhases((prev) => {
      const next = { ...prev };
      delete next[cardIndex];
      return next;
    });
  }
}

The order update and the rotation reroll both happen in the same tick as the phase flip to end, so the card drops to its resting depth immediately. It visibly slides back underneath the front card as it returns, instead of staying on top for the whole flight back.

Alternating the fling direction

Instead of every card flying off in the same direction, a nextDirection ref flips between 'left' and 'right' on every click, so consecutive flings alternate corners rather than repeating the same one:

const target =
  isAnimating && step !== 'end'
    ? {
        x: direction === 'left' ? -CORNER_TRANSLATE_PX : CORNER_TRANSLATE_PX,
        y: -CORNER_TRANSLATE_PX,
        rotate: rotation + CORNER_ROTATE_KICK_DEG,
        scale: 0.9,
      }
    : {
        x: stackPos * STACK_DEPTH_STEP_PX,
        y: stackPos * STACK_DEPTH_STEP_PX,
        rotate: rotation,
        scale: 1,
      };

Handing it to Motion for React

All of the above just computes a target {x, y, rotate, scale} object per card, per render. The actual tweening is Motion's job. The component just hands it the current animate target and lets the library interpolate:

<motion.button
  key={card.src}
  type='button'
  tabIndex={isFront ? 0 : -1}
  aria-label={isFront ? `Move photo: ${card.caption}` : undefined}
  animate={target}
  transition={{ duration, ease }}
  style={{ zIndex }}
  onClick={() => handleClick(i)}
  onAnimationComplete={() => handleAnimationComplete(i)}
  whileHover={isFront ? { scale: 1.05 } : undefined}
>
  {/* pin icon, photo, caption */}
</motion.button>

onAnimationComplete does real work here. It's the signal that drives the phase transitions above, not just a decorative callback. Duration and easing also flip depending on phase: flying out uses easeOut at 0.3s, settling back in uses easeIn at 0.2s, so the return leg feels slightly snappier than the departure.

What I'd change

The whole thing is one component with four useState calls tracking overlapping concerns (order, rotations, phases, directions). It works, but if I added a fifth interaction (say, dragging instead of just clicking), I'd probably reach for useReducer to keep the phase transitions in one place instead of four separate state updaters that all have to stay in sync by hand.