모션·인터랙션 / Cadence
Clip-Path Reveals
스크롤 · 로딩 · 이미지 모션. 미리보기와 원본 코드·프롬프트를 이곳에서 확인하세요.
미리보기 안에서 스크롤하거나 마우스를 움직여 모션을 확인하세요.
HTML · CSS · JavaScript 전체 코드
<!doctype html>
<!--
Clip-Path Reveals
A reveal kit built on clip-path transitions, on an editorial page:
- Intro veil: a full-screen grey veil; the wordmark's characters rise in (staggered),
the mark drifts up and fades, then the veil lifts away: clip-path inset(0 0 0 0) → inset(0 0 100% 0).
- Hero: the left photo opens like a shutter from the bottom (inset(100% 0 0 0) → 0) while
it scales down from 1.2; the framed photo on the right opens through an aperture from its
centre (inset(50%) → 0) from 1.35, 0.75s later; the hero wordmark's characters rise in.
- Section labels wipe in from the left (inset(0 100% 0 0) → 0).
- Cards: the photo rises like a blind, the text block wipes down beneath it and its lines
follow, staggered per card (120ms) and per line (90ms).
- Split: an aperture thumbnail, copy that rises in, and a portrait that wipes in from the left.
Scroll reveals use an IntersectionObserver and close again once an element is back below the
viewport, so they replay when you scroll down again. Click the hero to replay the intro.
Dependencies: none (plain HTML, CSS and JS).
Tweak:
- Easings: --inout cubic-bezier(0.76, 0, 0.24, 1) for every clip-path, --out
cubic-bezier(0.16, 1, 0.3, 1) for scales, rises and fades
- Veil: characters rise 1.1s from 0.2s + 0.09s each; mark fades 0.9s at 1.45s; veil lifts 1.1s at 1.55s
- Hero: HERO_DELAY_INTRO 1450ms (opens as the veil lifts); shutter 1.5s; aperture 1.5s at +0.75s;
photo scales 3.4s (1.2 → 1) and 2.8s (1.35 → 1); wordmark characters 1.3s from 0.45s + 0.08s each
- Cards: photo blind 1.3s, block wipe 1.1s at +0.5s, lines 0.9s at +0.85s + 90ms each, 120ms per card
- REVEAL_MARGIN: "0px 0px -10% 0px" (reveals start 10% above the bottom of the viewport)
- REPLAY_ON_REENTER: false to reveal each element only once
- PLAY_VEIL: false to skip the veil (the hero opens after 120ms instead)
From the Aiko site (Aiko, own project).
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Clip-Path Reveals</title>
<script>
// Hide the reveal targets before first paint, and start the intro veil (not with reduced motion).
(function (d) {
d.classList.add("js");
if (!matchMedia("(prefers-reduced-motion: reduce)").matches) d.classList.add("intro");
})(document.documentElement);
</script>
<style>
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
background: #fff;
color: #000;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
}
h1, h2, h3, p { margin: 0; font-weight: 400; }
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.eyebrow {
font-size: 12px;
font-weight: 500;
letter-spacing: 0.25em;
line-height: 1.6;
text-transform: uppercase;
color: #8e8e8e;
}
.cr {
--ease: cubic-bezier(0.22, 0.61, 0.36, 1);
--out: cubic-bezier(0.16, 1, 0.3, 1);
--inout: cubic-bezier(0.76, 0, 0.24, 1);
--surface: #efefef;
--radius: 6px;
--gutter: clamp(16px, 2.5vw, 32px);
--gap: clamp(16px, 2.4vw, 40px);
max-width: 1440px;
margin: 0 auto;
padding: var(--gutter) var(--gutter) 18vh;
}
.cr img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.cr figure { margin: 0; }
/* ---------- 1. Hero ---------- */
.cr-hero {
--hero-h: max(80vh, min(54vw, 100vh));
position: relative;
display: grid;
grid-template-columns: 1fr 1fr;
height: var(--hero-h);
overflow: hidden;
border-radius: var(--radius);
background: var(--surface);
cursor: pointer; /* a click replays the intro */
}
.cr-hero__media { overflow: hidden; }
.cr-hero__media img { object-position: 50% 22%; }
.cr-hero__aside {
display: flex;
align-items: center;
justify-content: center;
}
.cr-hero__frame {
width: min(58%, calc(var(--hero-h) * 0.64 / 1.2));
aspect-ratio: 5 / 6;
overflow: hidden;
border-radius: var(--radius);
}
.cr-hero__frame img { object-position: 45% 50%; }
.cr-hero__mark {
position: absolute;
top: 40px;
left: 0;
right: 0;
font-size: clamp(34px, 3.4vw, 50px);
line-height: 1;
letter-spacing: -0.03em;
text-align: center;
pointer-events: none;
}
/* ---------- 2. Cards ---------- */
.cr-section { padding-block: clamp(64px, 7vw, 112px); }
.cr-section > .eyebrow { width: fit-content; margin-bottom: 40px; }
.cr-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--gap);
margin: 0;
padding: 0;
list-style: none;
}
.cr-card {
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: var(--radius);
}
.cr-card__media { aspect-ratio: 8 / 9; overflow: hidden; }
.cr-card__body {
flex: 1;
padding: 24px;
background: var(--surface);
}
.cr-card__title {
display: inline-block;
padding-bottom: 2px;
font-size: 18px;
line-height: 1.3;
/* hover underline that draws under the title */
background: linear-gradient(currentColor, currentColor) 0 100% / 0 1px no-repeat;
transition: background-size 0.8s var(--out);
}
.cr-card:hover .cr-card__title { background-size: 100% 1px; }
.cr-card__body .eyebrow { margin-top: 6px; font-size: 10px; }
.cr-card__text {
max-width: 36em;
margin-top: 16px;
font-size: clamp(14px, 1.1vw, 16px);
line-height: 1.6;
}
/* ---------- 3. Split ---------- */
.cr-split {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--gap);
height: clamp(560px, 54vw, 900px);
}
.cr-split__panel {
display: flex;
flex-direction: column;
justify-content: space-between;
padding: clamp(24px, 3vw, 48px);
border-radius: var(--radius);
background: var(--surface);
}
.cr-split__thumb {
width: clamp(120px, 14vw, 200px);
aspect-ratio: 3 / 4;
overflow: hidden;
border-radius: var(--radius);
}
.cr-split__text { max-width: 30em; }
.cr-split__copy {
margin-top: 20px;
font-size: clamp(18px, 1.6vw, 24px);
line-height: 1.5;
}
.cr-split__media { overflow: hidden; border-radius: var(--radius); }
/* ================= Motion ================= */
/* --- Intro veil --- */
.cr-veil { display: none; }
html.intro .cr-veil {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
background: #e6e6e6;
pointer-events: none;
clip-path: inset(0 0 0 0);
animation: cr-veil-lift 1.1s cubic-bezier(0.76, 0, 0.24, 1) 1.55s forwards;
}
.cr-veil__mark {
font-size: clamp(56px, 8vw, 128px);
line-height: 1;
letter-spacing: -0.03em;
animation: cr-veil-mark 0.9s cubic-bezier(0.76, 0, 0.24, 1) 1.45s forwards;
}
.cr-veil .w,
.cr-hero__mark .w {
display: inline-block;
overflow: hidden;
vertical-align: top;
padding: 0 0.03em 0.1em;
}
.cr-veil .ch {
display: inline-block;
transform: translateY(110%);
animation: cr-veil-rise 1.1s cubic-bezier(0.16, 1, 0.3, 1) forwards;
animation-delay: calc(0.2s + var(--c) * 0.09s);
}
@keyframes cr-veil-rise { to { transform: none; } }
@keyframes cr-veil-mark { to { transform: translateY(-40px); opacity: 0; } }
@keyframes cr-veil-lift { to { clip-path: inset(0 0 100% 0); } }
/* --- Generic reveal: fade and rise --- */
html.js .reveal {
opacity: 0;
transform: translateY(24px);
transition: opacity 0.9s var(--out), transform 0.9s var(--out);
transition-delay: calc(var(--i, 0) * 90ms);
}
html.js .reveal--none { opacity: 1; transform: none; }
html.js .reveal.is-in { opacity: 1; transform: none; }
/* once a reveal has played, hover transitions respond without stagger delays */
html.js .settled,
html.js .settled * { transition-delay: 0s !important; }
/* Section labels wipe in from the left */
html.js .eyebrow.reveal {
opacity: 1;
transform: none;
clip-path: inset(0 100% 0 0);
transition: clip-path 1.3s var(--inout);
}
html.js .eyebrow.reveal.is-in { clip-path: inset(0 0 0 0); }
/* --- 1. Hero: shutter, aperture and rising wordmark --- */
html.js .cr-hero__media { clip-path: inset(100% 0 0 0); }
html.js .cr-hero__media img { transform: scale(1.2); }
html.js .cr-hero__frame { clip-path: inset(50% 50% 50% 50%); }
html.js .cr-hero__frame img { transform: scale(1.35); }
html.js .cr-hero__mark .ch { display: inline-block; transform: translateY(110%); }
html.js .cr-hero.is-loaded .cr-hero__media {
clip-path: inset(0 0 0 0);
transition: clip-path 1.5s var(--inout);
}
html.js .cr-hero.is-loaded .cr-hero__media img {
transform: none;
transition: transform 3.4s var(--out);
}
html.js .cr-hero.is-loaded .cr-hero__frame {
clip-path: inset(0 0 0 0);
transition: clip-path 1.5s var(--inout) 0.75s;
}
html.js .cr-hero.is-loaded .cr-hero__frame img {
transform: none;
transition: transform 2.8s var(--out) 0.75s;
}
html.js .cr-hero.is-loaded .cr-hero__mark .ch {
transform: none;
transition: transform 1.3s var(--out);
transition-delay: calc(0.45s + var(--c) * 0.08s);
}
/* --- 2. Cards: the photo rises like a blind, the block wipes down beneath --- */
html.js .cr-card .cr-card__media {
clip-path: inset(100% 0 0 0);
transition: clip-path 1.3s var(--inout) calc(var(--i, 0) * 120ms);
}
html.js .cr-card .cr-card__media img {
transform: scale(1.25);
transition: transform 1.9s var(--out) calc(var(--i, 0) * 120ms);
}
html.js .cr-card .cr-card__body {
clip-path: inset(0 0 100% 0);
transition: clip-path 1.1s var(--inout) calc(var(--i, 0) * 120ms + 0.5s);
}
html.js .cr-card .cr-card__body > * {
opacity: 0;
transform: translateY(12px);
transition: opacity 0.9s var(--out), transform 0.9s var(--out), background-size 0.8s var(--out);
transition-delay: calc(var(--i, 0) * 120ms + 0.85s + var(--k, 0) * 90ms);
}
.cr-card__body > :nth-child(2) { --k: 1; }
.cr-card__body > :nth-child(3) { --k: 2; }
html.js .cr-card.is-in .cr-card__media,
html.js .cr-card.is-in .cr-card__body { clip-path: inset(0 0 0 0); }
html.js .cr-card.is-in .cr-card__media img { transform: none; }
html.js .cr-card.is-in .cr-card__body > * { opacity: 1; transform: none; }
html.js .cr-card.is-in:hover .cr-card__media img { transform: scale(1.06); transition-duration: 1.6s; }
/* --- 3. Split: aperture thumbnail, wipe-in portrait --- */
html.js .cr-split__thumb {
clip-path: inset(50% 50% 50% 50%);
transition: clip-path 1.4s var(--inout);
}
html.js .cr-split__thumb img { transform: scale(1.4); transition: transform 2.2s var(--out); }
html.js .cr-split__thumb.is-in { clip-path: inset(0 0 0 0); }
html.js .cr-split__thumb.is-in img { transform: none; }
html.js .cr-split__media {
clip-path: inset(0 100% 0 0);
transition: clip-path 1.7s var(--inout);
}
html.js .cr-split__media img { transform: scale(1.2); transition: transform 2.6s var(--out); }
html.js .cr-split__media.is-in { clip-path: inset(0 0 0 0); }
html.js .cr-split__media.is-in img { transform: none; }
/* While a reveal is reset (scrolled back out of view), snap without transitions */
html.js .is-resetting,
html.js .is-resetting * { transition: none !important; }
@media (prefers-reduced-motion: reduce) {
html.js *,
html.js *::before,
html.js *::after { transition: none !important; animation: none !important; }
.cr-veil { display: none !important; }
.cr-card.is-in:hover .cr-card__media img { transform: none !important; }
}
/* ---------- Responsive ---------- */
@media (max-width: 1100px) {
.cr-cards { grid-template-columns: repeat(2, 1fr); }
.cr-card:last-child { display: none; }
}
@media (max-width: 820px) {
.cr-hero { grid-template-columns: 1fr; height: auto; }
.cr-hero__mark {
position: static;
order: -1;
padding: 28px 0 24px;
font-size: 34px;
}
.cr-hero__media { aspect-ratio: 4 / 5; }
.cr-hero__aside { padding: 64px var(--gutter); }
.cr-hero__frame { width: 68%; }
.cr-section > .eyebrow { margin-bottom: 28px; }
.cr-cards { grid-template-columns: 1fr; gap: 40px; }
.cr-card:last-child { display: flex; }
.cr-card__body { padding: 20px; }
.cr-card__text { margin-top: 14px; }
.cr-split { grid-template-columns: 1fr; height: auto; }
.cr-split__panel {
gap: 72px;
padding: 32px 24px 40px;
}
.cr-split__media { aspect-ratio: 4 / 5; }
}
</style>
</head>
<body>
<!-- Intro veil: the characters rise, then the veil lifts away through clip-path. -->
<div class="cr-veil" aria-hidden="true">
<span class="cr-veil__mark"><span class="w"><span class="ch" style="--c:0">A</span><span class="ch" style="--c:1">t</span><span class="ch" style="--c:2">e</span><span class="ch" style="--c:3">l</span><span class="ch" style="--c:4">i</span><span class="ch" style="--c:5">e</span><span class="ch" style="--c:6">r</span></span></span>
</div>
<!-- Swap the picsum.photos placeholders for your own photos (3:4 portraits work best). -->
<main class="cr">
<!-- 1. Hero: shutter (left), aperture (right), rising wordmark -->
<section class="cr-hero" aria-label="Hero">
<div class="cr-hero__media">
<img src="https://picsum.photos/seed/cadence-1/1200/1600" alt="" fetchpriority="high">
</div>
<div class="cr-hero__aside">
<figure class="cr-hero__frame">
<img src="https://picsum.photos/seed/cadence-2/600/800" alt="">
</figure>
</div>
<h1 class="cr-hero__mark"><span class="visually-hidden">Atelier</span><span class="w" aria-hidden="true"><span class="ch" style="--c:0">A</span><span class="ch" style="--c:1">t</span><span class="ch" style="--c:2">e</span><span class="ch" style="--c:3">l</span><span class="ch" style="--c:4">i</span><span class="ch" style="--c:5">e</span><span class="ch" style="--c:6">r</span></span></h1>
</section>
<!-- 2. Cards: label wipe, photo blinds, text blocks wipe down, lines follow -->
<section class="cr-section">
<h2 class="eyebrow reveal">Pier in Winter</h2>
<ul class="cr-cards">
<li class="cr-card reveal reveal--none" style="--i:0">
<figure class="cr-card__media"><img src="https://picsum.photos/seed/cadence-3/600/800" alt="" loading="lazy"></figure>
<div class="cr-card__body">
<h3 class="cr-card__title">Sea Wall</h3>
<p class="eyebrow">Look 01 · Navy wool</p>
<p class="cr-card__text">A long coat cut for the wind, worn open along the harbour at first light.</p>
</div>
</li>
<li class="cr-card reveal reveal--none" style="--i:1">
<figure class="cr-card__media"><img src="https://picsum.photos/seed/cadence-4/600/800" alt="" loading="lazy"></figure>
<div class="cr-card__body">
<h3 class="cr-card__title">Layers</h3>
<p class="eyebrow">Look 02 · Cream knit</p>
<p class="cr-card__text">A soft knit under heavy wool, the coat carried over one shoulder.</p>
</div>
</li>
<li class="cr-card reveal reveal--none" style="--i:2">
<figure class="cr-card__media"><img src="https://picsum.photos/seed/cadence-5/600/800" alt="" loading="lazy"></figure>
<div class="cr-card__body">
<h3 class="cr-card__title">White Steps</h3>
<p class="eyebrow">Look 03 · Morning</p>
<p class="cr-card__text">Pale concrete, grey water and a slow walk down to the pier.</p>
</div>
</li>
</ul>
</section>
<!-- 3. Split: aperture thumbnail, copy rises, portrait wipes in from the left -->
<section class="cr-split">
<div class="cr-split__panel">
<figure class="cr-split__thumb reveal reveal--none">
<img src="https://picsum.photos/seed/cadence-6/480/640" alt="" loading="lazy">
</figure>
<div class="cr-split__text reveal">
<h2 class="eyebrow">Behind the frame</h2>
<p class="cr-split__copy">Shot over one grey morning on the north sea wall, in natural light only. Every image is a pause between two steps.</p>
</div>
</div>
<figure class="cr-split__media reveal reveal--none">
<img src="https://picsum.photos/seed/cadence-7/1200/1600" alt="" loading="lazy">
</figure>
</section>
</main>
<script>
(function () {
"use strict";
const PLAY_VEIL = true; // false: no veil, the hero opens after HERO_DELAY
const REPLAY_ON_REENTER = true; // close reveals again once they're back below the viewport
const HERO_DELAY_INTRO = 1450; // ms: the hero opens as the veil lifts
const HERO_DELAY = 120; // ms: without the veil
const INTRO_CLEAR = 2900; // ms: the veil is gone
const HERO_SETTLE = 4500; // ms after the hero opens
const REVEAL_SETTLE = 3400; // ms after a scroll reveal plays
const REVEAL_MARGIN = "0px 0px -10% 0px";
const IMAGE_TIMEOUT = 2500; // ms: open the hero anyway if the image is slow
const html = document.documentElement;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const hero = document.querySelector(".cr-hero");
const heroImg = hero.querySelector(".cr-hero__media img");
if (!PLAY_VEIL) html.classList.remove("intro");
// `.settled` clears every stagger delay a few seconds after a reveal, so hovers respond at once.
const timers = new WeakMap();
function settle(el, ms) {
clearTimeout(timers.get(el));
timers.set(el, setTimeout(() => el.classList.add("settled"), ms));
}
/* Intro: veil + hero ---------------------------------------------------- */
let introTimers = [];
function scheduleHero(withVeil) {
introTimers.push(setTimeout(() => {
requestAnimationFrame(() => {
hero.classList.add("is-loaded");
settle(hero, HERO_SETTLE);
});
}, withVeil ? HERO_DELAY_INTRO : HERO_DELAY));
if (withVeil) introTimers.push(setTimeout(() => html.classList.remove("intro"), INTRO_CLEAR));
}
// Replay: reset to the closed state without transitions and restart the veil.
function replayIntro() {
introTimers.forEach(clearTimeout);
introTimers = [];
clearTimeout(timers.get(hero));
hero.classList.add("is-resetting");
hero.classList.remove("is-loaded", "settled");
html.classList.remove("intro");
void hero.offsetWidth;
hero.classList.remove("is-resetting");
if (PLAY_VEIL) html.classList.add("intro");
scheduleHero(PLAY_VEIL);
}
if (reduce) {
hero.classList.add("is-loaded");
} else {
const intro = html.classList.contains("intro");
let started = false;
const start = () => {
if (started) return;
started = true;
scheduleHero(intro); // the veil itself is already running from the head script
};
if (heroImg.complete) start();
else {
heroImg.addEventListener("load", start);
heroImg.addEventListener("error", start);
setTimeout(start, IMAGE_TIMEOUT);
}
// A click on the hero replays the veil and the hero intro.
hero.addEventListener("click", () => {
started = true;
window.scrollTo({ top: 0, behavior: "instant" });
replayIntro();
});
}
/* Scroll reveals --------------------------------------------------------- */
const targets = document.querySelectorAll(".reveal");
if (reduce || !("IntersectionObserver" in window)) {
targets.forEach((el) => el.classList.add("is-in"));
} else {
const io = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting || entry.target.classList.contains("is-in")) return;
entry.target.classList.add("is-in");
settle(entry.target, REVEAL_SETTLE);
});
}, { rootMargin: REVEAL_MARGIN, threshold: 0 });
// Once an element is fully below the viewport again, close it so it replays.
const out = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
const el = entry.target;
if (entry.isIntersecting || entry.boundingClientRect.top <= 0 || !el.classList.contains("is-in")) return;
clearTimeout(timers.get(el));
el.classList.add("is-resetting");
el.classList.remove("is-in", "settled");
void el.offsetWidth;
el.classList.remove("is-resetting");
});
}, { threshold: 0 });
targets.forEach((el) => {
io.observe(el);
if (REPLAY_ON_REENTER) out.observe(el);
});
}
})();
</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>
원본 구현 프롬프트
# Clip-Path Reveals: an intro veil, a shutter-and-aperture hero and wipe-in scroll reveals, all with clip-path
**Goal:** build an editorial page whose content is revealed entirely with animated `clip-path: inset()`: a full-screen intro veil that lifts away, a hero where one photo opens like a shutter and another through an aperture, and sections whose labels, photos and text blocks wipe in as you scroll.
## Visual behaviour
### 1. Intro veil (on load)
1. A fixed, full-screen light grey veil (`#e6e6e6`) covers the page, with a large wordmark (e.g. "Atelier") centred on it. Each character sits in an `overflow: hidden` wrapper, starting at `translateY(110%)` (hidden below its line).
2. The characters rise to their place: 1.1 s each, `cubic-bezier(0.16, 1, 0.3, 1)`, starting at 0.2 s and staggered 0.09 s per character.
3. At 1.45 s the whole wordmark drifts up 40px and fades to 0: 0.9 s, `cubic-bezier(0.76, 0, 0.24, 1)`.
4. At 1.55 s the veil lifts: `clip-path: inset(0 0 0 0)` → `inset(0 0 100% 0)` (the bottom edge rises to the top), 1.1 s, `cubic-bezier(0.76, 0, 0.24, 1)`. Remove the veil at 2.9 s.
### 2. Hero (starts at 1.45 s, as the veil lifts; 120 ms after load without the veil)
A rounded panel (light grey `#efefef`, 6px corners), two columns: a full-height photo on the left, and on the right a smaller framed photo (5:6) centred in the grey. A small wordmark sits centred at the top of the panel, 40px from its top edge.
- **Shutter (left photo):** `clip-path: inset(100% 0 0 0)` → `inset(0 0 0 0)` (opens upwards from the bottom edge), 1.5 s, `cubic-bezier(0.76, 0, 0.24, 1)`. At the same time the photo inside scales from 1.2 to 1 over 3.4 s, `cubic-bezier(0.16, 1, 0.3, 1)`.
- **Aperture (framed photo), 0.75 s later:** `clip-path: inset(50% 50% 50% 50%)` → `inset(0 0 0 0)` (opens from a point in the centre), 1.5 s, `cubic-bezier(0.76, 0, 0.24, 1)`. Its photo scales from 1.35 to 1 over 2.8 s, `cubic-bezier(0.16, 1, 0.3, 1)`, also 0.75 s delayed.
- **Wordmark:** characters rise from `translateY(110%)`, 1.3 s, `cubic-bezier(0.16, 1, 0.3, 1)`, from 0.45 s + 0.08 s per character.
- Start the hero once the left photo has loaded (or after 2.5 s at the latest). Clicking the hero scrolls to the top and replays the veil and the hero from the start.
### 3. Scroll reveals (IntersectionObserver)
Each element starts closed and opens once when it enters the viewport, using `rootMargin: "0px 0px -10% 0px"` (it must be 10% above the bottom edge). Once an element is completely below the viewport again (scrolled back up past it), it snaps back to closed without a transition, so it plays again on the next scroll down.
- **Section label** (small uppercase eyebrow): wipes in from the left, `clip-path: inset(0 100% 0 0)` → `inset(0 0 0 0)`, 1.3 s, `cubic-bezier(0.76, 0, 0.24, 1)`. Its width is `fit-content`, so the wipe follows the text.
- **Cards** (3 in a row; photo 8:9 on top, grey text block underneath with title, small tag line and a short paragraph), each card delayed by its index × 120 ms:
- The photo rises like a blind: `inset(100% 0 0 0)` → `inset(0 0 0 0)`, 1.3 s, `cubic-bezier(0.76, 0, 0.24, 1)`; the image inside scales 1.25 → 1 over 1.9 s, `cubic-bezier(0.16, 1, 0.3, 1)`.
- 0.5 s later the text block wipes down beneath it: `inset(0 0 100% 0)` → `inset(0 0 0 0)`, 1.1 s, `cubic-bezier(0.76, 0, 0.24, 1)`.
- Its three lines fade in and rise 12px: 0.9 s, `cubic-bezier(0.16, 1, 0.3, 1)`, starting 0.85 s after the card, 90 ms apart.
- **Split section** (grey panel with a small thumbnail at the top and copy at the bottom, next to a tall portrait):
- Thumbnail (3:4) opens through an aperture: `inset(50% 50% 50% 50%)` → `inset(0 0 0 0)`, 1.4 s, `cubic-bezier(0.76, 0, 0.24, 1)`; photo 1.4 → 1 over 2.2 s, `cubic-bezier(0.16, 1, 0.3, 1)`.
- The copy block fades in and rises 24px: 0.9 s, `cubic-bezier(0.16, 1, 0.3, 1)`.
- Portrait wipes in from the left: `inset(0 100% 0 0)` → `inset(0 0 0 0)`, 1.7 s, `cubic-bezier(0.76, 0, 0.24, 1)`; photo 1.2 → 1 over 2.6 s, `cubic-bezier(0.16, 1, 0.3, 1)`.
### 4. Hover details
- Card title: a 1px underline draws in from the left under the title (`background-size` 0 → 100% over 0.8 s, `cubic-bezier(0.16, 1, 0.3, 1)`).
- Card photo: scales to 1.06 over 1.6 s.
- A few seconds after a reveal has played (3.4 s for scroll reveals, 4.5 s for the hero), add a class that sets every `transition-delay` inside it to 0, so hover transitions respond immediately instead of inheriting the stagger delays.
## Exact values
| What | Value |
|---|---|
| Clip-path easing (every wipe, shutter, aperture, veil) | `cubic-bezier(0.76, 0, 0.24, 1)` (strong ease-in-out) |
| Scale / rise / fade easing | `cubic-bezier(0.16, 1, 0.3, 1)` (ease-out expo-like) |
| Veil | characters 1.1 s from 0.2 s + 0.09 s each · mark up 40px + fade 0.9 s at 1.45 s · lift 1.1 s at 1.55 s |
| Hero start | 1450 ms (with veil) / 120 ms (without) |
| Hero shutter | inset(100% 0 0 0) → 0, 1.5 s · photo 1.2 → 1, 3.4 s |
| Hero aperture | inset(50%) → 0, 1.5 s, +0.75 s · photo 1.35 → 1, 2.8 s, +0.75 s |
| Hero wordmark | characters 1.3 s, 0.45 s + 0.08 s each |
| Reveal trigger | IntersectionObserver, rootMargin `0px 0px -10% 0px`, threshold 0 |
| Label wipe | inset(0 100% 0 0) → 0, 1.3 s |
| Card stagger | 120 ms per card · lines 90 ms apart |
| Card photo | inset(100% 0 0 0) → 0, 1.3 s · scale 1.25 → 1, 1.9 s |
| Card block | inset(0 0 100% 0) → 0, 1.1 s, +0.5 s · lines rise 12px, 0.9 s, +0.85 s |
| Split thumbnail | inset(50%) → 0, 1.4 s · scale 1.4 → 1, 2.2 s |
| Split portrait | inset(0 100% 0 0) → 0, 1.7 s · scale 1.2 → 1, 2.6 s |
| Surfaces | `#efefef` panels, `#e6e6e6` veil, 6px corners |
| Hero height | `max(80vh, min(54vw, 100vh))` |
## Markup and layout
```html
<div class="veil" aria-hidden="true"><span class="veil-mark"><span class="w"><span class="ch" style="--c:0">A</span>…</span></span></div>
<main>
<section class="hero">
<div class="hero-media"><img …></div>
<div class="hero-aside"><figure class="hero-frame"><img …></figure></div>
<h1 class="hero-mark"><span class="visually-hidden">Atelier</span><span class="w" aria-hidden="true"><span class="ch" style="--c:0">A</span>…</span></h1>
</section>
<section>
<h2 class="eyebrow reveal">Section label</h2>
<ul class="cards">
<li class="card reveal" style="--i:0">
<figure class="card-media"><img …></figure>
<div class="card-body"><h3>Title</h3><p class="eyebrow">Tag</p><p>Text</p></div>
</li>
…
</ul>
</section>
<section class="split">
<div class="split-panel"><figure class="split-thumb reveal"><img …></figure><div class="split-text reveal">…</div></div>
<figure class="split-media reveal"><img …></figure>
</section>
</main>
```
- Every clipped element is the wrapper (`overflow: hidden`), and the scale runs on the `img` inside it (`width/height: 100%; object-fit: cover`), so the photo settles inside the opening frame.
- Drive everything with CSS transitions toggled by classes: `.is-loaded` on the hero, `.is-in` on each reveal. Delays come from custom properties (`--i` per card, `--k` per line, `--c` per character) in `calc()`.
- Add a `js` class on `<html>` in an inline `<head>` script so the closed states only apply when JavaScript runs (no flash of open content, and content stays visible without JS). Add an `intro` class there too to start the veil's CSS animations before first paint.
- For the snap-back, add a temporary class that sets `transition: none !important` on the element and its children, remove `.is-in`, force a reflow, then remove the temporary class.
## Libraries
- None: CSS transitions and keyframe animations on `clip-path` and `transform`, plus two IntersectionObservers (one to open, one to detect "completely below the viewport" for the reset: not intersecting and `boundingClientRect.top > 0`).
- Optional: GSAP + ScrollTrigger can drive the same states (`clipPath` tweens with `ease: "expo.inOut"`-like curves, `toggleActions: "play none none reset"`), but keep the durations, delays and the `cubic-bezier` curves above.
## Responsive and reduced motion
- Below 1100px: two cards per row (hide the third).
- Below 820px: the hero stacks (wordmark on top as a normal block at 34px, then the left photo at 4:5, then the framed photo at 68% width with 64px padding); cards go to one column (40px gap); the split section stacks with the portrait at 4:5.
- `prefers-reduced-motion: reduce`: no veil, no transitions or animations; the hero and every reveal are shown open immediately.
## Acceptance criteria
- On load, the veil's characters rise one by one, the mark drifts up and fades, and the veil lifts upwards via clip-path, revealing the hero mid-animation.
- The left hero photo opens from the bottom like a shutter while it gently zooms out; 0.75 s later the framed photo opens from its centre point; the wordmark's characters rise in.
- While scrolling down, the section label wipes in from the left, the card photos rise like blinds one after another, the grey text blocks wipe down beneath them and their lines follow.
- The split thumbnail opens from its centre and the portrait wipes in from the left.
- Scrolling back up past a section and down again replays its reveals; there is never a visible reverse animation.
- Hovers on cards respond immediately once the reveal has finished.
- Clicking the hero replays the veil and the hero intro. With reduced motion, everything is visible and static.
보관·활용 메모
Cadence에서 제공하는 코드와 프롬프트, 미리보기 영상을 이 사이트에 보관했습니다. 코드는 다운로드한 원본으로 제공하며 실행 화면은 별도로 격리되어 있습니다. 코드의 외부 라이브러리·샘플 이미지 요청은 유지됩니다.
보관일: 2026-10-26 · 프로젝트 적용: 별도 검증 필요
원본 코드·프롬프트·제작자 정보를 함께 보관했습니다. 실제 납품 시 코드와 포함 소재의 이용 조건을 확인하세요.
코드 SHA-256: 09bcd8ffeece122116bfec1ca91e7ae099b4be647cd7fca4be4c394ec21f5c80