모션·인터랙션 / Cadence
Curtain Intro
로딩 · 페이지 전환 · 텍스트 모션. 미리보기와 원본 코드·프롬프트를 이곳에서 확인하세요.
미리보기 안에서 스크롤하거나 마우스를 움직여 모션을 확인하세요.
HTML · CSS · JavaScript 전체 코드
<!doctype html>
<!--
Curtain Intro
A page-loader sequence. The wordmark's letters tip up out of a blur on a stagger while
the whole word's letter-spacing spreads out; a hairline progress bar and a meta row
(label + percentage) rise in under it. The bar follows the real download of the page
image, eased and paced so it never takes less than MIN_MS. At 100% six white columns
lift off the page on a stagger, revealing the photo underneath, the bar and meta drop
away, and the wordmark flies (FLIP: translate + scale) into its small header spot at
the top, where a static header wordmark takes over. Click to replay.
Dependencies: none (plain HTML, CSS and JS).
Tweak:
- Letters: 1.15s cubic-bezier(.16,.84,.3,1), 120ms + 62ms stagger, from
translateY(0.8em) rotateX(-62deg) blur(11px)
- Spread: letter-spacing 0.04em → 0.5em over 1.9s cubic-bezier(.2,.7,.2,1)
- Rule / meta rise in at 0.95s / 1.1s (0.9s, 14px)
- MIN_MS 2000 / MAX_MS 9000: the bar's minimum and maximum run time
- PROGRESS_EASE: shown += (want - shown) × (1 - e^(-4·dt))
- Columns: 6, 1s cubic-bezier(.76,0,.24,1), 65ms stagger (in the CSS)
- FLIP_DURATION 1050 ms, FLIP_DELAY 200 ms, FLIP_EASE cubic-bezier(.72,0,.16,1)
- --wm-top / --wm-size: where the header wordmark sits and how big it is
- .ci-content width: min(40vw, 560px), the intro wordmark is fitted to it
- LOOP / HOLD: set LOOP = true to replay automatically after a 2.5s hold
Based on the loader of the Northmill site (own project).
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Curtain Intro</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;
}
.ci-stage {
--wm-top: 40px; /* the header spot: a small wordmark centred at the top */
--wm-size: clamp(16px, 1.5vw, 22px);
position: fixed;
inset: 0;
overflow: hidden;
background: #fff;
color: #000;
cursor: pointer; /* click to replay */
-webkit-tap-highlight-color: transparent;
}
/* Hidden until the font is ready and the wordmark is fitted */
.ci-stage:not(.is-ready) { visibility: hidden; }
/* The page behind the loader: here a full-screen photo */
.ci-photo {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
user-select: none;
-webkit-user-drag: none;
}
/* The header wordmark: the intro mark flies here, then hands over. Same face, weight,
tracking and line box as the intro mark, so the hand-off has nothing to give away. */
.ci-wordmark {
position: absolute;
top: var(--wm-top);
left: 50%;
transform: translateX(-50%);
font-size: var(--wm-size);
font-weight: 400;
line-height: 0.78;
letter-spacing: 0.5em;
text-indent: 0.5em; /* mirrors the trailing tracking, so the mark sits optically centred */
text-transform: uppercase;
white-space: nowrap;
opacity: 0;
}
/* ── Intro ─────────────────────────────────────────────────────────── */
.ci-loader {
position: absolute;
inset: 0;
z-index: 5;
overflow: hidden;
background: transparent; /* the lifting columns reveal the photo */
}
.ci-loader[hidden] { display: none; }
/* The curtain: six columns that lift on a stagger */
.ci-panels {
position: absolute;
inset: 0;
display: flex;
}
.ci-panels > i {
flex: 1 1 0;
margin-right: -1px; /* overlap neighbours so no hairline of the photo shows between columns */
background: #fff;
transform: translate3d(0, 0, 0);
will-change: transform;
transition: transform 1s cubic-bezier(0.76, 0, 0.24, 1);
transition-delay: calc(var(--p) * 65ms);
}
.ci-loader.is-out .ci-panels > i { transform: translate3d(0, -101%, 0); }
/* A slow drift of light behind the wordmark, so the white is never flat */
.ci-glow {
position: absolute;
inset: -30%;
pointer-events: none;
background: radial-gradient(38% 46% at 50% 50%, rgb(0 0 0 / 0.045), rgb(0 0 0 / 0) 70%);
animation: ci-glow-drift 9s ease-in-out infinite;
}
.ci-loader.is-out .ci-glow {
opacity: 0;
transition: opacity 0.6s ease;
}
@keyframes ci-glow-drift {
0%, 100% { transform: translate3d(-13%, -6%, 0) scale(1); }
50% { transform: translate3d(13%, 6%, 0) scale(1.18); }
}
.ci-content {
position: absolute;
z-index: 2;
left: 50%;
width: min(40vw, 560px); /* the intro wordmark is fitted to this width */
top: 50%;
transform: translate(-50%, -50%);
perspective: 900px;
}
.ci-loader.is-out .ci-content { perspective: none; }
.ci-mark {
display: block;
font-weight: 400;
text-transform: uppercase;
letter-spacing: 0.5em;
line-height: 0.78;
white-space: nowrap;
font-size: 8vw; /* replaced by the fitter in the script */
transform-origin: 0 0;
will-change: transform;
animation: ci-mark-spread 1.9s cubic-bezier(0.2, 0.7, 0.2, 1) both;
}
.ci-mark span {
display: inline-block;
opacity: 0;
animation: ci-mark-in 1.15s cubic-bezier(0.16, 0.84, 0.3, 1) both;
animation-delay: calc(120ms + var(--i) * 62ms);
}
@keyframes ci-mark-spread {
from { letter-spacing: 0.04em; }
to { letter-spacing: 0.5em; }
}
@keyframes ci-mark-in {
from { opacity: 0; transform: translate3d(0, 0.8em, 0) rotateX(-62deg); filter: blur(11px); }
to { opacity: 1; transform: none; filter: blur(0); }
}
.ci-rule {
position: relative;
height: 1px;
margin-top: clamp(28px, 3.4vh, 46px);
background: rgb(0 0 0 / 0.12);
overflow: hidden;
opacity: 0;
animation: ci-meta-in 0.9s cubic-bezier(0.16, 0.84, 0.3, 1) 0.95s both;
}
.ci-rule > i {
position: absolute;
inset: 0;
background: #000;
transform: scaleX(0);
transform-origin: 0 50%;
}
.ci-meta {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 2em;
margin-top: clamp(12px, 1.5vh, 18px);
font-size: clamp(9px, 0.67vw, 11px);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.25em;
opacity: 0;
animation: ci-meta-in 0.9s cubic-bezier(0.16, 0.84, 0.3, 1) 1.1s both;
}
.ci-pct { font-variant-numeric: tabular-nums; }
.ci-pct em {
font-style: normal;
opacity: 0.45;
margin-left: 0.35em;
}
@keyframes ci-meta-in {
from { opacity: 0; transform: translate3d(0, 14px, 0); }
to { opacity: 1; transform: none; }
}
/* The bar and the counter have done their job; they leave first so the wordmark
travels alone. */
.ci-loader.is-out .ci-rule,
.ci-loader.is-out .ci-meta {
opacity: 0;
transform: translate3d(0, 16px, 0);
transition: opacity 0.4s ease, transform 0.5s cubic-bezier(0.76, 0, 0.24, 1);
animation: none;
}
/* Off-screen copy of the mark, used to measure the text width */
.ci-measure {
position: absolute;
visibility: hidden;
pointer-events: none;
top: -9999px;
left: -9999px;
white-space: nowrap;
font-weight: 400;
text-transform: uppercase;
letter-spacing: 0.5em;
font-size: 200px;
}
/* ── Responsive ────────────────────────────────────────────────────── */
@media (max-width: 760px) {
.ci-stage { --wm-top: 32px; }
.ci-meta {
gap: 1.2em;
font-size: 9px;
}
.ci-label { display: none; }
}
@media (max-width: 639px) {
.ci-content { width: 70vw; } /* the wordmark stays legible on phones */
}
@media (prefers-reduced-motion: reduce) {
.ci-mark,
.ci-mark span,
.ci-rule,
.ci-meta,
.ci-glow {
animation: none;
opacity: 1;
}
.ci-loader { transition: opacity 0.4s linear; }
.ci-loader.is-out { opacity: 0; }
.ci-loader.is-out .ci-panels > i {
transition: none;
transform: none;
}
}
</style>
</head>
<body>
<div class="ci-stage" id="stage" role="img" aria-label="Intro: a wordmark forms, a loading bar fills, then white columns lift away to reveal a photo">
<img class="ci-photo" id="photo" alt="" draggable="false">
<div class="ci-wordmark" id="wordmark" aria-hidden="true">Studio</div>
</div>
<!-- The loader is cloned from this template on every run. One <span> per letter (--i = index). -->
<template id="loaderTemplate">
<div class="ci-loader" role="progressbar" aria-label="Loading" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<div class="ci-panels" aria-hidden="true">
<i style="--p:0"></i><i style="--p:1"></i><i style="--p:2"></i>
<i style="--p:3"></i><i style="--p:4"></i><i style="--p:5"></i>
<span class="ci-glow"></span>
</div>
<div class="ci-content">
<div class="ci-mark" aria-hidden="true"><span style="--i:0">S</span><span style="--i:1">T</span><span style="--i:2">U</span><span style="--i:3">D</span><span style="--i:4">I</span><span style="--i:5">O</span></div>
<div class="ci-rule"><i></i></div>
<div class="ci-meta">
<span class="ci-label">Quiet motion, carefully made</span>
<span class="ci-pct"><span>0</span><em>%</em></span>
</div>
</div>
</div>
</template>
<span class="ci-measure" id="measure" aria-hidden="true">Studio</span>
<script>
(function () {
"use strict";
/* Constants --------------------------------------------------------------- */
const MIN_MS = 2000; // give the choreography room to read
const MAX_MS = 9000; // never strand a visitor on a slow line
const MEDIA_WEIGHT = 0.88; // share of the bar for the image; the rest is fonts
const FONT_WEIGHT = 0.12;
const PROGRESS_EASE = 4; // shown += (want - shown) * (1 - exp(-4 dt))
const FLIP_DURATION = 1050; // ms, wordmark into its header spot
const FLIP_DELAY = 200;
const FLIP_EASE = "cubic-bezier(.72,0,.16,1)";
const FINISH_AT = 1290; // ms after the reveal: loader removed, header mark shown
const REDUCED_FINISH_AT = 420;
const LOOP = false; // true: replay automatically after HOLD
const HOLD = 2500; // ms on the end state before an automatic replay
// The page image (3:2), one per run, in turn. Swap the picsum.photos placeholders
// for your own; the bar follows the real download of each.
const PHOTOS = [
"https://picsum.photos/seed/cadence-1/2400/1600",
"https://picsum.photos/seed/cadence-2/2400/1600",
"https://picsum.photos/seed/cadence-3/2400/1600",
"https://picsum.photos/seed/cadence-4/2400/1600",
];
/* Elements ---------------------------------------------------------------- */
const stage = document.getElementById("stage");
const photo = document.getElementById("photo");
const wordmark = document.getElementById("wordmark");
const measure = document.getElementById("measure");
const template = document.getElementById("loaderTemplate");
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
/* Real image progress ----------------------------------------------------- */
// One loader per photo: fetches the bytes with progress, then decodes. Cached, so a
// replay of the same photo is instant (MIN_MS still paces the bar).
const loads = new Map();
function loadPhoto(src) {
if (loads.has(src)) return loads.get(src);
const state = { received: 0, total: 0, url: null, decoded: false };
state.progress = () =>
state.decoded ? 1 : state.total ? Math.min(0.99, state.received / state.total) : 0;
const decode = (url) => {
const img = new Image();
img.src = url;
return (img.decode ? img.decode() : new Promise((r) => (img.onload = r)))
.catch(() => {})
.then(() => {
state.url = url;
state.decoded = true;
});
};
state.done = fetch(src)
.then((res) => {
state.total = Number(res.headers.get("content-length")) || 0;
if (!res.body || !res.body.getReader) return res.blob();
const reader = res.body.getReader();
const chunks = [];
const pump = () =>
reader.read().then(({ done, value }) => {
if (done) return new Blob(chunks, { type: res.headers.get("content-type") || "image/jpeg" });
chunks.push(value);
state.received += value.length;
return pump();
});
return pump();
})
.then((blob) => decode(URL.createObjectURL(blob)))
.catch(() => decode(src)); // fall back to a plain image load (e.g. no CORS)
loads.set(src, state);
return state;
}
/* Wordmark fit: the intro mark fills the measure -------------------------- */
let RATIO = null; // text width at 1px font-size, trailing space included
function fitMark(mark) {
if (RATIO === null) RATIO = measure.getBoundingClientRect().width / 200;
const wide = mark.parentElement.clientWidth;
// Solve size * RATIO - size * 0.5 = wide: the trailing letter-space is empty, so
// only the glyphs need to land on the edge.
if (wide) mark.style.fontSize = (wide / (RATIO - 0.5)).toFixed(2) + "px";
}
function textRect(el) {
const r = document.createRange();
r.selectNodeContents(el);
return r.getBoundingClientRect();
}
/* One run of the intro ---------------------------------------------------- */
let fontsOk = 0;
let run = 0;
let current = null; // the live run's state
let photoIndex = -1;
let timers = [];
const later = (fn, ms) => timers.push(setTimeout(fn, ms));
function play() {
const id = ++run;
timers.forEach(clearTimeout);
timers = [];
const loader = template.content.firstElementChild.cloneNode(true);
const loadMark = loader.querySelector(".ci-mark");
const loadBar = loader.querySelector(".ci-rule > i");
const loadPct = loader.querySelector(".ci-pct > span");
stage.querySelectorAll(".ci-loader").forEach((el) => el.remove());
stage.append(loader);
wordmark.style.opacity = "0";
fitMark(loadMark);
photoIndex = (photoIndex + 1) % PHOTOS.length;
const media = loadPhoto(PHOTOS[photoIndex]);
const state = { id, loader, loadMark, done: false, shown: 0, t0: performance.now(), prev: performance.now() };
current = state;
function stepIntro(now, dt) {
const el = now - state.t0;
// Capped by elapsed time, so a warm cache cannot flash the intro away.
let want = Math.min(media.progress() * MEDIA_WEIGHT + fontsOk * FONT_WEIGHT, el / MIN_MS);
if (el > MAX_MS) want = 1;
state.shown += (want - state.shown) * (1 - Math.exp(-PROGRESS_EASE * dt));
if (want >= 1 && state.shown > 0.995) state.shown = 1;
const pct = Math.round(state.shown * 100);
loadBar.style.transform = "scaleX(" + state.shown.toFixed(4) + ")";
loadPct.textContent = pct;
loader.setAttribute("aria-valuenow", pct);
if (state.shown >= 1) reveal();
}
function finish() {
if (id !== run) return;
loader.hidden = true;
wordmark.style.opacity = "1";
if (LOOP && !reduced) later(play, HOLD);
}
// The wordmark is the one thing both states share, so it is not replaced, it
// travels: measured against the header mark and handed over at the end of the
// move, when the two are the same size in the same place.
function reveal() {
if (state.done) return;
state.done = true;
// Show the photo under the curtain before it lifts.
if (media.url && photo.src !== media.url) photo.src = media.url;
loader.classList.add("is-out");
if (reduced) {
later(finish, REDUCED_FINISH_AT);
return;
}
const scale = parseFloat(getComputedStyle(wordmark).fontSize) / parseFloat(getComputedStyle(loadMark).fontSize);
const from = textRect(loadMark);
const to = textRect(wordmark);
// Scaling happens about the element's own top-left, but what has to land on
// target is the text, and with line-height .78 the glyph box sits well above the
// element box. Solve in origin space: offset = (text - origin) * (scale - 1).
const org = loadMark.getBoundingClientRect();
const dx = to.left - org.left - scale * (from.left - org.left);
const dy = to.top - org.top - scale * (from.top - org.top);
loadMark.animate(
[
{ transform: "none" },
{ transform: "translate3d(" + dx.toFixed(2) + "px," + dy.toFixed(2) + "px,0) scale(" + scale.toFixed(4) + ")" },
],
{ duration: FLIP_DURATION, delay: FLIP_DELAY, easing: FLIP_EASE, fill: "forwards" }
);
later(finish, FINISH_AT);
}
function tick(now) {
if (id !== run || state.done) return;
const dt = Math.min(Math.max((now - state.prev) / 1000, 0), 0.1);
state.prev = now;
stepIntro(now, dt);
if (!state.done) requestAnimationFrame(tick);
}
// Swap the photo in while the curtain is still closed, once it has loaded.
media.done.then(() => {
if (id === run && !state.done && media.url) photo.src = media.url;
});
requestAnimationFrame(tick);
}
// Refit the intro mark on resize, until it starts to travel.
function refit() {
if (current && !current.done) fitMark(current.loadMark);
}
window.addEventListener("resize", () => requestAnimationFrame(refit));
stage.addEventListener("click", play);
// Start once the font is ready, so the fit is measured in the real face.
const fontReady = document.fonts ? document.fonts.ready : Promise.resolve();
Promise.race([fontReady, new Promise((r) => setTimeout(r, 1500))]).finally(() => {
fontsOk = 1;
RATIO = null;
stage.classList.add("is-ready");
if (run === 0) play();
});
})();
</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>
원본 구현 프롬프트
# Curtain Intro: letters tip up out of a blur, a real progress bar fills, then columns lift and the wordmark flies into the header
**Goal:** build a page-loader sequence where the brand wordmark's letters tip up out of a blur while its tracking spreads, a hairline bar shows real loading progress, and at 100% six columns lift off the screen on a stagger to reveal the page while the wordmark flies (FLIP) into its small spot in the header.
## Visual behaviour
1. **Start:** a full-screen loader layered over the page. It consists of six equal vertical columns filling the screen (white, the loader's "curtain"), a very faint radial glow drifting slowly behind the centre, and a centred content block: the wordmark, a hairline rule, and a meta row (a short uppercase label on the left, a percentage on the right). The page behind (here a full-screen photo) and the small header wordmark (centred at the top, invisible) are already in place.
2. **Letters (CSS, from 0 s):** each letter is its own inline-block and animates from `opacity: 0; transform: translateY(0.8em) rotateX(-62deg); filter: blur(11px)` to its rest state over **1.15 s**, `cubic-bezier(0.16, 0.84, 0.3, 1)`, with a delay of **120 ms + index × 62 ms**. The content block has `perspective: 900px`, so the letters visibly tip up towards the viewer.
3. **Spread (at the same time):** the whole word's `letter-spacing` animates from **0.04em to 0.5em** over **1.9 s**, `cubic-bezier(0.2, 0.7, 0.2, 1)`. The word is centred and fitted so that, at 0.5em, its glyphs exactly fill the content block's width.
4. **Rule and meta:** the rule rises in at **0.95 s** and the meta row at **1.1 s**: each from `opacity 0; translateY(14px)` over **0.9 s**, `cubic-bezier(0.16, 0.84, 0.3, 1)`.
5. **Progress:** the black fill inside the rule (`scaleX`, origin left) and the percentage follow **real** loading progress:
- `want = min(imageProgress × 0.88 + fontsReady × 0.12, elapsed / 2000ms)`. Image progress is bytes received / content-length while streaming (capped at 0.99), and 1 once decoded.
- After **9 s**, force `want = 1` so a slow connection never strands the visitor.
- Smooth it every frame: `shown += (want - shown) × (1 - e^(-4·dt))` (dt in seconds). Snap to 1 when `want` is 1 and `shown > 0.995`.
- Result: the bar never completes in under 2 s (a warm cache can't flash the intro away), and it eases into 100%.
6. **Reveal (at 100%):** add an `is-out` state:
- The six columns translate to `translateY(-101%)` over **1 s**, `cubic-bezier(0.76, 0, 0.24, 1)`, each delayed **65 ms** more than the previous (left to right). The loader itself is transparent, so the page appears behind the rising columns.
- The rule and meta drop away: opacity 0 over 0.4 s and `translateY(16px)` over 0.5 s, `cubic-bezier(0.76, 0, 0.24, 1)`.
- The glow fades out over 0.6 s.
7. **Wordmark flight (FLIP):** measure the intro wordmark's text box and the header wordmark's text box (use a Range over the text, not the element box). Scale = header font size / intro font size. With `transform-origin: 0 0`, compute the translate so the **text** lands exactly on the header text: `dx = to.left - origin.left - scale × (from.left - origin.left)` (same for y). Animate `transform: none → translate(dx, dy) scale(s)` over **1050 ms** with a **200 ms** delay, `cubic-bezier(0.72, 0, 0.16, 1)`, `fill: forwards`.
8. **Hand-off:** **1290 ms** after the reveal starts, hide the loader and show the static header wordmark. They are identical in face, size, tracking and line box, so nothing jumps.
9. **Replay** (for a demo): rebuild the loader from a template (so every CSS animation restarts) and run again. Cancel timers and stale frame loops by run id.
## Exact values
| What | Value |
|---|---|
| Letters | from y 0.8em, rotateX −62°, blur 11px, opacity 0 · 1.15 s · `cubic-bezier(.16,.84,.3,1)` · delay 120 ms + 62 ms × i |
| Spread | letter-spacing 0.04em → 0.5em · 1.9 s · `cubic-bezier(.2,.7,.2,1)` |
| Perspective | 900px on the content block |
| Rule / meta in | at 0.95 s / 1.1 s · 0.9 s · from y 14px |
| Progress | min run 2000 ms · max 9000 ms · weights 0.88 image / 0.12 fonts · smoothing rate 4 |
| Columns | 6 · translateY −101% · 1 s · `cubic-bezier(.76,0,.24,1)` · 65 ms stagger |
| Flight | 1050 ms · delay 200 ms · `cubic-bezier(.72,0,.16,1)` |
| Hand-off | 1290 ms after the reveal |
| Wordmark | uppercase, weight 400, letter-spacing 0.5em, line-height 0.78; intro width `min(40vw, 560px)` (70vw on phones); header size `clamp(16px, 1.5vw, 22px)` with `text-indent: 0.5em` |
| Meta row | uppercase, weight 500, `clamp(9px, 0.67vw, 11px)`, letter-spacing 0.25em, tabular numbers; the "%" sign at 45% opacity |
| Glow | `radial-gradient(38% 46% at 50% 50%, rgb(0 0 0 / .045), transparent 70%)` on an element at `inset: -30%`, drifting `translate(-13%, -6%) scale(1)` ↔ `translate(13%, 6%) scale(1.18)` over 9 s ease-in-out, infinite |
## Markup
```html
<div class="stage">
<img class="page-photo" src="…" alt="">
<div class="header-wordmark">Studio</div> <!-- absolute, top 40px, centred, opacity 0 -->
<div class="loader" role="progressbar" aria-valuemin="0" aria-valuemax="100">
<div class="panels"><i style="--p:0"></i> … <i style="--p:5"></i><span class="glow"></span></div>
<div class="content"> <!-- centred, fixed width -->
<div class="mark"><span style="--i:0">S</span><span style="--i:1">T</span> …</div>
<div class="rule"><i></i></div>
<div class="meta"><span>Quiet motion, carefully made</span><span class="pct"><span>0</span><em>%</em></span></div>
</div>
</div>
</div>
```
- Columns: `display: flex` with `flex: 1`, overlapping by 1px (`margin-right: -1px`) so no sliver of the page shows between them.
- Fit the intro wordmark: measure the word at a known size with 0.5em tracking to get the width per px of font size (`ratio`), then `fontSize = width / (ratio - 0.5)` (the trailing letter-space is excluded). Refit on resize until the flight starts.
- Keep the stage hidden until the font is loaded (max 1.5 s), so the fit uses the real face.
## Libraries
None needed: CSS keyframes and transitions for the letters, spread, columns and drop-away; `fetch` with a streamed body reader for real progress; the Web Animations API (`element.animate`) for the FLIP. Fall back to a plain image load if streaming isn't available.
## Responsive and reduced motion
- Below 760px: hide the label (keep the percentage), tighter meta gap, header wordmark at top 32px. Below 640px: the intro wordmark block widens to 70vw.
- `prefers-reduced-motion: reduce`: no letter, spread, rise or glow animation (everything visible at once); the columns don't move. When loading completes, the whole loader fades out over 0.4 s linearly, and after 420 ms the header wordmark is shown.
## Acceptance criteria
- Letters tip up one by one out of a blur while the word visibly widens; the glyphs end exactly flush with the content width.
- The bar and percentage reflect real download progress, never finish in under 2 s, and never take longer than 9 s.
- At 100%, six columns lift away left to right on a 65 ms stagger, revealing the page underneath as they rise.
- The wordmark travels and shrinks into the header position and the static header wordmark takes over with no visible jump.
- Replaying restarts every animation from the beginning.
- With reduced motion, the loader simply fades away once loading completes.
보관·활용 메모
Cadence에서 제공하는 코드와 프롬프트, 미리보기 영상을 이 사이트에 보관했습니다. 코드는 다운로드한 원본으로 제공하며 실행 화면은 별도로 격리되어 있습니다. 코드의 외부 라이브러리·샘플 이미지 요청은 유지됩니다.
보관일: 2026-10-26 · 프로젝트 적용: 별도 검증 필요
원본 코드·프롬프트·제작자 정보를 함께 보관했습니다. 실제 납품 시 코드와 포함 소재의 이용 조건을 확인하세요.
코드 SHA-256: 8db95782685d006e3fb1dd1481871f4a87c461e942895e7f2e15d2412b4fecda