reactanimationmotion
Available in English

Membangun Polaroid Stack di Homepage Saya

Cara kerja tumpukan foto di homepage saya yang terasa bisa di-drag: stacking card dengan depth offset, state machine dua fase, dan Motion for React yang menangani animasi flingnya.


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

Kalau kamu mampir ke homepage saya, kemungkinan besar kamu sudah klik tumpukan foto kecil di sebelah nama saya. Klik card paling depan, dia akan terlempar ke salah satu sudut, tumpukan di bawahnya bergeser naik, lalu card tadi mendarat lagi di posisi paling bawah dengan kemiringan baru. Post ini membahas bagaimana komponen ini sebenarnya dibangun, bukan versi yang disederhanakan.

Data model

Setiap card cuma berisi foto, caption, dan rotasi dasar:

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 tidak pernah diurutkan ulang. Array ini jadi source of truth untuk foto dan caption setiap card. Yang berubah seiring waktu adalah array order (index mana yang sedang ada di paling depan) dan array rotations (kemiringan saat ini untuk tiap card, karena setiap kali sebuah card kembali ke belakang tumpukan, dia dapat rotasi acak yang baru).

Stacking dengan depth offset

Posisi diam sebuah card cuma posisinya di tumpukan dikalikan sedikit pixel step, diterapkan ke x dan y sekaligus:

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 paling depan (stackPos === 0) berada di (0, 0). Setiap card di belakangnya bergeser 8px lebih jauh ke kanan-bawah, sehingga terlihat seperti tumpukan fisik, bukan sekadar overlap datar.

State machine dua fase

Klik pada card paling depan akan membalik fasenya antara start dan 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 menerbangkan card ke salah satu sudut. Setelah animasi itu selesai, card tersebut perlu dipindah ke paling belakang array order dan mendapat rotasi acak baru, lalu langsung dianimasikan kembali ke posisi diamnya yang baru:

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

  if (step === 'start') {
    // sudah sampai sudut - kirim ke belakang stack, reroll
    // kemiringannya, lalu lanjut langsung ke leg kembali. order/zIndex
    // di-update di tick yang sama dengan perubahan phase, jadi dia
    // langsung jatuh di belakang card depan begitu terbang kembali.
    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;
    });
  }
}

Update order dan reroll rotasi sama-sama terjadi di tick yang sama dengan perubahan phase ke end, jadi card langsung turun ke depth-nya begitu balik. Card itu terlihat meluncur ke belakang card depan saat kembali, bukan tetap di atas selama seluruh perjalanan pulang.

Membolak-balik arah fling

Daripada semua card terbang ke arah yang sama, sebuah ref nextDirection membalik antara 'left' dan 'right' setiap kali diklik, jadi fling yang berurutan bergantian sudut, bukan mengulang sudut yang sama:

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

Menyerahkannya ke Motion for React

Semua hal di atas cuma menghitung target {x, y, rotate, scale} untuk setiap card, di setiap render. Tweening yang sebenarnya adalah tugas Motion. Komponennya cuma memberikan target animate saat ini dan membiarkan library yang melakukan interpolasi:

<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}
>
  {/* ikon pin, foto, caption */}
</motion.button>

onAnimationComplete benar-benar melakukan sesuatu di sini. Ini adalah sinyal yang menggerakkan transisi phase di atas, bukan sekadar callback dekoratif. Duration dan easing juga berubah tergantung phase: terbang keluar pakai easeOut selama 0.3s, kembali menetap pakai easeIn selama 0.2s, jadi perjalanan pulang terasa sedikit lebih snappy dibanding keberangkatannya.

Yang akan saya ubah

Semuanya adalah satu komponen dengan empat pemanggilan useState yang melacak concern yang saling tumpang tindih (order, rotations, phases, directions). Ini berfungsi, tapi kalau saya menambahkan interaksi kelima (misalnya drag, bukan cuma klik), saya kemungkinan akan beralih ke useReducer supaya semua transisi phase ada di satu tempat, bukan empat state updater terpisah yang harus selalu disinkronkan secara manual.