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.

