모션·인터랙션 / Cadence
Formation Loader — Slices
로딩 · 이미지 · 텍스트 모션. 미리보기와 원본 코드·프롬프트를 이곳에서 확인하세요.
미리보기 안에서 스크롤하거나 마우스를 움직여 모션을 확인하세요.
HTML · CSS · JavaScript 전체 코드
<!doctype html>
<!--
Formation Loader — Slices
A self-playing page loader in four formations. Tall portrait columns swing in around
a pivot far above and to the right. A curtain then wipes each column over to a slice
of one landscape photo, lined up so the slices read as a single photo seen through
slats. The slats close up, gaps and all, into the full-screen landscape, and finally
lift away like curtains, one after another, leaving the landscape showing only
through a giant wordmark on the white page. Click to replay.
Dependencies: GSAP 3.15 (from jsDelivr).
Tweak:
- Swing: SWING_FROM { transformOrigin "100% -450%", scaleX 1.05, skewX 15, xPercent 50,
rotation -10, hidden } · SWING_DUR 1.5s · SWING_STAGGER 0.16s · SWING_EASE power3
- Curtain: WIPE_SHIFT yPercent 102 (box from -102, image inside from +102) · WIPE_DUR
1.1s · WIPE_STAGGER 0.12s · WIPE_EASE power1.inOut
- CLOSE_DUR 1.3s (power3.inOut, 0.1s stagger from the centre)
- LIFT_DUR 1.1s · LIFT_STAGGER 0.08s · LIFT_EASE power3.inOut
- PAUSE 0.35s between formations
- Layout: 5 columns over min(84vw, 1240px) × 64vh, 12px gaps; 3 columns over 88vw ×
60vh, 8px gaps on narrow screens · wordmark fitted to 88% of the width (max 42vh)
- 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 — Slices</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;
}
.sl-stage {
/* --landscape is set by the script from LANDSCAPE.src */
position: fixed;
inset: 0;
overflow: hidden;
background: #fff;
cursor: pointer; /* click to replay */
-webkit-tap-highlight-color: transparent;
}
/* Hidden until the photos are decoded and the wordmark is fitted */
.sl-stage:not(.is-ready) { visibility: hidden; }
/* The end state: the landscape shows only through the letters. The box covers the
viewport, so "cover" maps the photo exactly as the full-screen slices do. */
.sl-word {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: var(--landscape) center / cover no-repeat;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
font-weight: 500;
line-height: 1;
letter-spacing: -0.04em;
white-space: nowrap;
pointer-events: none;
}
.sl-word span {
display: block;
padding-bottom: 0.08em; /* keeps a descender-free word optically centred */
}
.sl-cols,
.sl-slices {
position: absolute;
inset: 0;
}
/* Columns, placed by the script */
.sl-col {
position: absolute;
overflow: hidden;
border-radius: 6px;
background: #efefef;
will-change: transform, opacity;
}
.sl-col img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
user-select: none;
-webkit-user-drag: none;
}
/* Curtain layer: the outer box slides down while the inner counter-slides, so the
landscape slice seems to stay put while its edge wipes over the portrait */
.sl-wipe {
position: absolute;
inset: 0;
overflow: hidden;
}
.sl-wipe-inner {
position: absolute;
inset: 0;
background-image: var(--landscape);
background-repeat: no-repeat;
}
/* Full-screen slices of the landscape, each clipped to its column by the script */
.sl-slice {
position: absolute;
inset: 0;
background: var(--landscape) center / cover no-repeat;
visibility: hidden;
}
</style>
</head>
<body>
<div class="sl-stage" id="stage" aria-label="Slices formation loader">
<div class="sl-word" id="word" aria-hidden="true"><span>Studio</span></div>
<div class="sl-cols" id="cols"></div>
<div class="sl-slices" id="slices"></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
<script>
(function () {
"use strict";
/* Constants --------------------------------------------------------------- */
// 1. Swing in around a far pivot.
const SWING_FROM = { transformOrigin: "100% -450%", scaleX: 1.05, skewX: 15, xPercent: 50, rotation: -10 };
const SWING_DUR = 1.5;
const SWING_STAGGER = 0.16;
const SWING_EASE = "power3";
// 2. Curtain wipe to the landscape slices.
const WIPE_SHIFT = 102; // yPercent
const WIPE_DUR = 1.1;
const WIPE_STAGGER = 0.12;
const WIPE_EASE = "power1.inOut";
// 3. Close up into one photo, 4. lift away.
const CLOSE_DUR = 1.3;
const CLOSE_EASE = "power3.inOut";
const LIFT_DUR = 1.1;
const LIFT_STAGGER = 0.08;
const LIFT_EASE = "power3.inOut";
const PAUSE = 0.35;
const LOOP = false; // true: replay automatically after HOLD
const HOLD = 2.5; // s on the end state before an automatic replay
// Swap the picsum.photos placeholders for your own photos. The landscape's real
// pixel size (w, h) is needed to line the slices up with the full-screen photo.
const LANDSCAPE = { src: "https://picsum.photos/seed/cadence-10/2400/1600", w: 2400, h: 1600 };
const PORTRAITS = [1, 2, 3, 4, 5].map((n) => `https://picsum.photos/seed/cadence-${n}/600/800`); // 3:4
const PORTRAITS_NARROW = PORTRAITS.slice(1, 4); // 3 columns on narrow screens
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
/* Elements ---------------------------------------------------------------- */
const stage = document.getElementById("stage");
const colsBox = document.getElementById("cols");
const slicesBox = document.getElementById("slices");
const word = document.getElementById("word");
const wordText = word.querySelector("span");
stage.style.setProperty("--landscape", `url("${LANDSCAPE.src}")`);
let cols = [];
let slices = [];
let rects = [];
let tl = null;
let hold = null;
/* Layout ------------------------------------------------------------------ */
function build() {
const vw = innerWidth;
const vh = innerHeight;
const narrow = vw < 700 || vw / vh < 0.8;
const list = narrow ? PORTRAITS_NARROW : PORTRAITS;
const n = list.length;
const gap = narrow ? 8 : 12;
const areaW = narrow ? vw * 0.88 : Math.min(vw * 0.84, 1240);
const areaH = vh * (narrow ? 0.6 : 0.64);
const w = (areaW - (n - 1) * gap) / n;
const left0 = (vw - areaW) / 2;
const top = (vh - areaH) / 2;
rects = list.map((_, i) => ({ l: left0 + i * (w + gap), t: top, w, h: areaH }));
// The landscape, "cover"-fitted to the viewport.
const s = Math.max(vw / LANDSCAPE.w, vh / LANDSCAPE.h);
const W = LANDSCAPE.w * s;
const H = LANDSCAPE.h * s;
const ox = (vw - W) / 2;
const oy = (vh - H) / 2;
colsBox.replaceChildren();
slicesBox.replaceChildren();
cols = list.map((src, i) => {
const r = rects[i];
const col = document.createElement("div");
col.className = "sl-col";
Object.assign(col.style, { left: r.l + "px", top: r.t + "px", width: r.w + "px", height: r.h + "px" });
col.innerHTML =
`<img src="${src}" alt="" width="600" height="800">` +
`<div class="sl-wipe"><div class="sl-wipe-inner"></div></div>`;
// The slice of the landscape this column covers, in viewport coordinates.
Object.assign(col.querySelector(".sl-wipe-inner").style, {
backgroundSize: `${W}px ${H}px`,
backgroundPosition: `${ox - r.l}px ${oy - r.t}px`,
});
colsBox.append(col);
return col;
});
slices = list.map(() => {
const el = document.createElement("div");
el.className = "sl-slice";
slicesBox.append(el);
return el;
});
// Fit the wordmark to ~88% of the viewport width.
word.style.fontSize = "100px";
const fit = (vw * 0.88) / wordText.offsetWidth;
word.style.fontSize = Math.min(100 * fit, vh * 0.42) + "px";
}
// clip-path inset() for a window onto the full-screen slice.
function inset(t, r, b, l, round) {
return `inset(${t}px ${r}px ${b}px ${l}px round ${round}px)`;
}
function columnClip(i) {
const r = rects[i];
return inset(r.t, innerWidth - r.l - r.w, innerHeight - r.t - r.h, r.l, 6);
}
// The column's share of the full screen, overlapping its right neighbour by 1px.
function fullClip(i, lifted) {
const n = slices.length;
const sw = innerWidth / n;
const right = Math.max(0, innerWidth - (i + 1) * sw - 1);
return inset(0, right, lifted ? innerHeight : 0, i * sw, 0);
}
/* Reset ------------------------------------------------------------------- */
function reset() {
gsap.set(cols, { clearProps: "transform,opacity,visibility" });
gsap.set(cols.map((c) => c.querySelector(".sl-wipe")), { clearProps: "transform" });
gsap.set(cols.map((c) => c.querySelector(".sl-wipe-inner")), { clearProps: "transform" });
gsap.set(slices, { clearProps: "all" });
gsap.set(word, { autoAlpha: 0 });
}
/* Timeline ---------------------------------------------------------------- */
function timeline() {
const wipes = cols.map((c) => c.querySelector(".sl-wipe"));
const inners = cols.map((c) => c.querySelector(".sl-wipe-inner"));
const t = gsap.timeline();
// 1. Swing in around the far pivot.
t.fromTo(
cols,
{ ...SWING_FROM, autoAlpha: 0 },
{ scaleX: 1, skewX: 0, xPercent: 0, rotation: 0, autoAlpha: 1, duration: SWING_DUR, ease: SWING_EASE, stagger: SWING_STAGGER },
0
);
// 2. Curtain: each portrait wipes over to its slice of the landscape.
const wipeAt = SWING_DUR + SWING_STAGGER * (cols.length - 1) - 0.2;
t.fromTo(wipes, { yPercent: -WIPE_SHIFT }, { yPercent: 0, duration: WIPE_DUR, ease: WIPE_EASE, stagger: WIPE_STAGGER }, wipeAt);
t.fromTo(inners, { yPercent: WIPE_SHIFT }, { yPercent: 0, duration: WIPE_DUR, ease: WIPE_EASE, stagger: WIPE_STAGGER }, wipeAt);
// 3. Close up: hand over to full-screen slices clipped to the columns, then open
// the windows until they meet edge to edge.
const closeAt = wipeAt + WIPE_DUR + WIPE_STAGGER * (cols.length - 1) + PAUSE;
t.set(slices, { visibility: "visible", clipPath: (i) => columnClip(i) }, closeAt);
t.set(cols, { visibility: "hidden" }, closeAt);
t.to(
slices,
{ clipPath: (i) => fullClip(i, false), duration: CLOSE_DUR, ease: CLOSE_EASE, stagger: { amount: 0.1, from: "center" } },
closeAt
);
// 4. Lift: the slices rise away one after another, leaving the photo in the letters.
const liftAt = closeAt + CLOSE_DUR + 0.1 + PAUSE * 1.5;
t.set(word, { autoAlpha: 1 }, liftAt);
t.to(
slices,
{ clipPath: (i) => fullClip(i, true), duration: LIFT_DUR, ease: LIFT_EASE, stagger: LIFT_STAGGER },
liftAt
);
return t;
}
/* Play / loop ------------------------------------------------------------- */
function play() {
if (hold) hold.kill();
if (tl) tl.kill();
reset();
tl = timeline();
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))])
);
}
const landscape = new Image();
landscape.src = LANDSCAPE.src;
build();
Promise.all([document.fonts.ready, decode(landscape), ...Array.from(colsBox.querySelectorAll("img")).map(decode)]).then(
() => {
build(); // refit the wordmark once the font is in
stage.classList.add("is-ready");
play();
}
);
stage.addEventListener("click", () => {
if (!reduced && stage.classList.contains("is-ready")) play();
});
// Rebuild on a real size change (everything is placed in viewport pixels).
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 (tl) tl.kill();
if (hold) hold.kill();
build();
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 — Slices: columns swing in, wipe to one landscape, close up and lift away to leave the photo inside the wordmark
**Goal:** build a self-playing page loader where tall portrait columns swing in, wipe over to slices of a single landscape photo, close up into that photo full screen, and then lift away one by one, leaving the landscape visible only through a giant wordmark.
## 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. **Layout.** A row of tall columns, centred: **5 columns** across `min(84vw, 1240px)` with 12px gaps, `64vh` tall (narrow screens, width < 700px or aspect ratio < 0.8: **3 columns** across 88vw with 8px gaps, 60vh tall). Each column shows a different portrait photo (`object-fit: cover`, 6px radius).
2. **Swing in (0 – 2.14 s).** Each column animates from `transform-origin: 100% -450%` (a pivot far above and to the right), `scaleX: 1.05, skewX: 15°, xPercent: 50, rotation: -10°`, hidden, to its rest state (all 0 / 1, visible) over **1.5 s**, ease `power3.out` (ease-out quart, ≈ `cubic-bezier(0.25, 1, 0.5, 1)`), each **0.16 s** after the previous, left to right. Because of the far pivot, the columns sweep in on a wide arc.
3. **Curtain wipe (from 1.94 s, 0.2 s before the swing ends).** Inside each column sits a second layer showing that column's **slice of one landscape photo**, positioned so that across all columns the slices line up as one continuous photo seen through slats (the landscape is "cover"-fitted to the full viewport, and each slice's `background-position` is offset by the column's own position). The layer's box slides from `yPercent: -102` to 0 while the image inside it counter-slides from `yPercent: 102` to 0, over **1.1 s**, ease `power1.inOut` (≈ `cubic-bezier(0.45, 0, 0.55, 1)`), staggered **0.12 s**. The effect: the landscape stays put while its edge wipes down over the portrait.
4. **Close up (from about 3.87 s, after a 0.35 s pause).** Swap each column for a full-screen copy of the landscape clipped to exactly that column's rectangle (`clip-path: inset(top right bottom left round 6px)`), so nothing visibly changes. Then animate each clip to that column's share of the full screen (`inset(0 … 0 i × vw/n round 0)`, each overlapping its right neighbour by 1px) over **1.3 s**, ease `power3.inOut` (≈ `cubic-bezier(0.76, 0, 0.24, 1)`), staggered from the centre (0.1 s total). The slats widen and grow until they meet edge to edge as one full-screen photo.
5. **Lift (from about 5.8 s, after a 0.525 s pause).** Make the wordmark visible underneath: it fills the screen, centred, fitted to **88%** of the viewport width (max 42vh tall), and is filled with the **same** landscape, cover-fitted to the viewport, via `background-clip: text` (transparent text colour). Then each full-screen slice's clip animates its bottom inset to the full height (`inset(0 r 100% l)`), so the slices roll up and away like blinds, over **1.1 s**, ease `power3.inOut`, left to right, **0.08 s** apart. What remains is the white page with the landscape showing only through the letters, perfectly aligned with where the photo was. The timeline ends at about 7.2 s.
6. **Replay** (click): kill the timeline, clear the inline styles and play again. Optionally loop after a 2.5 s hold on the end state.
## Exact values
| What | Value |
|---|---|
| Columns | 5 over `min(84vw, 1240px)` × 64vh, 12px gaps (narrow: 3 over 88vw × 60vh, 8px) · 6px radius |
| Swing from | origin `100% -450%`, scaleX 1.05, skewX 15°, xPercent 50, rotation −10°, hidden |
| Swing | 1.5 s · `power3.out` · 0.16 s stagger |
| Wipe | box yPercent −102 → 0, image +102 → 0 · 1.1 s · `power1.inOut` · 0.12 s stagger · starts 0.2 s before the swing ends |
| Close | clip column → full-screen share · 1.3 s · `power3.inOut` · 0.1 s total stagger from the centre |
| Lift | clip bottom → 100% · 1.1 s · `power3.inOut` · 0.08 s stagger |
| Pauses | 0.35 s before closing, 0.525 s before lifting |
| Wordmark | weight 500, letter-spacing -0.04em, line-height 1, 88% of the viewport width (max 42vh), `background: <landscape> center / cover; background-clip: text; color: transparent` |
## Markup
```html
<div class="stage"> <!-- fixed, inset 0, white -->
<div class="word"><span>Studio</span></div> <!-- inset 0, grid-centred, landscape clipped to the text, hidden until the lift -->
<div class="cols"> <!-- generated: one per column, placed in px -->
<div class="col">
<img src="portrait.jpg" alt="">
<div class="wipe"><div class="wipe-inner"></div></div> <!-- wipe-inner: background = the landscape slice -->
</div> …
</div>
<div class="slices"> <!-- generated: one full-screen landscape per column, clip-path animated -->
<div class="slice"></div> …
</div>
</div>
```
To line the slices up you need the landscape's natural size: `s = max(vw / imgW, vh / imgH)`, rendered size `W = imgW × s`, `H = imgH × s`, offset `ox = (vw - W) / 2`, `oy = (vh - H) / 2`. Each column's `.wipe-inner` gets `background-size: W H` and `background-position: (ox - col.left) (oy - col.top)`. The full-screen slices and the wordmark use `center / cover` on a viewport-sized box, which maps the photo identically.
## Libraries
- **GSAP 3** (core only): one timeline; `fromTo` for the swing and the two wipe layers; `set` for the hand-over to the clipped slices; `to` on `clipPath` (GSAP interpolates `inset()` values when both have the same structure, so always include `round …px`).
- **Plain alternative:** the Web Animations API can animate `transform` and `clip-path: inset()` directly with per-element delays. Use `cubic-bezier(0.25, 1, 0.5, 1)` for the swing, `cubic-bezier(0.45, 0, 0.55, 1)` for the wipe and `cubic-bezier(0.76, 0, 0.24, 1)` for the close and lift.
## Responsive and reduced motion
- 5 columns on wide screens, 3 on narrow ones; everything is placed in viewport pixels, so rebuild the layout and timeline on a real size change (debounced 200 ms).
- Keep the stage hidden until the images are decoded and the wordmark is fitted in the final font.
- `prefers-reduced-motion: reduce`: no animation; show the end state (the landscape inside the wordmark), e.g. `timeline.progress(1)`.
## Acceptance criteria
- Columns sweep in on a wide arc from the upper right, one after another.
- Each portrait is wiped over by a slice of the landscape, and together the slices form one continuous, correctly aligned photo behind the gaps.
- The slats close up into the full-screen landscape with no visible jump at the hand-over and no hairline gaps once closed.
- The slices roll up one by one, and the landscape inside the letters lines up exactly with the photo that was just there.
- Replaying restarts cleanly; reduced motion shows the end state immediately.
보관·활용 메모
Cadence에서 제공하는 코드와 프롬프트, 미리보기 영상을 이 사이트에 보관했습니다. 코드는 다운로드한 원본으로 제공하며 실행 화면은 별도로 격리되어 있습니다. 코드의 외부 라이브러리·샘플 이미지 요청은 유지됩니다.
보관일: 2026-10-26 · 프로젝트 적용: 별도 검증 필요
원본 코드·프롬프트·제작자 정보를 함께 보관했습니다. 실제 납품 시 코드와 포함 소재의 이용 조건을 확인하세요.
코드 SHA-256: e61af65463e818bf93b4e566924860dc5eb87c366f327b37a70fd3644f6eeb99