모션·인터랙션 / Cadence
Split Image Loader
로딩 · 텍스트 · 이미지 모션. 미리보기와 원본 코드·프롬프트를 이곳에서 확인하세요.
미리보기 안에서 스크롤하거나 마우스를 움직여 모션을 확인하세요.
HTML · CSS · JavaScript 전체 코드
<!doctype html>
<!--
Split Image Loader
A first-load intro. Two full-height photos push into a 50/50 split from opposite
directions (the right half from the top, the left half from the bottom) while a wordmark
breathes outward across the middle (letter-spacing and a matching text-indent open
together, so it stays centred). The wordmark then fades and the halves slide back out,
revealing the page underneath. A new random pair of photos is picked on every run.
Dependencies: none (plain CSS transitions + JavaScript).
Tweak:
- ENTER 2200ms: the photos push in
- BREATHE 2400ms: the wordmark's letter-spacing opens from 0.15em to SPACING_END (0.5em)
- WORD_FADE 600ms: the wordmark fades out, starting at BREATHE
- EXIT 2200ms: the photos slide back out, starting at EXIT_AT (BREATHE + 650ms)
- Easing: --ease, cubic-bezier(0.16, 1, 0.3, 1) for everything
- LOAD_TIMEOUT 600ms: start anyway if the photos take longer to load
- WORDMARK and the .sl-word font size: clamp(2.6rem, 7.5vw, 6.5rem)
From the Kendal site (own project).
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Split Image Loader</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
html, body { height: 100%; margin: 0; background: #fff; }
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; color: #000; }
/* Your page, revealed when the loader slides away */
.page {
min-height: 100%;
display: grid;
place-items: center;
font-size: 14px;
color: #8e8e8e;
}
.replay {
position: fixed;
right: 20px;
bottom: 20px;
z-index: 20;
padding: 8px 14px;
border: 1px solid #e6e6e6;
border-radius: 999px;
background: #fff;
font: inherit;
font-size: 12px;
cursor: pointer;
}
.sl-loader {
--ease: cubic-bezier(0.16, 1, 0.3, 1); /* gentle, luxurious ease-out */
position: fixed;
inset: 0;
z-index: 10;
overflow: hidden;
}
.sl-loader-bg {
position: absolute;
inset: 0;
z-index: 0;
background: #fff; /* hides the page until the reveal */
}
.sl-half {
position: absolute;
top: 0;
width: 50%;
height: 100%;
z-index: 1;
overflow: hidden;
will-change: transform;
}
.sl-half--left { left: 0; }
.sl-half--right { left: 50%; }
.sl-half img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
user-select: none;
-webkit-user-drag: none;
}
.sl-word {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 2;
white-space: nowrap;
font-weight: 500;
line-height: 1;
color: #fff;
font-size: clamp(2.6rem, 7.5vw, 6.5rem);
/* The leading text-indent mirrors the trailing letter-spacing, so the word stays
optically centred as the tracking opens out. */
letter-spacing: 0.15em;
text-indent: 0.15em;
will-change: letter-spacing, text-indent, opacity;
pointer-events: none;
}
/* Phones: a smaller minimum so the opened-out word fits */
@media (max-width: 700px) {
.sl-word { font-size: 7.5vw; }
}
</style>
</head>
<body>
<main class="page">Your page content</main>
<button class="replay" type="button">Replay</button>
<script>
const ENTER = 2200; // photos push in
const BREATHE = 2400; // wordmark letter-spacing opens out over this
const WORD_FADE = 600; // wordmark fade-out
const EXIT = 2200; // photos slide back out
const EXIT_AT = BREATHE + 650;
const LOAD_TIMEOUT = 600; // start anyway if the photos take longer than this
const SPACING_END = "0.5em"; // letter-spacing + text-indent at the end of the breath (from 0.15em in CSS)
const WORDMARK = "STUDIO";
// Placeholder photos (3:4 portrait): swap in your own. Each run picks two different ones.
const IMAGES = Array.from({ length: 12 }, (_, i) => ({
small: `https://picsum.photos/seed/cadence-${i + 1}/600/800`,
large: `https://picsum.photos/seed/cadence-${i + 1}/1200/1600`,
}));
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const el = (cls) => {
const d = document.createElement("div");
d.className = cls;
return d;
};
const onceLoaded = (img) =>
img.complete
? Promise.resolve()
: new Promise((res) => {
img.onload = res;
img.onerror = res;
});
let timers = [];
let generation = 0;
const later = (fn, ms) => timers.push(setTimeout(fn, ms));
const clearTimers = () => {
timers.forEach(clearTimeout);
timers = [];
};
// Two different photos, uniformly at random, avoiding the last pair.
let lastPair = [];
function pickTwoImages() {
const pool = [];
for (let n = 0; n < IMAGES.length; n++) if (!lastPair.includes(n)) pool.push(n);
const i = Math.floor(Math.random() * pool.length);
let j = Math.floor(Math.random() * (pool.length - 1));
if (j >= i) j++; // guarantees j !== i, still uniform
lastPair = [pool[i], pool[j]];
return lastPair;
}
function makeImage(n) {
const img = new Image();
img.srcset = `${IMAGES[n].small} 600w, ${IMAGES[n].large} 1200w`;
img.sizes = "50vw";
img.src = IMAGES[n].small;
img.alt = "";
img.draggable = false;
return img;
}
let loader = null;
function runLoader() {
const gen = ++generation;
clearTimers();
if (loader) loader.remove();
const [nLeft, nRight] = pickTwoImages();
loader = el("sl-loader");
const bg = el("sl-loader-bg");
const left = el("sl-half sl-half--left");
const right = el("sl-half sl-half--right");
const leftImg = makeImage(nLeft);
const rightImg = makeImage(nRight);
left.append(leftImg);
right.append(rightImg);
const word = el("sl-word");
word.textContent = WORDMARK;
loader.append(bg, left, right, word);
document.body.append(loader);
if (reducedMotion) {
// No motion: show the split with the opened-out wordmark briefly, then reveal the page.
word.style.letterSpacing = SPACING_END;
word.style.textIndent = SPACING_END;
const current = loader;
later(() => current.remove(), EXIT_AT);
return;
}
// Start positions: right above, left below (no transition yet).
left.style.transform = "translateY(100%)";
right.style.transform = "translateY(-100%)";
// Begin once both photos are ready (or shortly after, as a fallback).
Promise.race([
Promise.all([onceLoaded(leftImg), onceLoaded(rightImg)]),
new Promise((r) => setTimeout(r, LOAD_TIMEOUT)),
]).then(() => {
if (gen === generation) start();
});
function start() {
const current = loader;
void left.offsetWidth; // commit the start positions before enabling transitions
// Photos push in
left.style.transition = `transform ${ENTER}ms var(--ease)`;
right.style.transition = `transform ${ENTER}ms var(--ease)`;
left.style.transform = "translateY(0)";
right.style.transform = "translateY(0)";
// Wordmark breathes outward
word.style.transition =
`letter-spacing ${BREATHE}ms var(--ease), ` +
`text-indent ${BREATHE}ms var(--ease), ` +
`opacity ${WORD_FADE}ms var(--ease)`;
word.style.letterSpacing = SPACING_END;
word.style.textIndent = SPACING_END;
// Wordmark fades away before the reveal
later(() => { word.style.opacity = "0"; }, BREATHE);
// Photos slide back out, revealing the page
later(() => {
bg.style.opacity = "0"; // the halves still cover the screen
left.style.transition = `transform ${EXIT}ms var(--ease)`;
right.style.transition = `transform ${EXIT}ms var(--ease)`;
left.style.transform = "translateY(100%)";
right.style.transform = "translateY(-100%)";
}, EXIT_AT);
later(() => current.remove(), EXIT_AT + EXIT + 60);
}
}
document.querySelector(".replay").addEventListener("click", runLoader);
runLoader();
</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>
원본 구현 프롬프트
# Split Image Loader: two photos push into a split screen while a wordmark breathes open **Goal:** build a first-load intro for a website. Two photos slide into a 50/50 split screen from opposite directions while a white wordmark slowly opens its letter-spacing across the middle. The wordmark fades, then the two halves slide back out the way they came, revealing the page. ## Visual behaviour 1. **Overlay.** A fixed, full-viewport loader above the page, with `overflow: hidden`. Inside it, bottom to top: - a white background layer that hides the page until the reveal; - a **left half** and a **right half** (each 50% wide, full height, `overflow: hidden`), each holding one photo with `object-fit: cover`; - the **wordmark**: one word in white capitals, medium weight (500), centred on the screen across the seam, `font-size: clamp(2.6rem, 7.5vw, 6.5rem)` (7.5vw below 700px), `letter-spacing: 0.15em` with a matching `text-indent: 0.15em`. The indent mirrors the tracking that trails the last letter, so the word stays optically centred while it opens. 2. **Photos.** Pick two different photos at random on every run (and avoid repeating the previous pair). Wait until both have loaded, or at most **600 ms**, before starting. 3. **Start state.** Left half at `translateY(100%)` (below the screen), right half at `translateY(-100%)` (above). The wordmark is visible from the start, white over the white background. 4. **0 ms, push in.** Both halves transition to `translateY(0)` over **2200 ms**: the left rises from the bottom, the right drops from the top, meeting in a full split screen. 5. **0 ms, breathe.** At the same moment, the wordmark's `letter-spacing` and `text-indent` both transition from 0.15em to **0.5em** over **2400 ms**. 6. **2400 ms, fade.** The wordmark fades to opacity 0 over **600 ms**. 7. **3050 ms, reveal** (2400 + 650). Hide the white background layer (the halves still cover the screen), then slide the halves back out over **2200 ms**: the left back down to `translateY(100%)`, the right back up to `translateY(-100%)`. The page shows through the opening gap in the middle, seen through both halves at once. 8. **5310 ms.** Remove the loader (3050 + 2200 + 60). Every transition uses the same easing: `cubic-bezier(0.16, 1, 0.3, 1)`, a long, soft ease-out (fast start, slow luxurious settle). ## Exact values | What | Value | |---|---| | Push in | 2200 ms, translateY ±100% → 0 (left from below, right from above) | | Breathe | letter-spacing + text-indent 0.15em → 0.5em, 2400 ms | | Wordmark fade | opacity 1 → 0, 600 ms, starts at 2400 ms | | Reveal | starts at 3050 ms, 2200 ms, back to ±100% | | Remove loader | 5310 ms | | Easing (all) | `cubic-bezier(0.16, 1, 0.3, 1)` | | Image wait | max 600 ms | | Wordmark size | `clamp(2.6rem, 7.5vw, 6.5rem)`, weight 500, white | ## Markup ```html <div class="loader"> <!-- fixed, inset 0, overflow hidden, z-index above the page --> <div class="loader-bg"></div> <!-- white, absolute inset 0 --> <div class="half half--left"><img src="…" alt=""></div> <div class="half half--right"><img src="…" alt=""></div> <div class="word">STUDIO</div> <!-- absolute, centred with translate(-50%, -50%) --> </div> ``` Build the loader in JavaScript on each run (so it can be replayed with a fresh pair of photos). Set the start transforms first, force a reflow (`element.offsetWidth`), then set the transitions and end values. Use `srcset` with `sizes="50vw"` so each half loads a fitting size. ## Libraries - None needed: CSS transitions plus a few `setTimeout`s. With GSAP or Motion, the same sequence is one timeline: halves `y: 0` (2.2 s) and word `letterSpacing / textIndent: "0.5em"` (2.4 s) at 0, word `opacity: 0` (0.6 s) at 2.4, halves back out (2.2 s) at 3.05, all with an ease matching `cubic-bezier(0.16, 1, 0.3, 1)` (close to `expo.out`). ## Responsive and reduced motion - The halves always split the viewport 50/50 and the photos cover-fit, so it works at any size. On narrow screens the wordmark uses `7.5vw` so the opened-out word still fits. - `prefers-reduced-motion: reduce`: no movement. Show the split screen with the wordmark already opened out (0.5em), then remove the loader after about 3 s. ## Acceptance criteria - On load, two different photos slide in from opposite edges (left from the bottom, right from the top) and meet in a clean vertical split within about 2 s, with a soft, decelerating finish. - Meanwhile the white wordmark, centred across the seam, slowly opens its letter-spacing and stays visually centred throughout. - At 2.4 s the wordmark fades out; at about 3 s the halves slide back out the way they came, revealing the page, and the loader is gone after about 5.3 s. - A replay (if offered) starts cleanly from the beginning with a new photo pair, and rapid replays never stack.
보관·활용 메모
Cadence에서 제공하는 코드와 프롬프트, 미리보기 영상을 이 사이트에 보관했습니다. 코드는 다운로드한 원본으로 제공하며 실행 화면은 별도로 격리되어 있습니다. 코드의 외부 라이브러리·샘플 이미지 요청은 유지됩니다.
보관일: 2026-10-26 · 프로젝트 적용: 별도 검증 필요
원본 코드·프롬프트·제작자 정보를 함께 보관했습니다. 실제 납품 시 코드와 포함 소재의 이용 조건을 확인하세요.
코드 SHA-256: 5bbec0908ecd98672df16668d5ee7e734db284f79ba9776814463648f2de43e5