모션·인터랙션 / Cadence
Photo Flip Loader
로딩 · 갤러리 · 텍스트 모션. 미리보기와 원본 코드·프롬프트를 이곳에서 확인하세요.
미리보기 안에서 스크롤하거나 마우스를 움직여 모션을 확인하세요.
HTML · CSS · JavaScript 전체 코드
<!doctype html>
<!--
Photo Flip Loader
A page loader. A giant wordmark is sized to 95% of the viewport width. One photo in
the centre flips through seven photos (200 ms each) and lands on the 4th. Every card
photo of the row is then FLIP-stacked onto that centre photo (4th on top), the centre
photo fades out, and the stack spreads out into a horizontal row of cards while the
wordmark shrinks to nav size. The card titles fade in mid-spread. Click to replay.
Dependencies: none (plain HTML, CSS and JS).
Tweak:
- TICKER_SEQ: the order of photos in the centre (1-based card numbers, ends on the 4th)
- TICK_MS: 200 ms per photo · START_DELAY / LAND_DELAY: 250 ms before / after the flipping
- DUR: 1200 ms spread + wordmark shrink · EASE: cubic-bezier(0.65, 0, 0.35, 1)
- TICKER_FADE: centre photo fades in 0.25s ease-in-out · SPREAD_DELAY: 350 ms after that fade starts
- META_DELAY: titles fade in 450 ms into the spread (0.9s ease-in-out)
- WORDMARK_WIDTH: 0.95 × viewport · WORDMARK_END_SIZE: 29px · WORDMARK_END_TRACKING: -0.01em
- --wm-top: the wordmark's distance from the top (30px)
- Row: cards 22vw wide (min 220px), 3vw gap, 3:4 photos
- LOOP / HOLD_MS: set LOOP = true to replay automatically after a 2.5 s hold
Based on the loader of the Studio Burnett site (Studio Burnett, own project).
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Photo Flip Loader</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;
}
.fl-stage {
--wm-top: 30px;
position: fixed;
inset: 0;
overflow: hidden;
background: #fff;
cursor: pointer; /* click to replay */
}
/* Hidden until the photos are loaded and the wordmark is sized */
.fl-stage:not(.is-ready) { visibility: hidden; }
/* Resets between runs happen without transitions */
.fl-stage.no-anim,
.fl-stage.no-anim * { transition: none !important; }
/* Wordmark: sized to 95vw by the script, shrinks to nav size (top-anchored) */
.fl-wordmark {
position: absolute;
left: 50%;
top: var(--wm-top);
z-index: 1;
font-weight: 500;
line-height: 0.9;
letter-spacing: -0.04em;
white-space: nowrap;
transform: translateX(-50%);
transform-origin: top center;
pointer-events: none;
will-change: font-size;
}
/* The single photo that flips through the sequence */
.fl-ticker {
position: absolute;
left: 50%;
top: 50%;
z-index: 15;
display: block;
width: auto;
height: 35vh;
aspect-ratio: 3 / 4;
object-fit: cover;
border-radius: 6px;
transform: translate(-50%, -50%);
will-change: opacity;
}
/* Card row */
.fl-viewport {
position: absolute;
left: 0;
right: 0;
top: 50%;
z-index: 10;
width: 100%;
overflow: hidden;
transform: translateY(-50%);
}
.fl-row {
display: flex;
align-items: center;
gap: 3vw;
width: max-content;
padding-left: 3vw;
will-change: transform;
}
.fl-card {
flex: 0 0 auto;
width: 22vw;
min-width: 220px;
}
.fl-card img {
display: block;
width: 100%;
height: auto;
aspect-ratio: 3 / 4;
object-fit: cover;
border-radius: 6px;
opacity: 1;
transition: opacity 0.5s ease;
will-change: transform, opacity;
backface-visibility: hidden;
user-select: none;
-webkit-user-drag: none;
}
.fl-stage.is-loading .fl-card img { opacity: 0; } /* hidden until the spread begins */
.fl-meta {
margin-top: 18px;
opacity: 0;
transition: opacity 0.9s ease-in-out;
}
.fl-card.show .fl-meta { opacity: 1; }
.fl-meta h3 {
margin: 0 0 4px;
font-size: 14px;
font-weight: 500;
}
.fl-meta p {
margin: 0;
font-size: 13px;
color: #8e8e8e;
}
/* Once loaded: hovering a card fades the others */
@media (hover: hover) {
.fl-stage:not(.is-loading) .fl-row:has(.fl-card:hover) .fl-card:not(:hover) img { opacity: 0.3; }
}
</style>
</head>
<body>
<div class="fl-stage is-loading" id="stage" aria-label="Photo flip loader">
<div class="fl-viewport">
<!-- Swap the picsum.photos placeholders for your own 3:4 photos (the script repeats this set for the row). -->
<div class="fl-row" id="row">
<div class="fl-card"><img src="https://picsum.photos/seed/cadence-1/600/800" alt=""><div class="fl-meta"><h3>Rust Coat</h3><p>Art Direction</p></div></div>
<div class="fl-card"><img src="https://picsum.photos/seed/cadence-2/600/800" alt=""><div class="fl-meta"><h3>Crosswalk</h3><p>Photography</p></div></div>
<div class="fl-card"><img src="https://picsum.photos/seed/cadence-3/600/800" alt=""><div class="fl-meta"><h3>The Turn</h3><p>Editorial</p></div></div>
<div class="fl-card"><img src="https://picsum.photos/seed/cadence-4/600/800" alt=""><div class="fl-meta"><h3>Portrait</h3><p>Campaign</p></div></div>
<div class="fl-card"><img src="https://picsum.photos/seed/cadence-5/600/800" alt=""><div class="fl-meta"><h3>Open Air</h3><p>Art Direction</p></div></div>
<div class="fl-card"><img src="https://picsum.photos/seed/cadence-6/600/800" alt=""><div class="fl-meta"><h3>Together</h3><p>Photography</p></div></div>
<div class="fl-card"><img src="https://picsum.photos/seed/cadence-7/600/800" alt=""><div class="fl-meta"><h3>Profile</h3><p>Editorial</p></div></div>
</div>
</div>
<div class="fl-wordmark" id="wordmark">STUDIO</div>
<img class="fl-ticker" id="ticker" src="https://picsum.photos/seed/cadence-1/600/800" alt="">
</div>
<script>
const TICKER_SEQ = [1, 2, 3, 5, 6, 7, 4]; // card photos shown in the centre, in order
const TICK_MS = 200; // per photo
const START_DELAY = 250; // ms before the flipping starts
const LAND_DELAY = 250; // ms after the last photo before landing
const DUR = 1200; // ms: spread + wordmark shrink
const EASE = "cubic-bezier(0.65, 0, 0.35, 1)";
const TICKER_FADE = "opacity 0.25s ease-in-out";
const SPREAD_DELAY = 350; // ms after the centre photo starts fading
const META_DELAY = 450; // ms into the spread before the titles fade in
const CLEANUP_PAD = 50; // ms after the spread
const WORDMARK_WIDTH = 0.95; // × viewport width
const WORDMARK_END_SIZE = "29px";
const WORDMARK_END_TRACKING = "-0.01em";
const REPEAT = 5; // the set of cards is repeated for the row
const CENTER_REPEAT_INDEX = 2; // the set that animates
const CENTER_CARD = 3; // the 4th card ends centred, on top of the stack
const LOOP = false; // true: replay automatically after HOLD_MS
const HOLD_MS = 2500;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const stage = document.getElementById("stage");
const row = document.getElementById("row");
const wordmark = document.getElementById("wordmark");
const tickerImg = document.getElementById("ticker");
// Repeat the set of cards so the row runs past both edges.
const originals = Array.from(row.children);
const srcs = originals.map((card) => card.querySelector("img").getAttribute("src"));
row.replaceChildren();
const firstSetCards = [];
for (let r = 0; r < REPEAT; r++) {
originals.forEach((card) => {
const clone = card.cloneNode(true);
if (r === CENTER_REPEAT_INDEX) firstSetCards.push(clone);
else clone.setAttribute("aria-hidden", "true");
row.append(clone);
});
}
const allCards = Array.from(row.children);
// Timers, tracked so a replay can cancel them.
let timers = [];
let tickInterval = null;
const later = (fn, ms) => timers.push(setTimeout(fn, ms));
function clearTimers() {
timers.forEach(clearTimeout);
timers = [];
clearInterval(tickInterval);
}
// Size the giant opening wordmark to ~95vw.
function sizeWordmark() {
wordmark.style.fontSize = "100px";
const measured = wordmark.offsetWidth;
wordmark.style.fontSize = ((window.innerWidth * WORDMARK_WIDTH) / measured) * 100 + "px";
}
// Flip through the photos in the centre, then land.
function runTicker() {
let frame = 0;
tickInterval = setInterval(() => {
tickerImg.src = srcs[TICKER_SEQ[frame] - 1];
frame++;
if (frame >= TICKER_SEQ.length) {
clearInterval(tickInterval);
later(land, LAND_DELAY);
}
}, TICK_MS);
}
// Offset that centres the 4th card of the animating set (offsetLeft ignores transforms).
function centreOffset() {
const target = firstSetCards[CENTER_CARD];
return window.innerWidth / 2 - (target.offsetLeft + target.offsetWidth / 2);
}
// FLIP-stack the cards on the centre photo, then spread them out into the row.
function land() {
allCards.forEach((c) => {
if (!firstSetCards.includes(c)) c.style.visibility = "hidden";
});
row.style.transform = `translateX(${centreOffset()}px)`;
const tickerRect = tickerImg.getBoundingClientRect();
firstSetCards.forEach((card, i) => {
const img = card.querySelector("img");
const r = img.getBoundingClientRect();
const dx = tickerRect.left + tickerRect.width / 2 - (r.left + r.width / 2);
const dy = tickerRect.top + tickerRect.height / 2 - (r.top + r.height / 2);
const scale = tickerRect.width / r.width;
img.style.transition = "none";
img.style.transform = `translate(${dx}px, ${dy}px) scale(${scale})`;
img.style.opacity = "1";
img.style.position = "relative";
img.style.zIndex = i === CENTER_CARD ? "2" : "1";
});
void row.offsetWidth; // force reflow
requestAnimationFrame(() => {
// The stacked card photos already cover the centre photo: fade it out.
tickerImg.style.transition = TICKER_FADE;
tickerImg.style.opacity = "0";
// Wordmark shrinks (top-anchored, so no vertical jump).
wordmark.style.transition = `font-size ${DUR}ms ${EASE}, letter-spacing ${DUR}ms ${EASE}`;
wordmark.style.fontSize = WORDMARK_END_SIZE;
wordmark.style.letterSpacing = WORDMARK_END_TRACKING;
later(() => {
firstSetCards.forEach((card) => {
const img = card.querySelector("img");
img.style.transition = `transform ${DUR}ms ${EASE}`;
img.style.transform = "translate(0, 0) scale(1)";
});
later(() => firstSetCards.forEach((c) => c.classList.add("show")), META_DELAY);
// Clean up and reveal the repeated cards.
later(() => {
tickerImg.style.display = "none";
stage.classList.remove("is-loading");
wordmark.style.transition = "";
allCards.forEach((c) => {
c.style.visibility = "";
c.classList.add("show");
const img = c.querySelector("img");
img.style.transition = "";
img.style.transform = "";
img.style.opacity = "";
img.style.zIndex = "";
img.style.position = "";
});
if (LOOP) later(replay, HOLD_MS);
}, DUR + CLEANUP_PAD);
}, SPREAD_DELAY);
});
}
// Back to the start state, without transitions.
function reset() {
clearTimers();
stage.classList.add("no-anim", "is-loading");
wordmark.style.transition = "";
wordmark.style.letterSpacing = "";
sizeWordmark();
tickerImg.style.transition = "";
tickerImg.style.opacity = "";
tickerImg.style.display = "";
tickerImg.src = srcs[TICKER_SEQ[0] - 1];
row.style.transform = "";
allCards.forEach((c) => {
c.style.visibility = "";
c.classList.remove("show");
const img = c.querySelector("img");
img.style.transition = "";
img.style.transform = "";
img.style.opacity = "";
img.style.zIndex = "";
img.style.position = "";
});
void stage.offsetWidth;
stage.classList.remove("no-anim");
stage.classList.add("is-ready");
}
function replay() {
reset();
later(runTicker, START_DELAY);
}
// Reduced motion: the landed state, no animation.
function showEndState() {
clearTimers();
tickerImg.style.display = "none";
wordmark.style.fontSize = WORDMARK_END_SIZE;
wordmark.style.letterSpacing = WORDMARK_END_TRACKING;
stage.classList.remove("is-loading");
allCards.forEach((c) => c.classList.add("show"));
row.style.transform = `translateX(${centreOffset()}px)`;
stage.classList.add("is-ready");
}
// Start once the font and every photo are loaded.
document.fonts.ready.then(() => {
const preloads = srcs.map(
(src) =>
new Promise((res) => {
const img = new Image();
img.onload = img.onerror = res;
img.src = src;
})
);
Promise.all(preloads).then(() => {
if (reduceMotion) {
showEndState();
window.addEventListener("resize", showEndState);
return;
}
replay();
stage.addEventListener("click", replay);
let resizeTimer;
window.addEventListener("resize", () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(replay, 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>
원본 구현 프롬프트
# Photo Flip Loader: photos flip in the centre, stack, then spread into a row while a giant wordmark shrinks
**Goal:** build a page loader where a single photo in the centre flips rapidly through a set of photos, then every card of a horizontal project row is stacked onto that photo and spreads out into the row, while a giant wordmark spanning the screen shrinks to a small logo at the top.
## Visual behaviour
1. **Start state** (white page, after all photos and the font have loaded; keep everything hidden until then):
- A **wordmark** (one word in caps, e.g. "STUDIO") at the top, horizontally centred, sized so it spans **95% of the viewport width**. Weight 500, line-height 0.9, letter-spacing -0.04em. Its top edge sits 30px from the top.
- One **centre photo**, 3:4, `35vh` tall, centred on the screen, 6px corners, above everything else.
- The **row of cards** exists but its photos are invisible (opacity 0) and the card titles are hidden.
2. **Flip** (after 250 ms): the centre photo's image is swapped every **200 ms**, seven times, showing the card photos in the order **1, 2, 3, 5, 6, 7, 4**. It ends on photo 4, which is the card that will end up centred in the row. Plain swaps, no transition.
3. **Land** (250 ms after the last swap):
- Translate the row horizontally so that card 4 (of the set that animates) is exactly centred in the viewport.
- **FLIP-stack:** for each of the 7 cards, measure its photo and the centre photo, and instantly (no transition) translate and scale the card photo so it sits exactly on top of the centre photo: `translate(dx, dy) scale(centreWidth / cardWidth)`, opacity 1. Card 4 is on top of the other cards (higher z-index). All other repeated cards in the row are hidden during this step.
- On the next frame: the centre photo fades out (`opacity 0.25s ease-in-out`). The card photos underneath already look identical, so nothing visibly changes.
- At the same moment the **wordmark shrinks** to `29px` with letter-spacing `-0.01em`, by transitioning `font-size` and `letter-spacing` over **1200 ms** with `cubic-bezier(0.65, 0, 0.35, 1)`. It is anchored at the top, so it shrinks upwards into a small logo with no vertical jump.
4. **Spread** (350 ms after the centre photo starts fading): every stacked photo transitions back to `translate(0, 0) scale(1)` over **1200 ms**, `cubic-bezier(0.65, 0, 0.35, 1)`. The cards fan out from the centre into their places in the row. Card 4 stays centred.
5. **Titles:** 450 ms into the spread, each card's title and tag fade in (`opacity 0.9s ease-in-out`).
6. **Clean up** (1200 + 50 ms after the spread starts): hide the centre photo, reveal the repeated cards on both sides (with their titles already shown), and remove the inline transforms.
7. **After loading:** hovering a card dims the other photos to opacity 0.3 (`opacity 0.5s ease`).
8. Clicking the stage (or a replay control) resets everything instantly, without transitions, and plays the sequence again. Resizing the window (debounced 200 ms) also replays, since the wordmark size and positions depend on the viewport.
## Exact values
| What | Value |
|---|---|
| Delay before flipping | 250 ms |
| Flip | 7 photos, 200 ms each, order 1, 2, 3, 5, 6, 7, 4 |
| Delay before landing | 250 ms |
| Centre photo | 3:4, height 35vh, centred, fades `0.25s ease-in-out` |
| Spread delay | 350 ms after the fade starts |
| Spread + wordmark shrink | 1200 ms, `cubic-bezier(0.65, 0, 0.35, 1)` (ease-in-out cubic) |
| Titles fade in | 450 ms into the spread, `0.9s ease-in-out` |
| Wordmark start | font-size so the word is 95vw wide, letter-spacing -0.04em |
| Wordmark end | 29px, letter-spacing -0.01em |
| Cards | 22vw wide (min 220px), 3:4 photos, 6px corners, 3vw gap, 3vw left padding |
| Card text | title 14px weight 500, 4px below it a tag 13px grey `#8e8e8e`, 18px above the text |
| Hover dim | other photos opacity 0.3 |
Total: about 3.5 s from start to the settled row.
## Markup and layout
```html
<div class="stage is-loading">
<div class="viewport"> <!-- full width, vertically centred, overflow hidden -->
<div class="row"> <!-- flex row, width max-content -->
<div class="card">
<img src="…" alt="…">
<div class="meta"><h3>Title</h3><p>Tag</p></div>
</div>
… 7 cards …
</div>
</div>
<div class="wordmark">STUDIO</div>
<img class="ticker" src="(photo 1)" alt="">
</div>
```
- `.stage`: fixed, full viewport, white, overflow hidden.
- `.viewport`: `position: absolute; left: 0; right: 0; top: 50%; transform: translateY(-50%); overflow: hidden`.
- Repeat the 7 cards **5 times** in JavaScript so the row runs off both edges. The 3rd repetition is the set that animates; the others are hidden until clean-up.
- Centre the row with `translateX(innerWidth / 2 - (card.offsetLeft + card.offsetWidth / 2))` (offsetLeft ignores transforms).
- `.wordmark`: `position: absolute; left: 50%; top: 30px; transform: translateX(-50%); transform-origin: top center; white-space: nowrap`. Size it by setting `font-size: 100px`, measuring `offsetWidth`, and scaling: `fontSize = (innerWidth × 0.95 / measured) × 100px`.
- `.ticker`: `position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); height: 35vh; aspect-ratio: 3/4; object-fit: cover`.
- While `is-loading`, card photos are `opacity: 0`.
- Track every timeout/interval so a replay can cancel them, and reset with a `no-anim` class that disables all transitions for one reflow.
## Libraries
- None. Plain JavaScript (`setTimeout`/`setInterval`, `getBoundingClientRect` for the FLIP measurements, `requestAnimationFrame` before starting transitions) and CSS transitions.
- Optional: GSAP's Flip plugin can do the stack-and-spread step, but keep the same timings and easing (`cubic-bezier(0.65, 0, 0.35, 1)` is ease-in-out cubic, GSAP `power2.inOut`).
- Preload all photos (`new Image()`) and wait for `document.fonts.ready` before measuring the wordmark and starting.
## Responsive and reduced motion
- Everything is in viewport units, so it scales; the cards have a 220px minimum width, so on phones the row shows roughly one card with neighbours peeking in.
- Replay on resize (debounced 200 ms).
- `prefers-reduced-motion: reduce`: skip the sequence and show the end state straight away: small wordmark, no centre photo, the row centred on card 4 with titles visible.
## Acceptance criteria
- On load the wordmark spans 95% of the width and one photo sits in the centre.
- The centre photo flips through 7 photos at 200 ms each and ends on the photo of the card that ends up centred.
- The stacked cards spread out from exactly the centre photo's position and size, with no visible jump when the centre photo fades away.
- The wordmark shrinks to 29px over the same 1.2 s, staying anchored at the top.
- Card titles fade in during the spread; afterwards the row extends past both edges.
- Hovering a card dims the others. Clicking replays from the start without stacking timers.
- With reduced motion, the final state is shown immediately.
보관·활용 메모
Cadence에서 제공하는 코드와 프롬프트, 미리보기 영상을 이 사이트에 보관했습니다. 코드는 다운로드한 원본으로 제공하며 실행 화면은 별도로 격리되어 있습니다. 코드의 외부 라이브러리·샘플 이미지 요청은 유지됩니다.
보관일: 2026-10-26 · 프로젝트 적용: 별도 검증 필요
원본 코드·프롬프트·제작자 정보를 함께 보관했습니다. 실제 납품 시 코드와 포함 소재의 이용 조건을 확인하세요.
코드 SHA-256: 56e8027968ef2884c7c9dc8787255b38fa65cb84fcf6fcc0c88c3699427cb463