<section class="demo">
<div class="demo__stage" id="stage">
<div class="demo__scene" id="scene">
<article class="demo__card" data-tone="a"><span>01</span><h3>Discovery</h3></article>
<article class="demo__card" data-tone="b"><span>02</span><h3>Direction</h3></article>
<article class="demo__card" data-tone="c"><span>03</span><h3>Craft</h3></article>
<article class="demo__card" data-tone="d"><span>04</span><h3>Delivery</h3></article>
</div>
<p class="demo__hint" id="hint">01 / 04</p>
</div>
</section>.demo {
background: #08080a;
color: #f4f4f2;
font-family: "Helvetica Neue", Inter, system-ui, sans-serif;
}
/* .demo 높이가 곧 스크롤 길이입니다 (카드 4장 + 여유) */
.demo {
height: 500vh;
}
/* position:sticky를 쓰면 ScrollTrigger의 pin 없이도 고정됩니다.
스크롤이 없는 환경(카드 프리뷰)에서도 레이아웃이 그대로 유지됩니다. */
.demo__stage {
position: sticky;
top: 0;
height: 100dvh;
display: grid;
place-items: center;
overflow: hidden;
}
.demo__scene {
position: relative;
width: min(300px, 76vw);
height: 200px;
transform-style: preserve-3d;
perspective: 900px;
}
.demo__card {
position: absolute;
inset: 0;
border-radius: 18px;
padding: 22px;
display: flex;
flex-direction: column;
justify-content: space-between;
border: 1px solid rgba(255, 255, 255, 0.09);
transform-style: preserve-3d;
will-change: transform, opacity;
}
.demo__card span {
font-size: 11px;
letter-spacing: 0.2em;
color: rgba(244, 244, 242, 0.5);
}
.demo__card h3 {
margin: 0;
font-size: 26px;
font-weight: 500;
letter-spacing: -0.03em;
}
.demo__card[data-tone="a"] { background: linear-gradient(155deg, #2f3550, #14161f); }
.demo__card[data-tone="b"] { background: linear-gradient(155deg, #1b3350, #0c1420); }
.demo__card[data-tone="c"] { background: linear-gradient(155deg, #3a3123, #17130d); }
.demo__card[data-tone="d"] { background: linear-gradient(155deg, #4a2422, #1d0f0e); }
.demo__hint {
position: absolute;
bottom: 28px;
margin: 0;
font-size: 11px;
letter-spacing: 0.2em;
color: #55555e;
font-variant-numeric: tabular-nums;
}gsap.registerPlugin(ScrollTrigger);
const cards = gsap.utils.toArray(".demo__card");
const hint = document.querySelector("#hint");
const GAP = 150; // 카드 사이의 Z 간격(px)
const PEEK = 34; // 뒤 카드가 위로 삐져나오는 양(px)
// progress(0~1)를 카드 인덱스로 환산해, 각 카드의 Z 위치를 직접 계산합니다.
// 개별 트윈을 쌓지 않으므로 카드 수가 늘어도 로직이 그대로입니다.
function render(progress) {
const head = progress * (cards.length - 1);
cards.forEach((card, index) => {
const offset = index - head;
const z = -offset * GAP;
// 앞으로 빠져나간 카드는 카메라를 지나쳐 사라집니다.
const passed = offset < 0;
const fade = passed ? 1 + offset * 1.6 : 1 - Math.max(0, offset - 2.2) * 1.4;
gsap.set(card, {
z: passed ? z * 2.4 : z,
y: passed ? offset * 40 : -offset * PEEK,
rotateX: passed ? offset * 8 : 0,
opacity: gsap.utils.clamp(0, 1, fade),
zIndex: cards.length - index,
});
});
const current = Math.min(cards.length, Math.round(head) + 1);
hint.textContent = String(current).padStart(2, "0") + " / 0" + cards.length;
}
// 고정은 CSS sticky가 담당하므로 ScrollTrigger는 진행도만 읽습니다.
ScrollTrigger.create({
trigger: ".demo",
start: "top top",
end: "bottom bottom",
scrub: 0.6,
invalidateOnRefresh: true,
onUpdate: (self) => render(self.progress),
});
render(0);