모션·인터랙션 / Cadence
Orbit Loader
로딩 · 갤러리 · 텍스트 모션. 미리보기와 원본 코드·프롬프트를 이곳에서 확인하세요.
미리보기 안에서 스크롤하거나 마우스를 움직여 모션을 확인하세요.
HTML · CSS · JavaScript 전체 코드
<!doctype html>
<!--
Orbit Loader
A page-loader opening. One photograph fades in at the centre (it is the top of a
stack of twelve). The stack then unfolds outward into an evenly spaced ring, each
photo rotated to follow the circle and shrinking to half size, while a wordmark fades
in at the centre. Once formed, the ring keeps orbiting slowly. Click to replay.
Dependencies: none (plain HTML, CSS and JS).
Tweak:
- N: number of photos (12) · SRC: the photos (the last one is the opening photograph)
- T_FADE_IN 950 ms (opening fade, cubic-bezier(0.33, 0, 0.15, 1)) · T_HOLD 260 ms
- T_SPREAD 1750 ms per photo, T_STAGGER 45 ms apart, quartic ease-out (1 - (1 - t)^4)
- T_MARK: the wordmark fades in 350 ms after the unfolding begins (0.7s)
- OMEGA_IDLE: orbit speed, 9°/s, eased in over OMEGA_RAMP (700 ms)
- Radius: max(min(W × 0.34, H × 0.52), min(W × 0.5, H × 0.3)), clamped to stay on screen
- RING_TILE: ring tile width = 0.235 × radius (0.27 below 760px) · HERO_SCALE: the
opening photo is 2× a ring tile
- REPLAY_FADE: 450 ms fade-out before a replay
Based on the loading sequence of the Studio Palette site (own project).
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Orbit 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;
--ol-ease: cubic-bezier(0.33, 0, 0.15, 1);
}
.ol-stage {
position: fixed;
inset: 0;
overflow: hidden;
cursor: pointer; /* click replays the intro */
}
.ol-ring {
position: absolute;
inset: 0;
transition: opacity 0.4s var(--ol-ease); /* fade-out before a replay */
}
.ol-ring.is-out { opacity: 0; }
.ol-tile {
position: absolute;
top: 0;
left: 0;
overflow: hidden;
opacity: 0;
will-change: transform, opacity;
backface-visibility: hidden;
/* The box is built at the opening photo's size and scaled down 1/2 into the ring,
so 12px here reads as 6px on the ring tiles. */
border-radius: 12px;
}
/* The opening photograph: on top of the stack and the only one faded up. The other
eleven stay at opacity 0 behind it until it is fully opaque, so the fade reads as
one photograph, never as a stack of transparencies. */
.ol-tile--lead { z-index: 1; }
.ol-tile img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
pointer-events: none;
user-select: none;
-webkit-user-drag: none;
}
/* The wordmark, centred over the ring */
.ol-loader {
position: fixed;
inset: 0;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: opacity 0.7s var(--ol-ease);
}
.ol-loader.is-in { opacity: 1; }
.ol-mark {
margin: 0;
font-weight: 500;
font-size: clamp(29px, 7.5vw, 72px);
line-height: 1;
letter-spacing: -0.04em;
}
</style>
</head>
<body>
<div class="ol-stage" id="stage" aria-label="Orbit loader">
<div class="ol-ring" id="ring"></div>
</div>
<div class="ol-loader" id="loader" aria-hidden="true">
<p class="ol-mark">Studio</p>
</div>
<script>
(() => {
"use strict";
/* Constants ----------------------------------------------------------------- */
const N = 12;
const STEP = 360 / N; // angular spacing around the ring
const T_FADE_IN = 950; // ms, the single centred photograph appears
const T_HOLD = 260; // …and holds before the stack opens
const T_SPREAD = 1750; // the unfolding into the ring
const T_STAGGER = 45; // per-photo offset within the unfolding
const T_MARK = 350; // the wordmark fades in this long after the unfolding begins
const FADE_EASE = "cubic-bezier(0.33, 0, 0.15, 1)";
const OMEGA_IDLE = 9; // deg/s, slow enough that you notice it late
const OMEGA_RAMP = 700; // ms
const RING_TILE = 0.235; // ring tile width as a fraction of the radius
const RING_TILE_S = 0.27; // …a touch larger where the ring is small (< 760px)
const HERO_SCALE = 2.0; // the stacked photograph is twice a ring tile
const MARGIN_X = 16; // px, the ring (plus half a tile) stays this far from the sides
const MARGIN_Y = 72; // px, …and this far from the top and bottom (room for a header)
const REPLAY_FADE = 450; // ms for the ring to fade out before a replay
/* Photos (3:4): tile i sits at -i × STEP in the ring; the LAST one is the opening
photograph on top of the stack. Swap the picsum.photos placeholders for your own. */
const SRC = Array.from({ length: N }, (_, i) => `https://picsum.photos/seed/cadence-${i + 1}/600/800`);
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
/* Helpers ------------------------------------------------------------------- */
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
const lerp = (a, b, t) => a + (b - a) * t;
const easeOut = (t) => 1 - Math.pow(1 - t, 4); // quartic, soft landing
const rad = (d) => (d * Math.PI) / 180;
/* Build the tiles ----------------------------------------------------------- */
const stage = document.getElementById("stage");
const ring = document.getElementById("ring");
const loader = document.getElementById("loader");
const tiles = SRC.map((src, i) => {
const node = document.createElement("div");
node.className = "ol-tile";
const img = document.createElement("img");
img.src = src;
img.alt = "";
img.width = 600;
img.height = 800;
node.appendChild(img);
ring.appendChild(node);
return { node, img, i, base: -i * STEP };
});
const lead = tiles[N - 1];
lead.node.classList.add("ol-tile--lead");
/* Geometry ------------------------------------------------------------------ */
let R = 0, TW = 0, CX = 0, CY = 0;
let SW = 0, SH = 0; // the tile's own box (= the opening photograph)
let RING_S = 1, HERO_S = 1; // ring and opening scales, relative to the box
function measure() {
const W = stage.clientWidth || innerWidth;
const H = stage.clientHeight || innerHeight;
// Wide screens are height-limited, tall ones width-limited; the larger of the two
// keeps the ring generous enough to hold the wordmark either way.
R = Math.max(Math.min(W * 0.34, H * 0.52), Math.min(W * 0.5, H * 0.3));
TW = R * (W < 760 ? RING_TILE_S : RING_TILE);
// Keep the whole ring (radius + half a 3:4 tile) on screen.
const tileHalf = (TW * 4) / 3 / 2;
const maxR = Math.min(W / 2 - MARGIN_X - tileHalf, H / 2 - MARGIN_Y - tileHalf);
if (R > maxR) {
R = maxR;
TW = R * (W < 760 ? RING_TILE_S : RING_TILE);
}
CX = W / 2;
CY = H / 2;
// The box is the opening photograph, scaled down into the ring, so no bitmap is
// ever shown above its laid-out size.
SW = TW * HERO_SCALE;
SH = (SW * 4) / 3;
HERO_S = 1;
RING_S = 1 / HERO_SCALE;
for (const t of tiles) {
t.node.style.width = SW + "px";
t.node.style.height = SH + "px";
}
}
const xf = (x, y, deg, s) =>
`translate3d(${x - SW / 2}px, ${y - SH / 2}px, 0) rotate(${deg}deg) scale(${s})`;
/* The sequence -------------------------------------------------------------- */
let phase = 0; // accumulated orbit rotation, degrees
let omega = 0;
let start = 0;
let last = 0;
let opened = false; // have the other eleven been un-hidden yet
let lit = false; // has the wordmark been brought up yet
let run = 0; // bumped on every replay, so a stale frame loop stops
let replayTimer = 0;
function place(t) {
const s0 = T_FADE_IN + T_HOLD;
for (const tile of tiles) {
const p = clamp((t - s0 - tile.i * T_STAGGER) / T_SPREAD, 0, 1);
const e = easeOut(p);
const a = tile.base * e + phase; // ring angle, degrees
const r = R * e;
const s = lerp(HERO_S, RING_S, e);
tile.node.style.transform = xf(CX + r * Math.cos(rad(a)), CY + r * Math.sin(rad(a)), a * e, s);
}
}
function tick(now, id) {
if (id !== run) return;
const dt = Math.min(now - last, 64) / 1000;
last = now;
const t = now - start;
const s0 = T_FADE_IN + T_HOLD; // unfolding begins
const s1 = s0 + T_SPREAD + T_STAGGER * (N - 1); // …and ends
// Once the ring has formed, ease the angular speed up to OMEGA_IDLE and orbit.
if (t > s1) {
omega += (OMEGA_IDLE - omega) * clamp(((dt * 1000) / OMEGA_RAMP) * 3, 0, 1);
phase += omega * dt;
}
// The stack is only ever more than one photograph deep once the lead is fully
// opaque and the photos are about to move apart.
if (!opened && t >= s0) {
opened = true;
for (const tile of tiles) tile.node.style.opacity = "1";
}
if (!lit && t >= s0 + T_MARK) {
lit = true;
loader.classList.add("is-in");
}
place(t);
requestAnimationFrame((n) => tick(n, id));
}
// One clock for the whole opening: the CSS fade and the rAF ring start in the same frame.
function begin() {
const id = ++run;
lead.node.style.transition = `opacity ${T_FADE_IN}ms ${FADE_EASE}`;
requestAnimationFrame((now) => {
if (id !== run) return;
lead.node.style.opacity = "1";
start = last = now;
requestAnimationFrame((n) => tick(n, id));
});
}
// Back to the closed stack, invisible.
function reset() {
run++;
phase = 0;
omega = 0;
opened = false;
lit = false;
measure();
for (const t of tiles) {
t.node.style.transition = "none";
t.node.style.opacity = "0";
t.node.style.transform = xf(CX, CY, 0, HERO_S);
}
void ring.offsetWidth; // commit the reset before the next fade starts
}
function replay() {
clearTimeout(replayTimer);
ring.classList.add("is-out");
loader.classList.remove("is-in");
replayTimer = setTimeout(() => {
reset();
ring.style.transition = "none";
ring.classList.remove("is-out");
void ring.offsetWidth;
ring.style.transition = "";
begin();
}, REPLAY_FADE);
}
/* Start --------------------------------------------------------------------- */
measure();
addEventListener("resize", () => {
measure();
if (reduced) place(Infinity);
});
if (reduced) {
// No sequence: the ring is formed and still, the wordmark is up.
for (const t of tiles) t.node.style.opacity = "1";
place(Infinity);
loader.classList.add("is-in");
} else {
reset();
stage.addEventListener("click", replay);
// Hold the sequence until the photographs are decoded (max 4 s), so the first
// fade is a photograph and not an empty frame.
const warm = tiles.map((t) => (t.img.decode ? t.img.decode().catch(() => {}) : Promise.resolve()));
Promise.race([Promise.all(warm), new Promise((r) => setTimeout(r, 4000))]).then(begin);
}
})();
</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>
원본 구현 프롬프트
# Orbit Loader: a stack of photos unfolds into a slowly orbiting ring around a wordmark
**Goal:** build a page-loader opening where a single photograph fades in at the centre of the screen, then turns out to be the top of a stack of twelve photos that unfold outward into an evenly spaced ring, each photo tilted to follow the circle, while a wordmark fades in at the centre and the ring keeps orbiting slowly.
## Visual behaviour
1. **Start:** a plain white, full-screen stage. Twelve 3:4 photos are stacked exactly on top of each other at the centre of the viewport, all invisible.
2. **Opening fade (0 → 950 ms):** only the top photo fades from opacity 0 to 1 over **950 ms** with `cubic-bezier(0.33, 0, 0.15, 1)`. The other eleven stay at opacity 0 underneath, so the fade reads as one photograph, never as a pile of semi-transparent images.
3. **Hold (260 ms):** the single photo sits still.
4. **Unfold (from 1210 ms):** the moment the unfolding begins, all twelve are made fully opaque (they are hidden under the top one, so nothing visibly changes). Then each photo travels from the centre to its slot on the ring:
- Photo `i` (0–11) has a ring angle of `-i × 30°` (evenly spaced, counter-clockwise). The top photo of the stack is photo 11.
- Each photo animates over **1750 ms**, starting **45 ms** after the previous one (photo 0 first, photo 11 last; total about 2.25 s).
- Easing: quartic ease-out, `e = 1 - (1 - p)^4` (≈ `cubic-bezier(0.25, 1, 0.5, 1)`).
- With that eased value `e`, the photo's angle is `angle × e`, its distance from the centre is `radius × e`, its **rotation is also `angle × e`** (so it ends tilted to match its position on the circle), and its scale goes from 1 to 0.5 (the stacked photo is twice the size of a ring tile).
- Because both the angle and the radius grow together, the photos sweep out in a spiral rather than a straight line.
5. **Wordmark:** 350 ms after the unfolding begins, a centred wordmark fades in over **0.7 s** with `cubic-bezier(0.33, 0, 0.15, 1)`. It sits above the photos.
6. **Orbit:** once the last photo has landed, the whole ring starts to rotate clockwise, continuously. The angular speed eases up from 0 to **9°/s** (each frame: `omega += (9 - omega) × min(dt_ms / 700 × 3, 1)`), so it starts so gently you only notice the movement late. The rotation is added to every photo's angle and tilt, so the tiles stay tilted to the circle as it turns.
7. **Replay** (click anywhere): the ring fades to opacity 0 over 0.4 s and the wordmark fades out, then after **450 ms** everything snaps back to the invisible stack and the sequence plays again from the opening fade. Rapid clicks must not stack runs (cancel the previous frame loop and timer).
## Geometry
- Viewport `W × H`. Radius `R = max(min(W × 0.34, H × 0.52), min(W × 0.5, H × 0.3))`.
- Ring tile width `TW = R × 0.235` (or `R × 0.27` when `W < 760px`). Tiles are 3:4, so height is `TW × 4/3`.
- Keep the whole ring on screen: if `R` is larger than `min(W/2 - 16 - tileHalf, H/2 - 72 - tileHalf)` (with `tileHalf = TW × 4/3 / 2`), clamp it to that and recompute `TW`. The 72px leaves room for a header; use a smaller margin if there is none.
- Build each tile's box at the **opening** size (`2 × TW` wide) and scale it down to 0.5 in the ring, so the image is never upscaled. Corner radius 12px on the box reads as 6px on the ring tiles.
- Position with one transform per tile: `translate3d(x - boxW/2, y - boxH/2, 0) rotate(deg) scale(s)`, where `x = CX + r·cos(a)`, `y = CY + r·sin(a)` around the viewport centre.
## Exact values
| What | Value |
|---|---|
| Photos | 12, 3:4, `object-fit: cover` |
| Opening fade | 950 ms, `cubic-bezier(0.33, 0, 0.15, 1)`, top photo only |
| Hold | 260 ms |
| Unfold | 1750 ms per photo, 45 ms stagger, quartic ease-out |
| Ring spacing | 30° (360 / 12), angle `-i × 30°` |
| Tile scale | 1 (stacked) → 0.5 (ring) |
| Wordmark | fades in 350 ms after the unfold starts, 0.7 s |
| Orbit | eases up to 9°/s, ramp constant 700 ms |
| Replay | 0.4 s fade-out, restart after 450 ms |
| Wordmark type | weight 500, `clamp(29px, 7.5vw, 72px)`, line-height 1, letter-spacing -0.04em |
## Markup
```html
<div class="stage"> <!-- fixed, full screen, overflow hidden, click to replay -->
<div class="ring"> <!-- absolute, inset 0; holds the 12 tiles -->
<div class="tile"><img src="…" alt=""></div> … (12, the last is the opening photo, z-index 1)
</div>
</div>
<div class="wordmark-layer"><p>Studio</p></div> <!-- fixed, centred, above the ring, pointer-events none -->
```
Tiles are `position: absolute; top: 0; left: 0; overflow: hidden; opacity: 0; will-change: transform, opacity`.
## Implementation
- No library needed: one `requestAnimationFrame` loop computes every tile's transform from the time since the opening fade began. The opening fade itself is a CSS opacity transition, started in the same frame as the clock, so they stay in sync.
- Wait for the photos to decode (`img.decode()`, with a 4 s fallback) before starting, so the first fade shows a photo and not an empty box.
- Recompute the geometry on resize.
## Responsive and reduced motion
- The radius formula covers both wide and tall screens; below 760px wide the tiles are a little larger relative to the ring.
- `prefers-reduced-motion: reduce`: no sequence and no orbit. Show the formed ring (all photos visible, in their tilted slots) with the wordmark already visible.
## Acceptance criteria
- The first thing visible is one photo fading in at the centre, with no sign of the photos beneath it.
- After a short hold the stack spirals open into an even ring of twelve tilted photos, in sequence, shrinking to half size, with a soft landing.
- The wordmark fades in shortly after the unfolding starts and stays centred inside the ring.
- After forming, the ring rotates clockwise very slowly (9°/s), with no jolt as the rotation starts.
- The ring is always fully on screen and never overlaps the wordmark.
- Clicking fades everything out and replays cleanly, even with rapid clicks.
- With reduced motion, the formed ring and wordmark appear immediately and stay still.
보관·활용 메모
Cadence에서 제공하는 코드와 프롬프트, 미리보기 영상을 이 사이트에 보관했습니다. 코드는 다운로드한 원본으로 제공하며 실행 화면은 별도로 격리되어 있습니다. 코드의 외부 라이브러리·샘플 이미지 요청은 유지됩니다.
보관일: 2026-10-26 · 프로젝트 적용: 별도 검증 필요
원본 코드·프롬프트·제작자 정보를 함께 보관했습니다. 실제 납품 시 코드와 포함 소재의 이용 조건을 확인하세요.
코드 SHA-256: 739f2e1a9aa6438238dd97d90e79f3fd6c619fe0f71f4f303dfce148adc59ef2