모션·인터랙션 / Cadence
Formation Loader — Stack
로딩 · 갤러리 · 이미지 모션. 미리보기와 원본 코드·프롬프트를 이곳에서 확인하세요.
미리보기 안에서 스크롤하거나 마우스를 움직여 모션을 확인하세요.
HTML · CSS · JavaScript 전체 코드
<!doctype html>
<!--
Formation Loader — Stack
A self-playing page loader in three formations. Nine photos rise from below one after
another, each tilted around its top edge, and land on a single centred pile while a
percentage counts up; each card darkens as the next one lands on it. The pile then
fans out like a hand of cards around a pivot far below them. Finally the side cards
drop away, the top card opens to a full-screen photo, and a white wordmark rises in
over it. Click to replay.
Dependencies: GSAP 3.15 (from jsDelivr).
Tweak:
- RISE_DUR 1.1s · RISE_STAGGER 0.26s · RISE_EASE power3 · RISE_ROTATION ±15°
(the tilt straightens on sine.inOut) · DIM: brightness(20%) for covered cards
- FAN_DUR 1.25s · FAN_STAGGER 0.3s (amount, from the centre) · FAN_EASE power3.inOut
· FAN_STEP_MAX 7° per card · FAN_PIVOT "50% 260%" (well below the cards)
- DROP_DUR 0.9s (power3.in) · OPEN_DUR 1.3s (power3.inOut)
- WORD_DUR 1.2s · WORD_EASE power4 · WORD_FROM yPercent 180
- PAUSE 0.35s between formations
- Cards: --card-h min(46vh, 66vw), 3:4 · shade: black at 22% over the open photo
- LOOP / HOLD: set LOOP = true to replay automatically after a 2.5s hold
Inspired by "On-Scroll Layout Formations" by Codrops (Manoela Ilic)
https://github.com/codrops/OnScrollLayoutFormations
MIT License · Copyright (c) 2009 - 2024 Codrops
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Formation Loader — Stack</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
overflow: hidden;
background: #fff;
color: #000;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
}
.fs-stage {
--card-h: min(46vh, 66vw);
--card-w: calc(var(--card-h) * 0.75); /* 3:4 cards */
position: fixed;
inset: 0;
overflow: hidden;
background: #fff;
cursor: pointer; /* click to replay */
-webkit-tap-highlight-color: transparent;
}
/* Hidden until the photos are decoded */
.fs-stage:not(.is-ready) { visibility: hidden; }
.fs-pile {
position: absolute;
inset: 0;
z-index: 1; /* keeps the cards' own z-indexes below the hero, wordmark and counter */
}
/* Every card sits in the centre; margins centre it so GSAP owns the transforms */
.fs-card {
position: absolute;
left: 50%;
top: 50%;
width: var(--card-w);
height: var(--card-h);
margin: calc(var(--card-h) / -2) 0 0 calc(var(--card-w) / -2);
overflow: hidden;
border-radius: 6px;
background: #efefef;
will-change: transform, filter;
}
.fs-card img,
.fs-hero img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
user-select: none;
-webkit-user-drag: none;
}
/* The top card's full-size twin; the script places it on the card, then opens it up */
.fs-hero {
position: absolute;
z-index: 5;
overflow: hidden;
border-radius: 6px;
visibility: hidden;
}
.fs-hero img {
position: absolute;
inset: 0;
object-position: 50% 50%; /* adjust to keep your subject in frame on portrait screens */
}
/* A light black wash so the white wordmark reads on a bright photo */
.fs-shade {
position: absolute;
inset: 0;
background: rgb(0 0 0 / 0.22);
opacity: 0;
}
.fs-word {
position: absolute;
inset: 0;
z-index: 6;
display: grid;
place-items: center;
overflow: hidden; /* masks the rise */
color: #fff;
font-size: clamp(56px, 13vw, 200px);
font-weight: 500;
line-height: 1;
letter-spacing: -0.04em;
pointer-events: none;
}
.fs-word span {
display: block;
visibility: hidden;
}
.fs-count {
position: absolute;
left: 50%;
bottom: max(6vh, 32px);
z-index: 7;
min-width: 4em;
margin-left: -2em;
text-align: center;
font-size: 12px;
font-weight: 500;
letter-spacing: 0.25em;
text-transform: uppercase;
color: #8e8e8e;
font-variant-numeric: tabular-nums;
pointer-events: none;
}
</style>
</head>
<body>
<!-- Swap the picsum.photos placeholders for your own 3:4 photos. The LAST card is the
one that opens to full screen; #hero holds a larger copy of the same photo. -->
<div class="fs-stage" id="stage" aria-label="Stack formation loader">
<div class="fs-pile" id="pile">
<div class="fs-card"><img src="https://picsum.photos/seed/cadence-1/600/800" alt="" width="600" height="800"></div>
<div class="fs-card"><img src="https://picsum.photos/seed/cadence-2/600/800" alt="" width="600" height="800"></div>
<div class="fs-card"><img src="https://picsum.photos/seed/cadence-3/600/800" alt="" width="600" height="800"></div>
<div class="fs-card"><img src="https://picsum.photos/seed/cadence-4/600/800" alt="" width="600" height="800"></div>
<div class="fs-card"><img src="https://picsum.photos/seed/cadence-5/600/800" alt="" width="600" height="800"></div>
<div class="fs-card"><img src="https://picsum.photos/seed/cadence-6/600/800" alt="" width="600" height="800"></div>
<div class="fs-card"><img src="https://picsum.photos/seed/cadence-7/600/800" alt="" width="600" height="800"></div>
<div class="fs-card"><img src="https://picsum.photos/seed/cadence-8/600/800" alt="" width="600" height="800"></div>
<div class="fs-card"><img src="https://picsum.photos/seed/cadence-9/600/800" alt="" width="600" height="800"></div>
</div>
<div class="fs-hero" id="hero">
<img src="https://picsum.photos/seed/cadence-9/1600/2133" alt="" width="1600" height="2133">
<div class="fs-shade" id="shade"></div>
</div>
<div class="fs-word" id="word" aria-hidden="true"><span>Studio</span></div>
<div class="fs-count" id="count" aria-hidden="true">0%</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
<script>
(function () {
"use strict";
/* Constants --------------------------------------------------------------- */
// 1. Rise onto the pile
const RISE_DUR = 1.1; // s
const RISE_STAGGER = 0.26; // s, so each landing reads
const RISE_EASE = "power3";
const RISE_ROTATION = 15; // degrees, ±, around the card's top edge
const DIM = "brightness(20%)"; // the cards underneath
// 2. Fan out like a hand of cards
const FAN_DUR = 1.25;
const FAN_STAGGER = 0.3; // amount, from the centre
const FAN_EASE = "power3.inOut";
const FAN_STEP_MAX = 7; // degrees per card
const FAN_PIVOT = "50% 260%"; // pivot well below the cards
// 3. Open up + wordmark
const DROP_DUR = 0.9;
const OPEN_DUR = 1.3;
const OPEN_EASE = "power3.inOut";
const WORD_DUR = 1.2;
const WORD_EASE = "power4";
const WORD_FROM = 180; // yPercent
const PAUSE = 0.35; // beat between formations
const LOOP = false; // true: replay automatically after HOLD
const HOLD = 2.5; // s on the end state before an automatic replay
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
/* Elements ---------------------------------------------------------------- */
const stage = document.getElementById("stage");
const cards = Array.from(document.querySelectorAll(".fs-card"));
const hero = document.getElementById("hero");
const shade = document.getElementById("shade");
const word = document.querySelector("#word span");
const count = document.getElementById("count");
const N = cards.length;
const MID = (N - 1) / 2;
const TOP = N - 1; // the last card to land; it opens up at the end
// Fan slot per card: the top card takes the middle, the rest fill outwards in order.
const slots = (() => {
const free = [];
for (let s = 0; s < N; s++) if (s !== MID) free.push(s);
return cards.map((_, i) => (i === TOP ? MID : free[i]));
})();
let tl = null;
let hold = null;
/* Geometry ---------------------------------------------------------------- */
function cardRect() {
const w = cards[0].offsetWidth;
const h = cards[0].offsetHeight;
return { w, h, x: (innerWidth - w) / 2, y: (innerHeight - h) / 2 };
}
// Degrees per fan step, so the outer cards stay mostly on screen on narrow viewports.
function fanStep() {
const { w, h } = cardRect();
const radius = 2.1 * h; // pivot (260% of the height) to the card centre
const reach = Math.max(innerWidth / 2 - 0.15 * w, 0);
const maxAngle = (Math.asin(Math.min(reach / radius, 1)) * 180) / Math.PI;
return Math.min(FAN_STEP_MAX, maxAngle / MID);
}
/* Reset ------------------------------------------------------------------- */
function reset() {
gsap.set(cards, { clearProps: "all" });
gsap.set(cards, { zIndex: (i) => i + 1, filter: "brightness(100%)" }); // so tweens never start from "none"
gsap.set(hero, { clearProps: "all" });
gsap.set(shade, { opacity: 0 });
gsap.set(word, { clearProps: "all" });
gsap.set(count, { clearProps: "opacity" });
count.textContent = "0%";
}
/* Timeline ---------------------------------------------------------------- */
function build() {
const vh = innerHeight;
const step = fanStep();
const rect = cardRect();
const counter = { v: 0 };
const t = gsap.timeline();
// 1. Rise: one after another from below, tilted, onto the pile.
t.fromTo(
cards,
{ y: () => vh * gsap.utils.random(1, 1.25) }, // a little scatter in depth
{ y: 0, duration: RISE_DUR, ease: RISE_EASE, stagger: RISE_STAGGER },
0
);
// The tilt straightens on a softer curve, so it still shows on screen.
t.fromTo(
cards,
{ rotation: () => gsap.utils.random(-RISE_ROTATION, RISE_ROTATION), transformOrigin: "50% 0%" },
{ rotation: 0, duration: RISE_DUR, ease: "sine.inOut", stagger: RISE_STAGGER },
0
);
// Each card darkens as the next one lands on it; the top card stays bright.
cards.slice(0, -1).forEach((card, i) => {
t.fromTo(
card,
{ filter: "brightness(100%)" },
{ filter: DIM, duration: RISE_DUR, ease: "none" },
(i + 1) * RISE_STAGGER + RISE_DUR * 0.35
);
});
// Counter: 0 → 100% over the rise.
const riseEnd = RISE_DUR + RISE_STAGGER * (N - 1);
t.to(
counter,
{
v: 100,
duration: riseEnd,
ease: "none",
onUpdate: () => (count.textContent = Math.round(counter.v) + "%"),
},
0
);
// 2. Fan: brighten, then spread around the low pivot, staggered from the centre.
const fanAt = riseEnd + PAUSE;
t.to(count, { opacity: 0, duration: 0.4, ease: "power1.out" }, fanAt);
t.set(cards, { zIndex: (i) => 100 - Math.abs(slots[i] - MID) * 2, transformOrigin: FAN_PIVOT }, fanAt);
t.to(cards, { filter: "brightness(100%)", duration: 0.8, ease: "power2.inOut" }, fanAt);
t.to(
cards,
{
rotation: (i) => (slots[i] - MID) * step,
duration: FAN_DUR,
ease: FAN_EASE,
stagger: { amount: FAN_STAGGER, from: "center" },
},
fanAt
);
// 3. Open: the side cards drop away, the top card opens to full screen.
const openAt = fanAt + FAN_DUR + FAN_STAGGER + PAUSE * 1.5;
const sides = cards.filter((_, i) => i !== TOP);
t.to(
sides,
{
y: () => vh * gsap.utils.random(1.05, 1.4),
rotation: (_, el) => (slots[cards.indexOf(el)] - MID) * step * 1.6, // keeps leaning outwards
duration: DROP_DUR,
ease: "power3.in",
stagger: { amount: 0.25, from: "edges" },
},
openAt
);
t.set(hero, { left: rect.x, top: rect.y, width: rect.w, height: rect.h, visibility: "visible" }, openAt + 0.2);
t.set(cards[TOP], { visibility: "hidden" }, openAt + 0.2);
t.to(
hero,
{ left: 0, top: 0, width: innerWidth, height: vh, borderRadius: 0, duration: OPEN_DUR, ease: OPEN_EASE },
openAt + 0.2
);
t.to(shade, { opacity: 1, duration: OPEN_DUR, ease: "power1.inOut" }, openAt + 0.2);
// 4. The wordmark rises in, masked.
t.fromTo(
word,
{ yPercent: WORD_FROM, autoAlpha: 0 },
{ yPercent: 0, autoAlpha: 1, duration: WORD_DUR, ease: WORD_EASE },
openAt + 0.2 + OPEN_DUR * 0.7
);
return t;
}
/* Play / loop ------------------------------------------------------------- */
function play() {
if (hold) hold.kill();
if (tl) tl.kill();
reset();
tl = build();
if (reduced) {
tl.progress(1); // reduced motion: straight to the end state
return;
}
if (LOOP) tl.eventCallback("onComplete", () => (hold = gsap.delayedCall(HOLD, play)));
}
// Wait for each photo to load, then (briefly) for it to decode.
function decode(img) {
const loaded = img.complete
? Promise.resolve()
: new Promise((done) => {
img.addEventListener("load", done, { once: true });
img.addEventListener("error", done, { once: true });
});
return loaded.then(() =>
Promise.race([img.decode ? img.decode().catch(() => {}) : null, new Promise((r) => setTimeout(r, 1500))])
);
}
Promise.all([document.fonts.ready, ...Array.from(stage.querySelectorAll("img")).map(decode)]).then(() => {
stage.classList.add("is-ready");
play();
});
stage.addEventListener("click", () => {
if (!reduced && stage.classList.contains("is-ready")) play();
});
// Rebuild on a real size change (positions depend on the viewport).
let size = innerWidth + "x" + innerHeight;
let resizeTimer;
addEventListener("resize", () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
const next = innerWidth + "x" + innerHeight;
if (next === size) return;
size = next;
if (stage.classList.contains("is-ready")) play();
}, 200);
});
})();
</script>
<script type="module" src="https://static.cloudflareinsights.com/beacon.min.js/vce701823e78c4301a35b7e07294d87301792088814092" integrity="sha512-UKNk8GbxkABxKcXYuLkO16EleLGUkcvJWtpSNmExE0Too3ZaFAxTscNWEc1OdAv5/BikHawapSCaiD2XCmp+Bw==" data-cf-beacon='{"version":"2024.11.0","token":"c54fa41b83884ade97f239ecb41aee46","r":1,"spa":2}' crossorigin="anonymous"></script>
</body>
</html>
원본 구현 프롬프트
# Formation Loader — Stack: photos pile up, fan out like a hand of cards, and the top one opens to full screen
**Goal:** build a self-playing page loader where photos rise one by one onto a centred pile while a percentage counts up, the pile fans out like a hand of cards, and the top photo then opens to a full-screen image with the wordmark rising in over it.
## Visual behaviour
The whole sequence is one timeline on a plain white, full-screen stage. It starts once every photo has loaded and decoded.
1. **Rise onto the pile (0 – 3.18 s).** Nine 3:4 cards, all centred in the viewport and stacked in DOM order (later cards on top), start below the screen.
- Each card moves from `y = viewportHeight × random(1, 1.25)` to `y = 0` over **1.1 s**, ease `power3.out` (ease-out quart, ≈ `cubic-bezier(0.25, 1, 0.5, 1)`), each one **0.26 s** after the previous.
- At the same time each card starts tilted by a random **±15°** around its **top edge** (`transform-origin: 50% 0%`) and straightens to 0° over the same 1.1 s, but on `sine.inOut` (≈ `cubic-bezier(0.37, 0, 0.63, 1)`), so the tilt is still visible as it lands.
- Each card darkens as the next one lands on it: card `i` (all except the top one) animates `filter: brightness(100%)` → `brightness(20%)` over 1.1 s, linear, starting at `(i + 1) × 0.26 + 0.385 s`. The top card stays bright.
- A small percentage label at the bottom centre counts **0% → 100%** linearly over the whole rise (1.1 + 0.26 × 8 = 3.18 s).
2. **Fan out (from 3.53 s, after a 0.35 s pause).**
- The counter fades out (0.4 s, `power1.out`).
- All cards brighten back to `brightness(100%)` over 0.8 s, `power2.inOut`.
- Re-order the pile for the fan: the top card goes to the middle slot (4 of 0–8) and the others fill the remaining slots left to right in DOM order. Set z-index so the middle is on top and the cards behind get lower towards the edges (`100 - |slot - 4| × 2`).
- Switch `transform-origin` to **`50% 260%`** (a pivot far below the cards) and rotate each card to `(slot - 4) × step` degrees over **1.25 s**, ease `power3.inOut` (≈ `cubic-bezier(0.76, 0, 0.24, 1)`), staggered from the centre outwards with a total stagger of **0.3 s**. The cards swing out along an arc, like a hand of cards.
- `step` is **7°**, reduced on narrow screens so the outer cards stay mostly visible: with `radius = 2.1 × cardHeight` and `reach = max(viewportWidth/2 - 0.15 × cardWidth, 0)`, `step = min(7, asin(min(reach / radius, 1)) in degrees / 4)`.
3. **Open up (from about 5.6 s, after a 0.525 s pause).**
- The eight side cards drop away: `y` to `viewportHeight × random(1.05, 1.4)` and rotation to `1.6 ×` their fan angle (they keep leaning outwards), **0.9 s**, `power3.in` (≈ `cubic-bezier(0.5, 0, 0.75, 0)`), staggered from the edges inwards over 0.25 s in total.
- **0.2 s** later the top card is swapped for a full-size copy of the same photo placed exactly over it (same left, top, width, height, 6px radius). That copy animates to the full viewport (left 0, top 0, 100% × 100%, radius 0) over **1.3 s**, `power3.inOut`. A black overlay at 22% opacity fades in on it over the same 1.3 s (`power1.inOut`) so white text will read.
4. **Wordmark (at 70% of the opening).** A large white wordmark, centred, rises from `yPercent: 180` (and hidden) to 0 (visible) over **1.2 s**, ease `power4.out` (ease-out quint, ≈ `cubic-bezier(0.22, 1, 0.36, 1)`), inside a full-screen box with `overflow: hidden`, so it appears to rise out of a mask. The timeline ends at about 7.9 s.
5. **Replay** (click): kill the timeline, clear every inline style the animation set, and build a fresh timeline (new random values). Optionally loop after a 2.5 s hold on the end state.
## Exact values
| What | Value |
|---|---|
| Cards | 9, 3:4, `height: min(46vh, 66vw)`, 6px radius, centred |
| Rise | 1.1 s · `power3.out` · 0.26 s stagger · from y 100–125% of the viewport |
| Tilt | ±15° random around the top edge → 0, 1.1 s `sine.inOut` |
| Dim | covered cards to `brightness(20%)` |
| Counter | 0 → 100%, linear over the rise |
| Pause between formations | 0.35 s (0.525 s before opening) |
| Fan | 1.25 s · `power3.inOut` · 0.3 s total stagger from the centre · 7°/card max · pivot `50% 260%` |
| Drop | 0.9 s · `power3.in` · 0.25 s total stagger from the edges |
| Open | 1.3 s · `power3.inOut` · overlay black 22% |
| Wordmark | `clamp(56px, 13vw, 200px)`, weight 500, letter-spacing -0.04em, line-height 1, white · from yPercent 180 · 1.2 s · `power4.out` · starts 0.91 s into the opening |
## Markup
```html
<div class="stage"> <!-- fixed, inset 0, white, overflow hidden -->
<div class="pile"> <!-- 9 × .card, absolutely centred with negative margins -->
<div class="card"><img src="…" alt=""></div> …
</div>
<div class="hero"><img src="…full-size copy of the last card…"><div class="shade"></div></div>
<div class="word"><span>Studio</span></div> <!-- full screen, grid-centred, overflow hidden -->
<div class="count">0%</div> <!-- small uppercase grey label, bottom centre -->
</div>
```
Centre the cards with `left: 50%; top: 50%` plus negative margins, so the animation library owns `transform` without fighting a centring translate. The hero is `position: absolute`, hidden until the opening, and its image is `object-fit: cover`.
## Libraries
- **GSAP 3** (core only): one timeline with absolute position parameters (`t.fromTo(cards, …, 0)`, `t.to(…, fanAt)` etc.), function-based values for random starts, slots and angles, and `stagger: { amount, from: "center" | "edges" }`. Initialise `filter: brightness(100%)` on reset so filter tweens never start from `none`.
- **Plain alternative:** the Web Animations API with per-element delays can reproduce it; compute the stagger delays yourself, and use `cubic-bezier(0.25, 1, 0.5, 1)` (rise), `cubic-bezier(0.76, 0, 0.24, 1)` (fan, open), `cubic-bezier(0.5, 0, 0.75, 0)` (drop) and `cubic-bezier(0.22, 1, 0.36, 1)` (wordmark).
## Responsive and reduced motion
- Card height `min(46vh, 66vw)` keeps the pile in proportion on any screen; the fan step shrinks on narrow screens.
- Rebuild the timeline on a real viewport size change (debounced 200 ms), since all positions are in pixels.
- Keep the stage hidden until the images are decoded, so nothing pops in half-loaded.
- `prefers-reduced-motion: reduce`: no animation; jump straight to the end state (full-screen photo with the wordmark), e.g. `timeline.progress(1)`.
## Acceptance criteria
- Photos rise one after another, tilted around their top edge, and land squarely on one pile; each covered card visibly darkens.
- The percentage reaches 100% as the last card lands.
- The pile fans into an even arc around a low pivot, middle card on top, staggered from the centre, with no card fully off screen on a phone.
- The side cards fall away while the top card grows seamlessly into a full-screen photo (no jump at the swap).
- The wordmark rises out of a mask over the darkened photo.
- Replaying restarts cleanly with fresh random tilts; reduced motion shows the end state immediately.
보관·활용 메모
Cadence에서 제공하는 코드와 프롬프트, 미리보기 영상을 이 사이트에 보관했습니다. 코드는 다운로드한 원본으로 제공하며 실행 화면은 별도로 격리되어 있습니다. 코드의 외부 라이브러리·샘플 이미지 요청은 유지됩니다.
보관일: 2026-10-26 · 프로젝트 적용: 별도 검증 필요
원본 코드·프롬프트·제작자 정보를 함께 보관했습니다. 실제 납품 시 코드와 포함 소재의 이용 조건을 확인하세요.
코드 SHA-256: f6d260e6cfb8bc40ca1944d68bf49b31447130a0ed076757d2d495fc6e5c65f6