css 스크롤 애니메이션 효과 9가지 — 뷰포트 구간 값으로 전환
css 스크롤 애니메이션 효과는 값 하나가 아홉 가지 다른 움직임을 만든다. 화면을 내리면 카드 한 장이 흐릿하게 떠 있다가 점점 또렷해진다. 그 흐림 정도는 스크롤 위치가 아니라 그 요소가 화면을 통과하는 구간 값 하나로 정해진다.
01 scroll-view-timeline-fade-scale-card
02 scroll-view-timeline-clip-path-reveal
03 scroll-view-timeline-mask-wipe-image
04 scroll-view-timeline-blur-focus-stack
05 scroll-view-timeline-rotate-3d-flip
06 scroll-view-timeline-skew-shear-panel
07 scroll-view-timeline-color-morph-badge
08 scroll-view-timeline-shadow-lift-depth
09 scroll-view-timeline-letter-spacing-stretch
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 구간 값에 맞춰 커지며 또렷해지는 카드
- 02 경계 값이 옮겨가며 드러나는 썸네일
- 03 대각선 경계 값이 훑고 지나가는 배경 이미지
- 04 구간 값 따라 초점이 풀렸다 맞는 카드
- 05 구간 값이 각도 값으로 바뀌는 3D 카드
- 06 기울기 값이 끝에서 풀리는 패널
- 07 구간 값이 색상 값 보간 지점이 되는 배지
- 08 구간 값만큼 떠오르는 그림자 카드
- 09 구간 값이 글자 간격 값으로 벌어지는 제목
아래 아홉 개는 그 구간 값을 어디에 물리는지로 나눴다. 앞쪽 여섯은 요소가 화면에 들어오는 구간(entry) 하나만 보고, 가운데 둘(04·05)은 화면을 완전히 통과하는 구간(cover) 전체를 보며, 08번도 다시 cover 로 돌아와 그림자 값을 늘린다. 같은 값이라도 어디에 대입하느냐에 따라 크기·색·기울기·간격처럼 전혀 다른 장면이 된다.
값이 어디서 갈리는가 — 구간 값 하나가 아홉 장면을 나눈다
CSS 만으로 이 아홉 개를 움직이는 축은 두 줄이다. animation-timeline: view() 가 키프레임의 진행률을 요소가 가장 가까운 스크롤 컨테이너를 지나는 위치에 묶고, animation-range 가 그 지나는 구간 중 어디부터 어디까지를 0%~100% 로 잡을지 고른다. entry 는 요소가 들어오기 시작해 완전히 들어올 때까지, cover 는 요소가 화면에 처음 걸치는 순간부터 완전히 빠져나갈 때까지다. 이 값 하나가 css 마우스 스크롤 애니메이션에서 카드 확대부터 배지 색 전환까지 아홉 장면을 각각 다른 속성에 물린다. 데모는 CSS.supports('animation-timeline: view()') 로 지원 여부를 먼저 재고, 미지원이면 entry 항목은 IntersectionObserver 교차 비율로, cover 항목은 스크롤 위치에서 요소 높이·뷰 높이로 직접 통과 진행률을 계산해 같은 속성에 대입한다. 값과 구간의 전체 스펙은 MDN 의 animation-range 문서에 정리돼 있다.
01구간 값에 맞춰 커지며 또렷해지는 카드
카드가 화면에 들어오는 동안 흐려져 있던 모습이 커지면서 또렷해진다. entry 구간의 진행률 값을 그대로 opacity 에, 시작 배율 0.8 에서 1 까지의 폭에 곱해 scale 에 대입한다.

<main class="stage">
<div class="fsc is-demo" id="fscRoot">
<div class="fsc__badge" id="fscBadge">진입 구간 0%~0%</div>
<button class="fsc__ctrl" id="fscCtrl" type="button">일시정지</button>
<div class="fsc__view" id="fscView">
<div class="fsc__track" id="fscTrack">
<div class="fsc__spacer fsc__spacer--top"></div>
<div class="fsc__card" id="fscCard">
<span class="fsc__label">기능 카드</span>
</div>
<div class="fsc__spacer fsc__spacer--bottom"></div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: animation-timeline: view() + animation-range: entry 로 카드가 뷰에 들어오는
// 진입 구간 값(0~1)에 투명도·크기를 직접 묶는다. 미지원 환경은 IntersectionObserver
// 교차 비율을 같은 값으로 대신 계산해 동일한 결과를 그린다.
$duration: $dur-loop;
$easing: linear;
$color: $subject-blue-deep;
@include stage($stage-ink);
.fsc {
position: relative;
width: min(100%, 280px);
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.fsc__badge {
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .08);
color: $subject-cream;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.fsc__ctrl {
position: absolute;
top: 0;
right: 0;
border: 0;
border-radius: $r-xs;
padding: $sp-1 $sp-2;
background: rgba(255, 247, 230, .12);
color: $subject-cream;
font-weight: 600;
font-size: 10px;
line-height: 1.4;
cursor: pointer;
}
.fsc__ctrl:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
.fsc__view {
box-sizing: border-box;
width: 100%;
height: 176px;
overflow-y: auto;
overflow-x: hidden;
border-radius: $r-card;
background: rgba(255, 255, 255, .05);
scrollbar-width: thin;
}
.fsc__track {
display: flex;
flex-direction: column;
align-items: center;
}
.fsc__spacer--top { height: 190px; flex: 0 0 auto; }
.fsc__spacer--bottom { height: 40px; flex: 0 0 auto; }
.fsc__card {
flex: 0 0 auto;
box-sizing: border-box;
width: 200px;
height: 72px;
display: grid;
place-items: center;
border-radius: $r-card;
background: $color;
box-shadow: $shadow-raised;
opacity: 0;
transform-origin: center;
transform: scale(var(--fade-scale-start, .8));
}
.fsc__label {
color: $subject-cream;
font-weight: 700;
font-size: 14px;
}
// 네이티브: animation-timeline: view() + animation-range: entry 가 진입 구간 값을
// 직접 투명도·크기에 묶는다.
.supports-vt .fsc__card {
animation-name: fscEnter;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: view();
animation-range: entry;
}
@keyframes fscEnter {
0% { opacity: 0; transform: scale(var(--fade-scale-start, .8)); }
100% { opacity: 1; transform: scale(1); }
}
@media (max-width: 340px) {
.fsc { gap: $sp-2; }
.fsc__view { height: 130px; }
.fsc__spacer--top { height: 130px; }
.fsc__spacer--bottom { height: 26px; }
.fsc__card { width: 168px; height: 56px; }
}
@media (prefers-reduced-motion: reduce) {
.fsc__card { animation: none !important; opacity: 1 !important; transform: scale(1) !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.fsc {
position: relative;
width: min(100%, 280px);
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.fsc__badge {
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.08);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.fsc__ctrl {
position: absolute;
top: 0;
right: 0;
border: 0;
border-radius: 4px;
padding: 4px 8px;
background: rgba(255, 247, 230, 0.12);
color: #fff7e6;
font-weight: 600;
font-size: 10px;
line-height: 1.4;
cursor: pointer;
}
.fsc__ctrl:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.fsc__view {
box-sizing: border-box;
width: 100%;
height: 176px;
overflow-y: auto;
overflow-x: hidden;
border-radius: 16px;
background: rgba(255, 255, 255, 0.05);
scrollbar-width: thin;
}
.fsc__track {
display: flex;
flex-direction: column;
align-items: center;
}
.fsc__spacer--top {
height: 190px;
flex: 0 0 auto;
}
.fsc__spacer--bottom {
height: 40px;
flex: 0 0 auto;
}
.fsc__card {
flex: 0 0 auto;
box-sizing: border-box;
width: 200px;
height: 72px;
display: grid;
place-items: center;
border-radius: 16px;
background: #0a4fe8;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
opacity: 0;
transform-origin: center;
transform: scale(var(--fade-scale-start, 0.8));
}
.fsc__label {
color: #fff7e6;
font-weight: 700;
font-size: 14px;
}
.supports-vt .fsc__card {
animation-name: fscEnter;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: view();
animation-range: entry;
}
@keyframes fscEnter {
0% {
opacity: 0;
transform: scale(var(--fade-scale-start, 0.8));
}
100% {
opacity: 1;
transform: scale(1);
}
}
@media (max-width: 340px) {
.fsc {
gap: 8px;
}
.fsc__view {
height: 130px;
}
.fsc__spacer--top {
height: 130px;
}
.fsc__spacer--bottom {
height: 26px;
}
.fsc__card {
width: 168px;
height: 56px;
}
}
@media (prefers-reduced-motion: reduce) {
.fsc__card {
animation: none !important;
opacity: 1 !important;
transform: scale(1) !important;
}
}(function () {
// VIEWTL_FADE_SCALE_START — 변형 1: 0.8 → 0.6 로 낮추면 카드가 더 작게 쭈그러든 채 시작해 등장이 강조된다.
var VIEWTL_FADE_SCALE_START = 0.8;
document.documentElement.style.setProperty('--fade-scale-start', String(VIEWTL_FADE_SCALE_START));
var supportsVt = CSS.supports('animation-timeline: view()');
var root = document.getElementById('fscRoot');
var view = document.getElementById('fscView');
var card = document.getElementById('fscCard');
var badge = document.getElementById('fscBadge');
var ctrl = document.getElementById('fscCtrl');
root.classList.add(supportsVt ? 'supports-vt' : 'no-vt');
// 계산 한 겹: 요소 높이·뷰포트 높이에서 진입 구간(시작~끝 %) 값을 구해 배지에 표시한다.
function entryRangePct() {
var elH = card.offsetHeight;
var viewH = view.clientHeight;
return Math.round(Math.min(100, (elH / viewH) * 100));
}
var rangeEnd = entryRangePct();
var rangeText = '진입 구간 0%~' + rangeEnd + '%';
function render(ratio) {
if (!supportsVt) {
var start = VIEWTL_FADE_SCALE_START;
var scale = start + (1 - start) * ratio;
card.style.opacity = String(ratio);
card.style.transform = 'scale(' + scale.toFixed(3) + ')';
}
badge.textContent = rangeText + ' · 진행 ' + Math.round(ratio * 100) + '%';
}
var lastRatio = 0;
if (!supportsVt) {
var STEPS = [];
for (var s = 0; s <= 50; s += 1) { STEPS.push(s / 50); }
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
lastRatio = entry.intersectionRatio;
render(lastRatio);
});
}, { root: view, threshold: STEPS });
io.observe(card);
} else {
badge.textContent = rangeText;
}
view.addEventListener('scroll', function () {
if (supportsVt) {
var max = view.scrollHeight - view.clientHeight;
var p = max > 0 ? Math.min(1, Math.max(0, view.scrollTop / max)) : 0;
badge.textContent = rangeText + ' · 스크롤 ' + Math.round(p * 100) + '%';
}
}, { passive: true });
// 자동 시연: rAF 로 프레임을 누적해 컨테이너를 왕복 스크롤한다 (실시간 setInterval 이 아니라 프레임 누적).
var playing = true;
var frame = 0;
var PERIOD = 66;
function tick() {
if (playing) {
frame += 1;
var max = view.scrollHeight - view.clientHeight;
var t = frame % (PERIOD * 2);
var f = t < PERIOD ? (t / PERIOD) : ((PERIOD * 2 - t) / PERIOD);
view.scrollTop = f * max;
}
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
function stopAuto() {
if (root.classList.contains('is-demo')) { root.classList.remove('is-demo'); }
playing = false;
ctrl.textContent = '재생';
}
view.addEventListener('wheel', stopAuto, { passive: true });
view.addEventListener('touchstart', stopAuto, { passive: true });
ctrl.addEventListener('click', function () {
playing = !playing;
ctrl.textContent = playing ? '일시정지' : '재생';
if (playing) { root.classList.add('is-demo'); }
});
ctrl.addEventListener('keydown', function (e) {
if (e.key === 'r' || e.key === 'R') {
frame = 0;
view.scrollTop = 0;
lastRatio = 0;
render(0);
playing = true;
root.classList.add('is-demo');
ctrl.textContent = '일시정지';
}
});
window.addEventListener('resize', function () {
rangeEnd = entryRangePct();
rangeText = '진입 구간 0%~' + rangeEnd + '%';
});
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
playing = false;
root.classList.remove('is-demo');
lastRatio = 1;
render(1);
card.style.opacity = '1';
card.style.transform = 'scale(1)';
badge.textContent = rangeText + ' · 최종 상태';
}
})();import { useEffect, useRef, useState } from "react";
import styles from "./FadeScaleCard.module.scss";
// 핵심: animation-timeline: view() + animation-range: entry 로 진입 구간 값(0~1)에
// 투명도·크기를 직접 묶는다. 미지원 환경은 IntersectionObserver 교차 비율로 같은 값을
// 계산한다. VIEWTL_FADE_SCALE_START — 변형 1: 0.8 → 0.6 로 낮추면 등장이 더 강조된다.
const VIEWTL_FADE_SCALE_START = 0.8;
export default function FadeScaleCard() {
const rootRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<HTMLDivElement>(null);
const cardRef = useRef<HTMLDivElement>(null);
const [badge, setBadge] = useState("진입 구간 0%~0%");
const [demo, setDemo] = useState(true);
const [supportsVt, setSupportsVt] = useState(false);
useEffect(() => {
const view = viewRef.current;
const card = cardRef.current;
const root = rootRef.current;
if (!view || !card || !root) { return; }
card.style.setProperty("--fade-scale-start", String(VIEWTL_FADE_SCALE_START));
const vt = CSS.supports("animation-timeline: view()");
setSupportsVt(vt);
function entryRangePct() {
const elH = card!.offsetHeight;
const viewH = view!.clientHeight;
return Math.round(Math.min(100, (elH / viewH) * 100));
}
let rangeEnd = entryRangePct();
let rangeText = "진입 구간 0%~" + rangeEnd + "%";
function render(ratio: number) {
if (!vt) {
const start = VIEWTL_FADE_SCALE_START;
const scale = start + (1 - start) * ratio;
card!.style.opacity = String(ratio);
card!.style.transform = "scale(" + scale.toFixed(3) + ")";
}
setBadge(rangeText + " · 진행 " + Math.round(ratio * 100) + "%");
}
let io: IntersectionObserver | null = null;
if (!vt) {
const STEPS: number[] = [];
for (let s = 0; s <= 50; s += 1) { STEPS.push(s / 50); }
io = new IntersectionObserver((entries) => {
entries.forEach((entry) => { render(entry.intersectionRatio); });
}, { root: view, threshold: STEPS });
io.observe(card);
} else {
setBadge(rangeText);
}
function onScroll() {
if (vt) {
const max = view!.scrollHeight - view!.clientHeight;
const p = max > 0 ? Math.min(1, Math.max(0, view!.scrollTop / max)) : 0;
setBadge(rangeText + " · 스크롤 " + Math.round(p * 100) + "%");
}
}
view.addEventListener("scroll", onScroll, { passive: true });
let playing = true;
let frame = 0;
const PERIOD = 66;
let rafId = 0;
function tick() {
if (playing) {
frame += 1;
const max = view!.scrollHeight - view!.clientHeight;
const t = frame % (PERIOD * 2);
const f = t < PERIOD ? (t / PERIOD) : ((PERIOD * 2 - t) / PERIOD);
view!.scrollTop = f * max;
}
rafId = requestAnimationFrame(tick);
}
rafId = requestAnimationFrame(tick);
function stopAuto() {
setDemo(false);
playing = false;
}
view.addEventListener("wheel", stopAuto, { passive: true });
view.addEventListener("touchstart", stopAuto, { passive: true });
function onClick() {
playing = !playing;
setDemo(playing);
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === "r" || e.key === "R") {
frame = 0;
view!.scrollTop = 0;
render(0);
playing = true;
setDemo(true);
}
}
root.addEventListener("keydown", onKeyDown);
function onResize() {
rangeEnd = entryRangePct();
rangeText = "진입 구간 0%~" + rangeEnd + "%";
}
window.addEventListener("resize", onResize);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
playing = false;
setDemo(false);
render(1);
card.style.opacity = "1";
card.style.transform = "scale(1)";
setBadge(rangeText + " · 최종 상태");
}
const ctrl = root.querySelector("button");
ctrl?.addEventListener("click", onClick);
return () => {
cancelAnimationFrame(rafId);
io?.disconnect();
view.removeEventListener("scroll", onScroll);
view.removeEventListener("wheel", stopAuto);
view.removeEventListener("touchstart", stopAuto);
root.removeEventListener("keydown", onKeyDown);
window.removeEventListener("resize", onResize);
ctrl?.removeEventListener("click", onClick);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div ref={rootRef} className={`${styles.fsc} ${demo ? styles["is-demo"] : ""} ${supportsVt ? styles["supports-vt"] : styles["no-vt"]}`}>
<div className={styles["fsc__badge"]}>{badge}</div>
<button className={styles["fsc__ctrl"]} type="button">{demo ? "일시정지" : "재생"}</button>
<div ref={viewRef} className={styles["fsc__view"]}>
<div className={styles["fsc__track"]}>
<div className={`${styles["fsc__spacer"]} ${styles["fsc__spacer--top"]}`} />
<div ref={cardRef} className={styles["fsc__card"]}>
<span className={styles["fsc__label"]}>기능 카드</span>
</div>
<div className={`${styles["fsc__spacer"]} ${styles["fsc__spacer--bottom"]}`} />
</div>
</div>
</div>
</main>
);
}01 · 구간 값에 맞춰 커지며 또렷해지는 카드 — 측정 누적면적 16.106% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 네이티브: animation-timeline: view() + animation-range: entry 가 진입 구간 값을
// 직접 투명도·크기에 묶는다.
.supports-vt .fsc__card {
animation-name: fscEnter;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: view();
animation-range: entry;
}
@keyframes fscEnter {
0% { opacity: 0; transform: scale(var(--fade-scale-start, .8)); }
100% { opacity: 1; transform: scale(1); }
}
측정: 누적면적 16.1%, 23/23 프레임에서 움직였다.
02경계 값이 옮겨가며 드러나는 썸네일
썸네일을 가린 사각형이 오른쪽부터 걷히며 이미지를 조금씩 보여준다. clip-path 의 오른쪽 경계 값이 entry 구간 진행률을 따라 100% 에서 0% 로 줄어들어, 가려진 폭만큼만 이미지가 드러난다.

<main class="stage">
<div class="cpr is-demo" id="cprRoot">
<div class="cpr__badge" id="cprBadge">진입 구간 0%~0%</div>
<button class="cpr__ctrl" id="cprCtrl" type="button">일시정지</button>
<div class="cpr__view" id="cprView">
<div class="cpr__track" id="cprTrack">
<div class="cpr__spacer cpr__spacer--top"></div>
<div class="cpr__panel" id="cprPanel">
<span class="cpr__label">프로젝트</span>
</div>
<div class="cpr__spacer cpr__spacer--bottom"></div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: animation-timeline: view() + animation-range: entry 로 clip-path 의 가려진
// 폭을 진입 구간 값(0~1)에 직접 묶어 커튼처럼 걷어낸다. 미지원 환경은 교차 비율을
// 같은 공식으로 계산해 동일한 inset() 값을 적용한다.
$duration: $dur-loop;
$easing: linear;
$color: $subject-ink;
@include stage($stage-yellow);
.cpr {
position: relative;
width: min(100%, 280px);
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.cpr__badge {
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(23, 20, 26, .08);
color: $subject-ink;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.cpr__ctrl {
position: absolute;
top: 0;
right: 0;
border: 0;
border-radius: $r-xs;
padding: $sp-1 $sp-2;
background: rgba(23, 20, 26, .12);
color: $subject-ink;
font-weight: 600;
font-size: 10px;
line-height: 1.4;
cursor: pointer;
}
.cpr__ctrl:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.cpr__view {
box-sizing: border-box;
width: 100%;
height: 176px;
overflow-y: auto;
overflow-x: hidden;
border-radius: $r-card;
background: rgba(23, 20, 26, .06);
scrollbar-width: thin;
}
.cpr__track {
display: flex;
flex-direction: column;
align-items: center;
}
.cpr__spacer--top { height: 190px; flex: 0 0 auto; }
.cpr__spacer--bottom { height: 40px; flex: 0 0 auto; }
.cpr__panel {
flex: 0 0 auto;
box-sizing: border-box;
width: 216px;
height: 76px;
display: grid;
place-items: center;
border-radius: $r-card;
background: $color;
box-shadow: $shadow-raised;
clip-path: inset(0 100% 0 0);
}
.cpr__label {
color: $subject-cream;
font-weight: 700;
font-size: 14px;
}
.supports-vt .cpr__panel {
animation-name: cprReveal;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: view();
animation-range: entry;
}
@keyframes cprReveal {
0% { clip-path: inset(0 100% 0 0); }
100% { clip-path: inset(0 0% 0 0); }
}
@media (max-width: 340px) {
.cpr { gap: $sp-2; }
.cpr__view { height: 130px; }
.cpr__spacer--top { height: 130px; }
.cpr__spacer--bottom { height: 26px; }
.cpr__panel { width: 176px; height: 60px; }
}
@media (prefers-reduced-motion: reduce) {
.cpr__panel { animation: none !important; clip-path: inset(0 0% 0 0) !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cpr {
position: relative;
width: min(100%, 280px);
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.cpr__badge {
padding: 4px 12px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.08);
color: #17141a;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.cpr__ctrl {
position: absolute;
top: 0;
right: 0;
border: 0;
border-radius: 4px;
padding: 4px 8px;
background: rgba(23, 20, 26, 0.12);
color: #17141a;
font-weight: 600;
font-size: 10px;
line-height: 1.4;
cursor: pointer;
}
.cpr__ctrl:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.cpr__view {
box-sizing: border-box;
width: 100%;
height: 176px;
overflow-y: auto;
overflow-x: hidden;
border-radius: 16px;
background: rgba(23, 20, 26, 0.06);
scrollbar-width: thin;
}
.cpr__track {
display: flex;
flex-direction: column;
align-items: center;
}
.cpr__spacer--top {
height: 190px;
flex: 0 0 auto;
}
.cpr__spacer--bottom {
height: 40px;
flex: 0 0 auto;
}
.cpr__panel {
flex: 0 0 auto;
box-sizing: border-box;
width: 216px;
height: 76px;
display: grid;
place-items: center;
border-radius: 16px;
background: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
clip-path: inset(0 100% 0 0);
}
.cpr__label {
color: #fff7e6;
font-weight: 700;
font-size: 14px;
}
.supports-vt .cpr__panel {
animation-name: cprReveal;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: view();
animation-range: entry;
}
@keyframes cprReveal {
0% {
clip-path: inset(0 100% 0 0);
}
100% {
clip-path: inset(0 0% 0 0);
}
}
@media (max-width: 340px) {
.cpr {
gap: 8px;
}
.cpr__view {
height: 130px;
}
.cpr__spacer--top {
height: 130px;
}
.cpr__spacer--bottom {
height: 26px;
}
.cpr__panel {
width: 176px;
height: 60px;
}
}
@media (prefers-reduced-motion: reduce) {
.cpr__panel {
animation: none !important;
clip-path: inset(0 0% 0 0) !important;
}
}(function () {
var supportsVt = CSS.supports('animation-timeline: view()');
var root = document.getElementById('cprRoot');
var view = document.getElementById('cprView');
var panel = document.getElementById('cprPanel');
var badge = document.getElementById('cprBadge');
var ctrl = document.getElementById('cprCtrl');
root.classList.add(supportsVt ? 'supports-vt' : 'no-vt');
// 계산 한 겹: 요소 높이·뷰포트 높이에서 진입 구간(시작~끝 %) 값을 구해 배지에 표시한다.
function entryRangePct() {
var elH = panel.offsetHeight;
var viewH = view.clientHeight;
return Math.round(Math.min(100, (elH / viewH) * 100));
}
var rangeEnd = entryRangePct();
var rangeText = '진입 구간 0%~' + rangeEnd + '%';
function render(ratio) {
if (!supportsVt) {
var hide = (1 - ratio) * 100;
panel.style.clipPath = 'inset(0 ' + hide.toFixed(1) + '% 0 0)';
}
badge.textContent = rangeText + ' · 진행 ' + Math.round(ratio * 100) + '%';
}
var lastRatio = 0;
if (!supportsVt) {
var STEPS = [];
for (var s = 0; s <= 50; s += 1) { STEPS.push(s / 50); }
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
lastRatio = entry.intersectionRatio;
render(lastRatio);
});
}, { root: view, threshold: STEPS });
io.observe(panel);
} else {
badge.textContent = rangeText;
}
view.addEventListener('scroll', function () {
if (supportsVt) {
var max = view.scrollHeight - view.clientHeight;
var p = max > 0 ? Math.min(1, Math.max(0, view.scrollTop / max)) : 0;
badge.textContent = rangeText + ' · 스크롤 ' + Math.round(p * 100) + '%';
}
}, { passive: true });
// 자동 시연: rAF 로 프레임을 누적해 컨테이너를 왕복 스크롤한다.
var playing = true;
var frame = 0;
var PERIOD = 66;
function tick() {
if (playing) {
frame += 1;
var max = view.scrollHeight - view.clientHeight;
var t = frame % (PERIOD * 2);
var f = t < PERIOD ? (t / PERIOD) : ((PERIOD * 2 - t) / PERIOD);
view.scrollTop = f * max;
}
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
function stopAuto() {
if (root.classList.contains('is-demo')) { root.classList.remove('is-demo'); }
playing = false;
ctrl.textContent = '재생';
}
view.addEventListener('wheel', stopAuto, { passive: true });
view.addEventListener('touchstart', stopAuto, { passive: true });
ctrl.addEventListener('click', function () {
playing = !playing;
ctrl.textContent = playing ? '일시정지' : '재생';
if (playing) { root.classList.add('is-demo'); }
});
ctrl.addEventListener('keydown', function (e) {
if (e.key === 'r' || e.key === 'R') {
frame = 0;
view.scrollTop = 0;
lastRatio = 0;
render(0);
playing = true;
root.classList.add('is-demo');
ctrl.textContent = '일시정지';
}
});
window.addEventListener('resize', function () {
rangeEnd = entryRangePct();
rangeText = '진입 구간 0%~' + rangeEnd + '%';
});
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
playing = false;
root.classList.remove('is-demo');
lastRatio = 1;
render(1);
panel.style.clipPath = 'inset(0 0% 0 0)';
badge.textContent = rangeText + ' · 최종 상태';
}
})();import { useEffect, useRef, useState } from "react";
import styles from "./ClipPathReveal.module.scss";
// 핵심: animation-timeline: view() + animation-range: entry 로 clip-path 의 가려진
// 폭을 진입 구간 값(0~1)에 직접 묶어 커튼처럼 걷어낸다. 미지원 환경은 교차 비율로
// 같은 공식을 계산해 동일한 inset() 값을 적용한다.
export default function ClipPathReveal() {
const rootRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [badge, setBadge] = useState("진입 구간 0%~0%");
const [demo, setDemo] = useState(true);
const [supportsVt, setSupportsVt] = useState(false);
useEffect(() => {
const view = viewRef.current;
const panel = panelRef.current;
const root = rootRef.current;
if (!view || !panel || !root) { return; }
const vt = CSS.supports("animation-timeline: view()");
setSupportsVt(vt);
function entryRangePct() {
const elH = panel!.offsetHeight;
const viewH = view!.clientHeight;
return Math.round(Math.min(100, (elH / viewH) * 100));
}
let rangeEnd = entryRangePct();
let rangeText = "진입 구간 0%~" + rangeEnd + "%";
function render(ratio: number) {
if (!vt) {
const hide = (1 - ratio) * 100;
panel!.style.clipPath = "inset(0 " + hide.toFixed(1) + "% 0 0)";
}
setBadge(rangeText + " · 진행 " + Math.round(ratio * 100) + "%");
}
let io: IntersectionObserver | null = null;
if (!vt) {
const STEPS: number[] = [];
for (let s = 0; s <= 50; s += 1) { STEPS.push(s / 50); }
io = new IntersectionObserver((entries) => {
entries.forEach((entry) => { render(entry.intersectionRatio); });
}, { root: view, threshold: STEPS });
io.observe(panel);
} else {
setBadge(rangeText);
}
function onScroll() {
if (vt) {
const max = view!.scrollHeight - view!.clientHeight;
const p = max > 0 ? Math.min(1, Math.max(0, view!.scrollTop / max)) : 0;
setBadge(rangeText + " · 스크롤 " + Math.round(p * 100) + "%");
}
}
view.addEventListener("scroll", onScroll, { passive: true });
let playing = true;
let frame = 0;
const PERIOD = 66;
let rafId = 0;
function tick() {
if (playing) {
frame += 1;
const max = view!.scrollHeight - view!.clientHeight;
const t = frame % (PERIOD * 2);
const f = t < PERIOD ? (t / PERIOD) : ((PERIOD * 2 - t) / PERIOD);
view!.scrollTop = f * max;
}
rafId = requestAnimationFrame(tick);
}
rafId = requestAnimationFrame(tick);
function stopAuto() {
setDemo(false);
playing = false;
}
view.addEventListener("wheel", stopAuto, { passive: true });
view.addEventListener("touchstart", stopAuto, { passive: true });
function onClick() {
playing = !playing;
setDemo(playing);
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === "r" || e.key === "R") {
frame = 0;
view!.scrollTop = 0;
render(0);
playing = true;
setDemo(true);
}
}
root.addEventListener("keydown", onKeyDown);
function onResize() {
rangeEnd = entryRangePct();
rangeText = "진입 구간 0%~" + rangeEnd + "%";
}
window.addEventListener("resize", onResize);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
playing = false;
setDemo(false);
render(1);
panel.style.clipPath = "inset(0 0% 0 0)";
setBadge(rangeText + " · 최종 상태");
}
const ctrl = root.querySelector("button");
ctrl?.addEventListener("click", onClick);
return () => {
cancelAnimationFrame(rafId);
io?.disconnect();
view.removeEventListener("scroll", onScroll);
view.removeEventListener("wheel", stopAuto);
view.removeEventListener("touchstart", stopAuto);
root.removeEventListener("keydown", onKeyDown);
window.removeEventListener("resize", onResize);
ctrl?.removeEventListener("click", onClick);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div ref={rootRef} className={`${styles.cpr} ${demo ? styles["is-demo"] : ""} ${supportsVt ? styles["supports-vt"] : styles["no-vt"]}`}>
<div className={styles["cpr__badge"]}>{badge}</div>
<button className={styles["cpr__ctrl"]} type="button">{demo ? "일시정지" : "재생"}</button>
<div ref={viewRef} className={styles["cpr__view"]}>
<div className={styles["cpr__track"]}>
<div className={`${styles["cpr__spacer"]} ${styles["cpr__spacer--top"]}`} />
<div ref={panelRef} className={styles["cpr__panel"]}>
<span className={styles["cpr__label"]}>프로젝트</span>
</div>
<div className={`${styles["cpr__spacer"]} ${styles["cpr__spacer--bottom"]}`} />
</div>
</div>
</div>
</main>
);
}02 · 경계 값이 옮겨가며 드러나는 썸네일 — 측정 누적면적 17.112% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.supports-vt .cpr__panel {
animation-name: cprReveal;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: view();
animation-range: entry;
}
@keyframes cprReveal {
0% { clip-path: inset(0 100% 0 0); }
100% { clip-path: inset(0 0% 0 0); }
}
측정: 누적면적 17.1%, 23/23 프레임에서 움직였다.
03대각선 경계 값이 훑고 지나가는 배경 이미지
배경 이미지 위를 덮은 대각선 경계가 아래로 훑고 지나가며 이미지를 드러낸다. 등록해 둔 커스텀 프로퍼티 --wipe 값이 구간 진행률을 따라 0% 에서 100% 로 바뀌고, mask-image 그라디언트 경계를 calc(var(--wipe) - 12%) 에서 var(--wipe) 까지로 계산해 대각선 경계가 이동한다.

<main class="stage">
<div class="mwi is-demo" id="mwiRoot">
<div class="mwi__badge" id="mwiBadge">진입 구간 0%~0%</div>
<button class="mwi__ctrl" id="mwiCtrl" type="button">일시정지</button>
<div class="mwi__view" id="mwiView">
<div class="mwi__track" id="mwiTrack">
<div class="mwi__spacer mwi__spacer--top"></div>
<div class="mwi__frame" id="mwiFrame">
<div class="mwi__pic" id="mwiPic"></div>
</div>
<div class="mwi__spacer mwi__spacer--bottom"></div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: animation-timeline: view() + animation-range: entry 로 등록 커스텀 프로퍼티
// --wipe(퍼센트)를 진입 구간 값에 직접 묶고, mask-image 그라디언트 경계를 그 값으로
// 계산해 번지듯 드러낸다. 미지원 환경은 교차 비율로 같은 그라디언트를 계산해 그린다.
@property --wipe {
syntax: "<percentage>";
inherits: true;
initial-value: 0%;
}
$duration: $dur-loop;
$easing: linear;
$color: $subject-orange-deep;
@include stage($stage-orange);
.mwi {
position: relative;
width: min(100%, 280px);
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.mwi__badge {
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(23, 20, 26, .10);
color: $subject-ink;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.mwi__ctrl {
position: absolute;
top: 0;
right: 0;
border: 0;
border-radius: $r-xs;
padding: $sp-1 $sp-2;
background: rgba(23, 20, 26, .14);
color: $subject-ink;
font-weight: 600;
font-size: 10px;
line-height: 1.4;
cursor: pointer;
}
.mwi__ctrl:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.mwi__view {
box-sizing: border-box;
width: 100%;
height: 176px;
overflow-y: auto;
overflow-x: hidden;
border-radius: $r-card;
background: rgba(23, 20, 26, .08);
scrollbar-width: thin;
}
.mwi__track {
display: flex;
flex-direction: column;
align-items: center;
}
.mwi__spacer--top { height: 190px; flex: 0 0 auto; }
.mwi__spacer--bottom { height: 40px; flex: 0 0 auto; }
.mwi__frame {
flex: 0 0 auto;
box-sizing: border-box;
width: 216px;
height: 96px;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
}
.mwi__pic {
width: 100%;
height: 100%;
background:
radial-gradient(circle at 30% 30%, $subject-sky 0%, transparent 55%),
linear-gradient(135deg, $subject-blue-deep, $color);
-webkit-mask-image: linear-gradient(135deg, #000 calc(var(--wipe) - 12%), transparent var(--wipe));
mask-image: linear-gradient(135deg, #000 calc(var(--wipe) - 12%), transparent var(--wipe));
}
.supports-vt .mwi__pic {
animation-name: mwiWipe;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: view();
animation-range: entry;
}
@keyframes mwiWipe {
0% { --wipe: 0%; }
100% { --wipe: 100%; }
}
@media (max-width: 340px) {
.mwi { gap: $sp-2; }
.mwi__view { height: 130px; }
.mwi__spacer--top { height: 130px; }
.mwi__spacer--bottom { height: 26px; }
.mwi__frame { width: 176px; height: 80px; }
}
@media (prefers-reduced-motion: reduce) {
.mwi__pic {
animation: none !important;
-webkit-mask-image: none !important;
mask-image: none !important;
}
}@property --wipe {
syntax: "<percentage>";
inherits: true;
initial-value: 0%;
}
html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.mwi {
position: relative;
width: min(100%, 280px);
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.mwi__badge {
padding: 4px 12px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.1);
color: #17141a;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.mwi__ctrl {
position: absolute;
top: 0;
right: 0;
border: 0;
border-radius: 4px;
padding: 4px 8px;
background: rgba(23, 20, 26, 0.14);
color: #17141a;
font-weight: 600;
font-size: 10px;
line-height: 1.4;
cursor: pointer;
}
.mwi__ctrl:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.mwi__view {
box-sizing: border-box;
width: 100%;
height: 176px;
overflow-y: auto;
overflow-x: hidden;
border-radius: 16px;
background: rgba(23, 20, 26, 0.08);
scrollbar-width: thin;
}
.mwi__track {
display: flex;
flex-direction: column;
align-items: center;
}
.mwi__spacer--top {
height: 190px;
flex: 0 0 auto;
}
.mwi__spacer--bottom {
height: 40px;
flex: 0 0 auto;
}
.mwi__frame {
flex: 0 0 auto;
box-sizing: border-box;
width: 216px;
height: 96px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.mwi__pic {
width: 100%;
height: 100%;
background: radial-gradient(circle at 30% 30%, #6ec8ff 0%, transparent 55%), linear-gradient(135deg, #0a4fe8, #c93000);
-webkit-mask-image: linear-gradient(135deg, #000 calc(var(--wipe) - 12%), transparent var(--wipe));
mask-image: linear-gradient(135deg, #000 calc(var(--wipe) - 12%), transparent var(--wipe));
}
.supports-vt .mwi__pic {
animation-name: mwiWipe;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: view();
animation-range: entry;
}
@keyframes mwiWipe {
0% {
--wipe: 0%;
}
100% {
--wipe: 100%;
}
}
@media (max-width: 340px) {
.mwi {
gap: 8px;
}
.mwi__view {
height: 130px;
}
.mwi__spacer--top {
height: 130px;
}
.mwi__spacer--bottom {
height: 26px;
}
.mwi__frame {
width: 176px;
height: 80px;
}
}
@media (prefers-reduced-motion: reduce) {
.mwi__pic {
animation: none !important;
-webkit-mask-image: none !important;
mask-image: none !important;
}
}(function () {
var supportsVt = CSS.supports('animation-timeline: view()');
var root = document.getElementById('mwiRoot');
var view = document.getElementById('mwiView');
var frame = document.getElementById('mwiFrame');
var pic = document.getElementById('mwiPic');
var badge = document.getElementById('mwiBadge');
var ctrl = document.getElementById('mwiCtrl');
root.classList.add(supportsVt ? 'supports-vt' : 'no-vt');
// 계산 한 겹: 요소 높이·뷰포트 높이에서 진입 구간(시작~끝 %) 값을 구해 배지에 표시한다.
function entryRangePct() {
var elH = frame.offsetHeight;
var viewH = view.clientHeight;
return Math.round(Math.min(100, (elH / viewH) * 100));
}
var rangeEnd = entryRangePct();
var rangeText = '진입 구간 0%~' + rangeEnd + '%';
function render(ratio) {
if (!supportsVt) {
var edge = Math.max(0, ratio * 100 - 12);
var full = ratio * 100;
var grad = 'linear-gradient(135deg, #000 ' + edge.toFixed(1) + '%, transparent ' + full.toFixed(1) + '%)';
pic.style.maskImage = grad;
pic.style.webkitMaskImage = grad;
}
badge.textContent = rangeText + ' · 진행 ' + Math.round(ratio * 100) + '%';
}
var lastRatio = 0;
if (!supportsVt) {
var STEPS = [];
for (var s = 0; s <= 50; s += 1) { STEPS.push(s / 50); }
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
lastRatio = entry.intersectionRatio;
render(lastRatio);
});
}, { root: view, threshold: STEPS });
io.observe(frame);
} else {
badge.textContent = rangeText;
}
view.addEventListener('scroll', function () {
if (supportsVt) {
var max = view.scrollHeight - view.clientHeight;
var p = max > 0 ? Math.min(1, Math.max(0, view.scrollTop / max)) : 0;
badge.textContent = rangeText + ' · 스크롤 ' + Math.round(p * 100) + '%';
}
}, { passive: true });
// 자동 시연: rAF 로 프레임을 누적해 컨테이너를 왕복 스크롤한다.
var playing = true;
var frameN = 0;
var PERIOD = 66;
function tick() {
if (playing) {
frameN += 1;
var max = view.scrollHeight - view.clientHeight;
var t = frameN % (PERIOD * 2);
var f = t < PERIOD ? (t / PERIOD) : ((PERIOD * 2 - t) / PERIOD);
view.scrollTop = f * max;
}
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
function stopAuto() {
if (root.classList.contains('is-demo')) { root.classList.remove('is-demo'); }
playing = false;
ctrl.textContent = '재생';
}
view.addEventListener('wheel', stopAuto, { passive: true });
view.addEventListener('touchstart', stopAuto, { passive: true });
ctrl.addEventListener('click', function () {
playing = !playing;
ctrl.textContent = playing ? '일시정지' : '재생';
if (playing) { root.classList.add('is-demo'); }
});
ctrl.addEventListener('keydown', function (e) {
if (e.key === 'r' || e.key === 'R') {
frameN = 0;
view.scrollTop = 0;
lastRatio = 0;
render(0);
playing = true;
root.classList.add('is-demo');
ctrl.textContent = '일시정지';
}
});
window.addEventListener('resize', function () {
rangeEnd = entryRangePct();
rangeText = '진입 구간 0%~' + rangeEnd + '%';
});
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
playing = false;
root.classList.remove('is-demo');
lastRatio = 1;
render(1);
pic.style.maskImage = 'none';
pic.style.webkitMaskImage = 'none';
badge.textContent = rangeText + ' · 최종 상태';
}
})();import { useEffect, useRef, useState } from "react";
import styles from "./MaskWipeImage.module.scss";
// 핵심: animation-timeline: view() + animation-range: entry 로 등록 커스텀 프로퍼티
// --wipe(퍼센트)를 진입 구간 값에 직접 묶고, mask-image 그라디언트 경계를 그 값으로
// 계산해 대각선으로 번지듯 드러낸다. 미지원 환경은 교차 비율로 같은 그라디언트를 계산한다.
export default function MaskWipeImage() {
const rootRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<HTMLDivElement>(null);
const frameRef = useRef<HTMLDivElement>(null);
const picRef = useRef<HTMLDivElement>(null);
const [badge, setBadge] = useState("진입 구간 0%~0%");
const [demo, setDemo] = useState(true);
const [supportsVt, setSupportsVt] = useState(false);
useEffect(() => {
const view = viewRef.current;
const frame = frameRef.current;
const pic = picRef.current;
const root = rootRef.current;
if (!view || !frame || !pic || !root) { return; }
const vt = CSS.supports("animation-timeline: view()");
setSupportsVt(vt);
function entryRangePct() {
const elH = frame!.offsetHeight;
const viewH = view!.clientHeight;
return Math.round(Math.min(100, (elH / viewH) * 100));
}
let rangeEnd = entryRangePct();
let rangeText = "진입 구간 0%~" + rangeEnd + "%";
function render(ratio: number) {
if (!vt) {
const edge = Math.max(0, ratio * 100 - 12);
const full = ratio * 100;
const grad = "linear-gradient(135deg, #000 " + edge.toFixed(1) + "%, transparent " + full.toFixed(1) + "%)";
pic!.style.maskImage = grad;
pic!.style.webkitMaskImage = grad;
}
setBadge(rangeText + " · 진행 " + Math.round(ratio * 100) + "%");
}
let io: IntersectionObserver | null = null;
if (!vt) {
const STEPS: number[] = [];
for (let s = 0; s <= 50; s += 1) { STEPS.push(s / 50); }
io = new IntersectionObserver((entries) => {
entries.forEach((entry) => { render(entry.intersectionRatio); });
}, { root: view, threshold: STEPS });
io.observe(frame);
} else {
setBadge(rangeText);
}
function onScroll() {
if (vt) {
const max = view!.scrollHeight - view!.clientHeight;
const p = max > 0 ? Math.min(1, Math.max(0, view!.scrollTop / max)) : 0;
setBadge(rangeText + " · 스크롤 " + Math.round(p * 100) + "%");
}
}
view.addEventListener("scroll", onScroll, { passive: true });
let playing = true;
let frameN = 0;
const PERIOD = 66;
let rafId = 0;
function tick() {
if (playing) {
frameN += 1;
const max = view!.scrollHeight - view!.clientHeight;
const t = frameN % (PERIOD * 2);
const f = t < PERIOD ? (t / PERIOD) : ((PERIOD * 2 - t) / PERIOD);
view!.scrollTop = f * max;
}
rafId = requestAnimationFrame(tick);
}
rafId = requestAnimationFrame(tick);
function stopAuto() {
setDemo(false);
playing = false;
}
view.addEventListener("wheel", stopAuto, { passive: true });
view.addEventListener("touchstart", stopAuto, { passive: true });
function onClick() {
playing = !playing;
setDemo(playing);
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === "r" || e.key === "R") {
frameN = 0;
view!.scrollTop = 0;
render(0);
playing = true;
setDemo(true);
}
}
root.addEventListener("keydown", onKeyDown);
function onResize() {
rangeEnd = entryRangePct();
rangeText = "진입 구간 0%~" + rangeEnd + "%";
}
window.addEventListener("resize", onResize);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
playing = false;
setDemo(false);
render(1);
pic.style.maskImage = "none";
pic.style.webkitMaskImage = "none";
setBadge(rangeText + " · 최종 상태");
}
const ctrl = root.querySelector("button");
ctrl?.addEventListener("click", onClick);
return () => {
cancelAnimationFrame(rafId);
io?.disconnect();
view.removeEventListener("scroll", onScroll);
view.removeEventListener("wheel", stopAuto);
view.removeEventListener("touchstart", stopAuto);
root.removeEventListener("keydown", onKeyDown);
window.removeEventListener("resize", onResize);
ctrl?.removeEventListener("click", onClick);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div ref={rootRef} className={`${styles.mwi} ${demo ? styles["is-demo"] : ""} ${supportsVt ? styles["supports-vt"] : styles["no-vt"]}`}>
<div className={styles["mwi__badge"]}>{badge}</div>
<button className={styles["mwi__ctrl"]} type="button">{demo ? "일시정지" : "재생"}</button>
<div ref={viewRef} className={styles["mwi__view"]}>
<div className={styles["mwi__track"]}>
<div className={`${styles["mwi__spacer"]} ${styles["mwi__spacer--top"]}`} />
<div ref={frameRef} className={styles["mwi__frame"]}>
<div ref={picRef} className={styles["mwi__pic"]} />
</div>
<div className={`${styles["mwi__spacer"]} ${styles["mwi__spacer--bottom"]}`} />
</div>
</div>
</div>
</main>
);
}03 · 대각선 경계 값이 훑고 지나가는 배경 이미지 — 측정 누적면적 21.765% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.mwi__pic {
width: 100%;
height: 100%;
background:
radial-gradient(circle at 30% 30%, $subject-sky 0%, transparent 55%),
linear-gradient(135deg, $subject-blue-deep, $color);
-webkit-mask-image: linear-gradient(135deg, #000 calc(var(--wipe) - 12%), transparent var(--wipe));
mask-image: linear-gradient(135deg, #000 calc(var(--wipe) - 12%), transparent var(--wipe));
}
.supports-vt .mwi__pic {
animation-name: mwiWipe;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: view();
animation-range: entry;
}
@keyframes mwiWipe {
0% { --wipe: 0%; }
100% { --wipe: 100%; }
}
측정: 누적면적 21.8%, 23/23 프레임에서 움직였다.
04구간 값 따라 초점이 풀렸다 맞는 카드
카드가 화면을 지나는 동안 초점이 풀렸다 맞았다를 반복한다. cover 구간을 0→50→100% 로 나눠 흐림 값을 12px 에서 0px 로 줄였다가 다시 12px 로 늘리는 하나의 굴곡 값(hump)으로 들어올 때와 나갈 때 각각 초점이 풀리고 맞는다.

<main class="stage">
<div class="bfs is-demo" id="bfsRoot">
<div class="bfs__badge" id="bfsBadge">통과 구간 0%~100%</div>
<button class="bfs__ctrl" id="bfsCtrl" type="button">일시정지</button>
<div class="bfs__view" id="bfsView">
<div class="bfs__track" id="bfsTrack">
<div class="bfs__spacer bfs__spacer--top"></div>
<div class="bfs__card" id="bfsCard">
<span class="bfs__label">제품</span>
</div>
<div class="bfs__spacer bfs__spacer--bottom"></div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: animation-timeline: view() + animation-range: cover 로 요소가 뷰포트를
// 통과하는 전체 구간 값(0~1)에 흐림 정도를 묶는다 — 시작·끝에서 흐리고 중간에서
// 또렷해진다(초점이 풀렸다 맞는다). 미지원 환경은 스크롤 위치로 같은 통과 진행 값을
// 직접 계산해 동일한 흐림 값을 그린다.
$duration: $dur-loop;
$easing: linear;
$color: $subject-blue-deep;
@include stage($stage-paper);
.bfs {
position: relative;
width: min(100%, 280px);
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.bfs__badge {
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(20, 45, 120, .08);
color: $subject-blue-deep;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.bfs__ctrl {
position: absolute;
top: 0;
right: 0;
border: 0;
border-radius: $r-xs;
padding: $sp-1 $sp-2;
background: rgba(20, 45, 120, .10);
color: $subject-blue-deep;
font-weight: 600;
font-size: 10px;
line-height: 1.4;
cursor: pointer;
}
.bfs__ctrl:focus-visible { outline: 2px solid $subject-blue-deep; outline-offset: 2px; }
.bfs__view {
box-sizing: border-box;
width: 100%;
height: 176px;
overflow-y: auto;
overflow-x: hidden;
border-radius: $r-card;
background: rgba(20, 45, 120, .05);
scrollbar-width: thin;
}
.bfs__track {
display: flex;
flex-direction: column;
align-items: center;
}
.bfs__spacer--top { height: 200px; flex: 0 0 auto; }
.bfs__spacer--bottom { height: 200px; flex: 0 0 auto; }
.bfs__card {
flex: 0 0 auto;
box-sizing: border-box;
width: 200px;
height: 84px;
display: grid;
place-items: center;
border-radius: $r-card;
background: $color;
box-shadow: $shadow-raised;
filter: blur(var(--blur-max, 12px));
}
.bfs__label {
color: $subject-cream;
font-weight: 700;
font-size: 14px;
}
.supports-vt .bfs__card {
animation-name: bfsFocus;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: view();
animation-range: cover;
}
@keyframes bfsFocus {
0% { filter: blur(var(--blur-max, 12px)); opacity: .55; }
50% { filter: blur(0px); opacity: 1; }
100% { filter: blur(var(--blur-max, 12px)); opacity: .55; }
}
@media (max-width: 340px) {
.bfs { gap: $sp-2; }
.bfs__view { height: 130px; }
.bfs__spacer--top { height: 150px; }
.bfs__spacer--bottom { height: 150px; }
.bfs__card { width: 168px; height: 68px; }
}
@media (prefers-reduced-motion: reduce) {
.bfs__card { animation: none !important; filter: blur(0px) !important; opacity: 1 !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.bfs {
position: relative;
width: min(100%, 280px);
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.bfs__badge {
padding: 4px 12px;
border-radius: 999px;
background: rgba(20, 45, 120, 0.08);
color: #0a4fe8;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.bfs__ctrl {
position: absolute;
top: 0;
right: 0;
border: 0;
border-radius: 4px;
padding: 4px 8px;
background: rgba(20, 45, 120, 0.1);
color: #0a4fe8;
font-weight: 600;
font-size: 10px;
line-height: 1.4;
cursor: pointer;
}
.bfs__ctrl:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
.bfs__view {
box-sizing: border-box;
width: 100%;
height: 176px;
overflow-y: auto;
overflow-x: hidden;
border-radius: 16px;
background: rgba(20, 45, 120, 0.05);
scrollbar-width: thin;
}
.bfs__track {
display: flex;
flex-direction: column;
align-items: center;
}
.bfs__spacer--top {
height: 200px;
flex: 0 0 auto;
}
.bfs__spacer--bottom {
height: 200px;
flex: 0 0 auto;
}
.bfs__card {
flex: 0 0 auto;
box-sizing: border-box;
width: 200px;
height: 84px;
display: grid;
place-items: center;
border-radius: 16px;
background: #0a4fe8;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
filter: blur(var(--blur-max, 12px));
}
.bfs__label {
color: #fff7e6;
font-weight: 700;
font-size: 14px;
}
.supports-vt .bfs__card {
animation-name: bfsFocus;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: view();
animation-range: cover;
}
@keyframes bfsFocus {
0% {
filter: blur(var(--blur-max, 12px));
opacity: 0.55;
}
50% {
filter: blur(0px);
opacity: 1;
}
100% {
filter: blur(var(--blur-max, 12px));
opacity: 0.55;
}
}
@media (max-width: 340px) {
.bfs {
gap: 8px;
}
.bfs__view {
height: 130px;
}
.bfs__spacer--top {
height: 150px;
}
.bfs__spacer--bottom {
height: 150px;
}
.bfs__card {
width: 168px;
height: 68px;
}
}
@media (prefers-reduced-motion: reduce) {
.bfs__card {
animation: none !important;
filter: blur(0px) !important;
opacity: 1 !important;
}
}(function () {
// VIEWTL_BLUR_MAX_PX — 변형 2: 12 → 24 로 늘리면 들어오고 나갈 때 초점이 풀리는 정도가 더 뚜렷해진다.
var VIEWTL_BLUR_MAX_PX = 12;
document.documentElement.style.setProperty('--blur-max', VIEWTL_BLUR_MAX_PX + 'px');
var supportsVt = CSS.supports('animation-timeline: view()');
var root = document.getElementById('bfsRoot');
var view = document.getElementById('bfsView');
var card = document.getElementById('bfsCard');
var badge = document.getElementById('bfsBadge');
var ctrl = document.getElementById('bfsCtrl');
root.classList.add(supportsVt ? 'supports-vt' : 'no-vt');
// 계산 한 겹: 요소가 뷰포트를 완전히 통과하는 구간(cover) 진행 값을 요소 높이·뷰포트
// 높이·스크롤 위치로 직접 계산한다 (교차 비율이 아니라 통과 위치 값).
function coverProgress() {
var elTop = card.offsetTop;
var elH = card.offsetHeight;
var viewH = view.clientHeight;
var raw = (view.scrollTop - elTop + viewH) / (elH + viewH);
return Math.min(1, Math.max(0, raw));
}
function render(cov) {
var hump = 1 - Math.abs(cov * 2 - 1);
var blurPx = VIEWTL_BLUR_MAX_PX * (1 - hump);
if (!supportsVt) {
card.style.filter = 'blur(' + blurPx.toFixed(1) + 'px)';
card.style.opacity = String(0.55 + 0.45 * hump);
}
badge.textContent = '통과 진행 ' + Math.round(cov * 100) + '% · 흐림 ' + blurPx.toFixed(1) + 'px';
}
view.addEventListener('scroll', function () {
render(coverProgress());
}, { passive: true });
render(coverProgress());
// 자동 시연: rAF 로 프레임을 누적해 컨테이너를 왕복 스크롤한다.
var playing = true;
var frame = 0;
var PERIOD = 80;
function tick() {
if (playing) {
frame += 1;
var max = view.scrollHeight - view.clientHeight;
var t = frame % (PERIOD * 2);
var f = t < PERIOD ? (t / PERIOD) : ((PERIOD * 2 - t) / PERIOD);
view.scrollTop = f * max;
render(coverProgress());
}
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
function stopAuto() {
if (root.classList.contains('is-demo')) { root.classList.remove('is-demo'); }
playing = false;
ctrl.textContent = '재생';
}
view.addEventListener('wheel', stopAuto, { passive: true });
view.addEventListener('touchstart', stopAuto, { passive: true });
ctrl.addEventListener('click', function () {
playing = !playing;
ctrl.textContent = playing ? '일시정지' : '재생';
if (playing) { root.classList.add('is-demo'); }
render(coverProgress());
});
ctrl.addEventListener('keydown', function (e) {
if (e.key === 'r' || e.key === 'R') {
frame = 0;
view.scrollTop = 0;
playing = true;
root.classList.add('is-demo');
ctrl.textContent = '일시정지';
render(coverProgress());
}
});
window.addEventListener('resize', function () {
render(coverProgress());
});
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
playing = false;
root.classList.remove('is-demo');
card.style.filter = 'blur(0px)';
card.style.opacity = '1';
badge.textContent = '통과 진행 50% · 흐림 0.0px';
}
})();import { useEffect, useRef, useState } from "react";
import styles from "./BlurFocusStack.module.scss";
// 핵심: animation-timeline: view() + animation-range: cover 로 요소가 뷰포트를
// 통과하는 전체 구간 값(0~1)에 흐림 정도를 묶는다 — 시작·끝에서 흐리고 중간에서
// 또렷해진다. 미지원 환경은 스크롤 위치로 같은 통과 진행 값을 직접 계산한다.
// VIEWTL_BLUR_MAX_PX — 변형 2: 12 → 24 로 늘리면 흐려지는 정도가 더 뚜렷해진다.
const VIEWTL_BLUR_MAX_PX = 12;
export default function BlurFocusStack() {
const rootRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<HTMLDivElement>(null);
const cardRef = useRef<HTMLDivElement>(null);
const [badge, setBadge] = useState("통과 구간 0%~100%");
const [demo, setDemo] = useState(true);
const [supportsVt, setSupportsVt] = useState(false);
useEffect(() => {
const view = viewRef.current;
const card = cardRef.current;
const root = rootRef.current;
if (!view || !card || !root) { return; }
card.style.setProperty("--blur-max", VIEWTL_BLUR_MAX_PX + "px");
const vt = CSS.supports("animation-timeline: view()");
setSupportsVt(vt);
function coverProgress() {
const elTop = card!.offsetTop;
const elH = card!.offsetHeight;
const viewH = view!.clientHeight;
const raw = (view!.scrollTop - elTop + viewH) / (elH + viewH);
return Math.min(1, Math.max(0, raw));
}
function render(cov: number) {
const hump = 1 - Math.abs(cov * 2 - 1);
const blurPx = VIEWTL_BLUR_MAX_PX * (1 - hump);
if (!vt) {
card!.style.filter = "blur(" + blurPx.toFixed(1) + "px)";
card!.style.opacity = String(0.55 + 0.45 * hump);
}
setBadge("통과 진행 " + Math.round(cov * 100) + "% · 흐림 " + blurPx.toFixed(1) + "px");
}
function onScroll() { render(coverProgress()); }
view.addEventListener("scroll", onScroll, { passive: true });
render(coverProgress());
let playing = true;
let frame = 0;
const PERIOD = 80;
let rafId = 0;
function tick() {
if (playing) {
frame += 1;
const max = view!.scrollHeight - view!.clientHeight;
const t = frame % (PERIOD * 2);
const f = t < PERIOD ? (t / PERIOD) : ((PERIOD * 2 - t) / PERIOD);
view!.scrollTop = f * max;
render(coverProgress());
}
rafId = requestAnimationFrame(tick);
}
rafId = requestAnimationFrame(tick);
function stopAuto() {
setDemo(false);
playing = false;
}
view.addEventListener("wheel", stopAuto, { passive: true });
view.addEventListener("touchstart", stopAuto, { passive: true });
function onClick() {
playing = !playing;
setDemo(playing);
render(coverProgress());
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === "r" || e.key === "R") {
frame = 0;
view!.scrollTop = 0;
playing = true;
setDemo(true);
render(coverProgress());
}
}
root.addEventListener("keydown", onKeyDown);
function onResize() { render(coverProgress()); }
window.addEventListener("resize", onResize);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
playing = false;
setDemo(false);
card.style.filter = "blur(0px)";
card.style.opacity = "1";
setBadge("통과 진행 50% · 흐림 0.0px");
}
const ctrl = root.querySelector("button");
ctrl?.addEventListener("click", onClick);
return () => {
cancelAnimationFrame(rafId);
view.removeEventListener("scroll", onScroll);
view.removeEventListener("wheel", stopAuto);
view.removeEventListener("touchstart", stopAuto);
root.removeEventListener("keydown", onKeyDown);
window.removeEventListener("resize", onResize);
ctrl?.removeEventListener("click", onClick);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div ref={rootRef} className={`${styles.bfs} ${demo ? styles["is-demo"] : ""} ${supportsVt ? styles["supports-vt"] : styles["no-vt"]}`}>
<div className={styles["bfs__badge"]}>{badge}</div>
<button className={styles["bfs__ctrl"]} type="button">{demo ? "일시정지" : "재생"}</button>
<div ref={viewRef} className={styles["bfs__view"]}>
<div className={styles["bfs__track"]}>
<div className={`${styles["bfs__spacer"]} ${styles["bfs__spacer--top"]}`} />
<div ref={cardRef} className={styles["bfs__card"]}>
<span className={styles["bfs__label"]}>제품</span>
</div>
<div className={`${styles["bfs__spacer"]} ${styles["bfs__spacer--bottom"]}`} />
</div>
</div>
</div>
</main>
);
}04 · 구간 값 따라 초점이 풀렸다 맞는 카드 — 측정 누적면적 28.216% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.supports-vt .bfs__card {
animation-name: bfsFocus;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: view();
animation-range: cover;
}
@keyframes bfsFocus {
0% { filter: blur(var(--blur-max, 12px)); opacity: .55; }
50% { filter: blur(0px); opacity: 1; }
100% { filter: blur(var(--blur-max, 12px)); opacity: .55; }
}
측정: 누적면적 28.2%, 23/23 프레임에서 움직였다.
05구간 값이 각도 값으로 바뀌는 3D 카드
카드가 화면을 지나는 동안 입체적으로 뒤집혀 뒷면을 보여준다. 같은 cover 구간 진행률 값을 rotateY 각도(0deg~180deg)로 바꿔 회전시키고, 구간이 중간을 지나는 순간 backface-visibility 로 감춰 둔 뒷면이 정면에 드러난다.

<main class="stage">
<div class="r3f is-demo" id="r3fRoot">
<div class="r3f__badge" id="r3fBadge">통과 구간 0%~100%</div>
<button class="r3f__ctrl" id="r3fCtrl" type="button">일시정지</button>
<div class="r3f__view" id="r3fView">
<div class="r3f__track" id="r3fTrack">
<div class="r3f__spacer r3f__spacer--top"></div>
<div class="r3f__flip">
<div class="r3f__inner" id="r3fInner">
<div class="r3f__face r3f__face--front">서비스</div>
<div class="r3f__face r3f__face--back">자세히</div>
</div>
</div>
<div class="r3f__spacer r3f__spacer--bottom"></div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: animation-timeline: view() + animation-range: cover 로 요소가 뷰포트를
// 통과하는 전체 구간 값(0~1)을 그대로 각도 값(0~180deg)으로 바꿔 카드를 입체적으로
// 뒤집는다. 미지원 환경은 스크롤 위치로 같은 통과 진행 값을 계산해 같은 각도를 적용한다.
$duration: $dur-loop;
$easing: linear;
$color: $subject-blue-deep;
@include stage($stage-ink);
.r3f {
position: relative;
width: min(100%, 280px);
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.r3f__badge {
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .08);
color: $subject-cream;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.r3f__ctrl {
position: absolute;
top: 0;
right: 0;
border: 0;
border-radius: $r-xs;
padding: $sp-1 $sp-2;
background: rgba(255, 247, 230, .12);
color: $subject-cream;
font-weight: 600;
font-size: 10px;
line-height: 1.4;
cursor: pointer;
}
.r3f__ctrl:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
.r3f__view {
box-sizing: border-box;
width: 100%;
height: 176px;
overflow-y: auto;
overflow-x: hidden;
border-radius: $r-card;
background: rgba(255, 255, 255, .05);
scrollbar-width: thin;
}
.r3f__track {
display: flex;
flex-direction: column;
align-items: center;
}
.r3f__spacer--top { height: 200px; flex: 0 0 auto; }
.r3f__spacer--bottom { height: 200px; flex: 0 0 auto; }
.r3f__flip {
flex: 0 0 auto;
width: 168px;
height: 88px;
perspective: 560px;
}
.r3f__inner {
position: relative;
width: 100%;
height: 100%;
transform-style: preserve-3d;
transform: rotateY(0deg);
}
.r3f__face {
position: absolute;
inset: 0;
display: grid;
place-items: center;
border-radius: $r-card;
box-shadow: $shadow-raised;
backface-visibility: hidden;
color: $subject-cream;
font-weight: 700;
font-size: 14px;
}
.r3f__face--front { background: $color; }
.r3f__face--back { background: $subject-orange-deep; transform: rotateY(180deg); }
.supports-vt .r3f__inner {
animation-name: r3fFlip;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: view();
animation-range: cover;
}
@keyframes r3fFlip {
0% { transform: rotateY(0deg); }
100% { transform: rotateY(180deg); }
}
@media (max-width: 340px) {
.r3f { gap: $sp-2; }
.r3f__view { height: 130px; }
.r3f__spacer--top { height: 150px; }
.r3f__spacer--bottom { height: 150px; }
.r3f__flip { width: 140px; height: 72px; }
}
@media (prefers-reduced-motion: reduce) {
.r3f__inner { animation: none !important; transform: rotateY(0deg) !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.r3f {
position: relative;
width: min(100%, 280px);
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.r3f__badge {
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.08);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.r3f__ctrl {
position: absolute;
top: 0;
right: 0;
border: 0;
border-radius: 4px;
padding: 4px 8px;
background: rgba(255, 247, 230, 0.12);
color: #fff7e6;
font-weight: 600;
font-size: 10px;
line-height: 1.4;
cursor: pointer;
}
.r3f__ctrl:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.r3f__view {
box-sizing: border-box;
width: 100%;
height: 176px;
overflow-y: auto;
overflow-x: hidden;
border-radius: 16px;
background: rgba(255, 255, 255, 0.05);
scrollbar-width: thin;
}
.r3f__track {
display: flex;
flex-direction: column;
align-items: center;
}
.r3f__spacer--top {
height: 200px;
flex: 0 0 auto;
}
.r3f__spacer--bottom {
height: 200px;
flex: 0 0 auto;
}
.r3f__flip {
flex: 0 0 auto;
width: 168px;
height: 88px;
perspective: 560px;
}
.r3f__inner {
position: relative;
width: 100%;
height: 100%;
transform-style: preserve-3d;
transform: rotateY(0deg);
}
.r3f__face {
position: absolute;
inset: 0;
display: grid;
place-items: center;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
backface-visibility: hidden;
color: #fff7e6;
font-weight: 700;
font-size: 14px;
}
.r3f__face--front {
background: #0a4fe8;
}
.r3f__face--back {
background: #c93000;
transform: rotateY(180deg);
}
.supports-vt .r3f__inner {
animation-name: r3fFlip;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: view();
animation-range: cover;
}
@keyframes r3fFlip {
0% {
transform: rotateY(0deg);
}
100% {
transform: rotateY(180deg);
}
}
@media (max-width: 340px) {
.r3f {
gap: 8px;
}
.r3f__view {
height: 130px;
}
.r3f__spacer--top {
height: 150px;
}
.r3f__spacer--bottom {
height: 150px;
}
.r3f__flip {
width: 140px;
height: 72px;
}
}
@media (prefers-reduced-motion: reduce) {
.r3f__inner {
animation: none !important;
transform: rotateY(0deg) !important;
}
}(function () {
var supportsVt = CSS.supports('animation-timeline: view()');
var root = document.getElementById('r3fRoot');
var view = document.getElementById('r3fView');
var inner = document.getElementById('r3fInner');
var badge = document.getElementById('r3fBadge');
var ctrl = document.getElementById('r3fCtrl');
root.classList.add(supportsVt ? 'supports-vt' : 'no-vt');
// 계산 한 겹: 요소가 뷰포트를 통과하는 구간(cover) 진행 값을 요소 높이·뷰포트
// 높이·스크롤 위치로 계산해, 같은 값을 각도 값으로 바꾼다.
function coverProgress() {
var elTop = inner.parentElement.offsetTop;
var elH = inner.parentElement.offsetHeight;
var viewH = view.clientHeight;
var raw = (view.scrollTop - elTop + viewH) / (elH + viewH);
return Math.min(1, Math.max(0, raw));
}
function render(cov) {
var angle = cov * 180;
if (!supportsVt) {
inner.style.transform = 'rotateY(' + angle.toFixed(1) + 'deg)';
}
badge.textContent = '통과 진행 ' + Math.round(cov * 100) + '% · 각도 ' + Math.round(angle) + 'deg';
}
view.addEventListener('scroll', function () {
render(coverProgress());
}, { passive: true });
render(coverProgress());
// 자동 시연: rAF 로 프레임을 누적해 컨테이너를 왕복 스크롤한다.
var playing = true;
var frame = 0;
var PERIOD = 80;
function tick() {
if (playing) {
frame += 1;
var max = view.scrollHeight - view.clientHeight;
var t = frame % (PERIOD * 2);
var f = t < PERIOD ? (t / PERIOD) : ((PERIOD * 2 - t) / PERIOD);
view.scrollTop = f * max;
render(coverProgress());
}
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
function stopAuto() {
if (root.classList.contains('is-demo')) { root.classList.remove('is-demo'); }
playing = false;
ctrl.textContent = '재생';
}
view.addEventListener('wheel', stopAuto, { passive: true });
view.addEventListener('touchstart', stopAuto, { passive: true });
ctrl.addEventListener('click', function () {
playing = !playing;
ctrl.textContent = playing ? '일시정지' : '재생';
if (playing) { root.classList.add('is-demo'); }
render(coverProgress());
});
ctrl.addEventListener('keydown', function (e) {
if (e.key === 'r' || e.key === 'R') {
frame = 0;
view.scrollTop = 0;
playing = true;
root.classList.add('is-demo');
ctrl.textContent = '일시정지';
render(coverProgress());
}
});
window.addEventListener('resize', function () {
render(coverProgress());
});
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
playing = false;
root.classList.remove('is-demo');
inner.style.transform = 'rotateY(0deg)';
badge.textContent = '통과 진행 0% · 각도 0deg';
}
})();import { useEffect, useRef, useState } from "react";
import styles from "./Rotate3dFlip.module.scss";
// 핵심: animation-timeline: view() + animation-range: cover 로 요소가 뷰포트를
// 통과하는 전체 구간 값(0~1)을 그대로 각도 값(0~180deg)으로 바꿔 카드를 입체적으로
// 뒤집는다. 미지원 환경은 스크롤 위치로 같은 통과 진행 값을 계산해 같은 각도를 적용한다.
export default function Rotate3dFlip() {
const rootRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<HTMLDivElement>(null);
const flipRef = useRef<HTMLDivElement>(null);
const innerRef = useRef<HTMLDivElement>(null);
const [badge, setBadge] = useState("통과 구간 0%~100%");
const [demo, setDemo] = useState(true);
const [supportsVt, setSupportsVt] = useState(false);
useEffect(() => {
const view = viewRef.current;
const flip = flipRef.current;
const inner = innerRef.current;
const root = rootRef.current;
if (!view || !flip || !inner || !root) { return; }
const vt = CSS.supports("animation-timeline: view()");
setSupportsVt(vt);
function coverProgress() {
const elTop = flip!.offsetTop;
const elH = flip!.offsetHeight;
const viewH = view!.clientHeight;
const raw = (view!.scrollTop - elTop + viewH) / (elH + viewH);
return Math.min(1, Math.max(0, raw));
}
function render(cov: number) {
const angle = cov * 180;
if (!vt) {
inner!.style.transform = "rotateY(" + angle.toFixed(1) + "deg)";
}
setBadge("통과 진행 " + Math.round(cov * 100) + "% · 각도 " + Math.round(angle) + "deg");
}
function onScroll() { render(coverProgress()); }
view.addEventListener("scroll", onScroll, { passive: true });
render(coverProgress());
let playing = true;
let frame = 0;
const PERIOD = 80;
let rafId = 0;
function tick() {
if (playing) {
frame += 1;
const max = view!.scrollHeight - view!.clientHeight;
const t = frame % (PERIOD * 2);
const f = t < PERIOD ? (t / PERIOD) : ((PERIOD * 2 - t) / PERIOD);
view!.scrollTop = f * max;
render(coverProgress());
}
rafId = requestAnimationFrame(tick);
}
rafId = requestAnimationFrame(tick);
function stopAuto() {
setDemo(false);
playing = false;
}
view.addEventListener("wheel", stopAuto, { passive: true });
view.addEventListener("touchstart", stopAuto, { passive: true });
function onClick() {
playing = !playing;
setDemo(playing);
render(coverProgress());
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === "r" || e.key === "R") {
frame = 0;
view!.scrollTop = 0;
playing = true;
setDemo(true);
render(coverProgress());
}
}
root.addEventListener("keydown", onKeyDown);
function onResize() { render(coverProgress()); }
window.addEventListener("resize", onResize);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
playing = false;
setDemo(false);
inner.style.transform = "rotateY(0deg)";
setBadge("통과 진행 0% · 각도 0deg");
}
const ctrl = root.querySelector("button");
ctrl?.addEventListener("click", onClick);
return () => {
cancelAnimationFrame(rafId);
view.removeEventListener("scroll", onScroll);
view.removeEventListener("wheel", stopAuto);
view.removeEventListener("touchstart", stopAuto);
root.removeEventListener("keydown", onKeyDown);
window.removeEventListener("resize", onResize);
ctrl?.removeEventListener("click", onClick);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div ref={rootRef} className={`${styles.r3f} ${demo ? styles["is-demo"] : ""} ${supportsVt ? styles["supports-vt"] : styles["no-vt"]}`}>
<div className={styles["r3f__badge"]}>{badge}</div>
<button className={styles["r3f__ctrl"]} type="button">{demo ? "일시정지" : "재생"}</button>
<div ref={viewRef} className={styles["r3f__view"]}>
<div className={styles["r3f__track"]}>
<div className={`${styles["r3f__spacer"]} ${styles["r3f__spacer--top"]}`} />
<div ref={flipRef} className={styles["r3f__flip"]}>
<div ref={innerRef} className={styles["r3f__inner"]}>
<div className={`${styles["r3f__face"]} ${styles["r3f__face--front"]}`}>서비스</div>
<div className={`${styles["r3f__face"]} ${styles["r3f__face--back"]}`}>자세히</div>
</div>
</div>
<div className={`${styles["r3f__spacer"]} ${styles["r3f__spacer--bottom"]}`} />
</div>
</div>
</div>
</main>
);
}05 · 구간 값이 각도 값으로 바뀌는 3D 카드 — 측정 누적면적 17.417% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.supports-vt .r3f__inner {
animation-name: r3fFlip;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: view();
animation-range: cover;
}
@keyframes r3fFlip {
0% { transform: rotateY(0deg); }
100% { transform: rotateY(180deg); }
}
측정: 누적면적 17.4%, 23/23 프레임에서 움직였다.
06기울기 값이 끝에서 풀리는 패널
패널이 비스듬히 기울어져 있다가 화면 안쪽으로 들어올수록 반듯해진다. entry 구간 진행률 값을 skewX 각도(-18deg~0deg)로 매핑해, 구간 시작에 가까울수록 기울기가 크게 남아 있다가 값이 끝에 닿는 순간 0deg 로 풀린다.

<main class="stage">
<div class="ssp is-demo" id="sspRoot">
<div class="ssp__badge" id="sspBadge">진입 구간 0%~0%</div>
<button class="ssp__ctrl" id="sspCtrl" type="button">일시정지</button>
<div class="ssp__view" id="sspView">
<div class="ssp__track" id="sspTrack">
<div class="ssp__spacer ssp__spacer--top"></div>
<div class="ssp__panel" id="sspPanel">
<span class="ssp__label">요금제</span>
</div>
<div class="ssp__spacer ssp__spacer--bottom"></div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: animation-timeline: view() + animation-range: entry 로 기울기 값을 진입
// 구간 값(0~1)에 직접 묶는다 — 시작 지점에 가까울수록 기울어져 있다가 구간 값이
// 끝에 닿는 순간 0으로 풀린다. 미지원 환경은 교차 비율로 같은 기울기 값을 계산한다.
$duration: $dur-loop;
$easing: linear;
$color: $subject-ink;
@include stage($stage-yellow);
.ssp {
position: relative;
width: min(100%, 280px);
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.ssp__badge {
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(23, 20, 26, .08);
color: $subject-ink;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.ssp__ctrl {
position: absolute;
top: 0;
right: 0;
border: 0;
border-radius: $r-xs;
padding: $sp-1 $sp-2;
background: rgba(23, 20, 26, .12);
color: $subject-ink;
font-weight: 600;
font-size: 10px;
line-height: 1.4;
cursor: pointer;
}
.ssp__ctrl:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.ssp__view {
box-sizing: border-box;
width: 100%;
height: 176px;
overflow-y: auto;
overflow-x: hidden;
border-radius: $r-card;
background: rgba(23, 20, 26, .06);
scrollbar-width: thin;
}
.ssp__track {
display: flex;
flex-direction: column;
align-items: center;
}
.ssp__spacer--top { height: 190px; flex: 0 0 auto; }
.ssp__spacer--bottom { height: 40px; flex: 0 0 auto; }
.ssp__panel {
flex: 0 0 auto;
box-sizing: border-box;
width: 200px;
height: 76px;
display: grid;
place-items: center;
border-radius: $r-card;
background: $color;
box-shadow: $shadow-raised;
transform: skewX(-18deg);
}
.ssp__label {
color: $subject-cream;
font-weight: 700;
font-size: 14px;
display: inline-block;
transform: skewX(18deg);
}
.supports-vt .ssp__panel {
animation-name: sspShear;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: view();
animation-range: entry;
}
@keyframes sspShear {
0% { transform: skewX(-18deg); }
100% { transform: skewX(0deg); }
}
@media (max-width: 340px) {
.ssp { gap: $sp-2; }
.ssp__view { height: 130px; }
.ssp__spacer--top { height: 130px; }
.ssp__spacer--bottom { height: 26px; }
.ssp__panel { width: 168px; height: 60px; }
}
@media (prefers-reduced-motion: reduce) {
.ssp__panel { animation: none !important; transform: skewX(0deg) !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ssp {
position: relative;
width: min(100%, 280px);
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.ssp__badge {
padding: 4px 12px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.08);
color: #17141a;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.ssp__ctrl {
position: absolute;
top: 0;
right: 0;
border: 0;
border-radius: 4px;
padding: 4px 8px;
background: rgba(23, 20, 26, 0.12);
color: #17141a;
font-weight: 600;
font-size: 10px;
line-height: 1.4;
cursor: pointer;
}
.ssp__ctrl:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.ssp__view {
box-sizing: border-box;
width: 100%;
height: 176px;
overflow-y: auto;
overflow-x: hidden;
border-radius: 16px;
background: rgba(23, 20, 26, 0.06);
scrollbar-width: thin;
}
.ssp__track {
display: flex;
flex-direction: column;
align-items: center;
}
.ssp__spacer--top {
height: 190px;
flex: 0 0 auto;
}
.ssp__spacer--bottom {
height: 40px;
flex: 0 0 auto;
}
.ssp__panel {
flex: 0 0 auto;
box-sizing: border-box;
width: 200px;
height: 76px;
display: grid;
place-items: center;
border-radius: 16px;
background: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform: skewX(-18deg);
}
.ssp__label {
color: #fff7e6;
font-weight: 700;
font-size: 14px;
display: inline-block;
transform: skewX(18deg);
}
.supports-vt .ssp__panel {
animation-name: sspShear;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: view();
animation-range: entry;
}
@keyframes sspShear {
0% {
transform: skewX(-18deg);
}
100% {
transform: skewX(0deg);
}
}
@media (max-width: 340px) {
.ssp {
gap: 8px;
}
.ssp__view {
height: 130px;
}
.ssp__spacer--top {
height: 130px;
}
.ssp__spacer--bottom {
height: 26px;
}
.ssp__panel {
width: 168px;
height: 60px;
}
}
@media (prefers-reduced-motion: reduce) {
.ssp__panel {
animation: none !important;
transform: skewX(0deg) !important;
}
}(function () {
var supportsVt = CSS.supports('animation-timeline: view()');
var root = document.getElementById('sspRoot');
var view = document.getElementById('sspView');
var panel = document.getElementById('sspPanel');
var badge = document.getElementById('sspBadge');
var ctrl = document.getElementById('sspCtrl');
root.classList.add(supportsVt ? 'supports-vt' : 'no-vt');
// 계산 한 겹: 요소 높이·뷰포트 높이에서 진입 구간(시작~끝 %) 값을 구해 배지에 표시한다.
function entryRangePct() {
var elH = panel.offsetHeight;
var viewH = view.clientHeight;
return Math.round(Math.min(100, (elH / viewH) * 100));
}
var rangeEnd = entryRangePct();
var rangeText = '진입 구간 0%~' + rangeEnd + '%';
var SKEW_START = -18;
function render(ratio) {
if (!supportsVt) {
var skew = SKEW_START * (1 - ratio);
panel.style.transform = 'skewX(' + skew.toFixed(1) + 'deg)';
}
badge.textContent = rangeText + ' · 진행 ' + Math.round(ratio * 100) + '%';
}
var lastRatio = 0;
if (!supportsVt) {
var STEPS = [];
for (var s = 0; s <= 50; s += 1) { STEPS.push(s / 50); }
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
lastRatio = entry.intersectionRatio;
render(lastRatio);
});
}, { root: view, threshold: STEPS });
io.observe(panel);
} else {
badge.textContent = rangeText;
}
view.addEventListener('scroll', function () {
if (supportsVt) {
var max = view.scrollHeight - view.clientHeight;
var p = max > 0 ? Math.min(1, Math.max(0, view.scrollTop / max)) : 0;
badge.textContent = rangeText + ' · 스크롤 ' + Math.round(p * 100) + '%';
}
}, { passive: true });
var playing = true;
var frame = 0;
var PERIOD = 66;
function tick() {
if (playing) {
frame += 1;
var max = view.scrollHeight - view.clientHeight;
var t = frame % (PERIOD * 2);
var f = t < PERIOD ? (t / PERIOD) : ((PERIOD * 2 - t) / PERIOD);
view.scrollTop = f * max;
}
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
function stopAuto() {
if (root.classList.contains('is-demo')) { root.classList.remove('is-demo'); }
playing = false;
ctrl.textContent = '재생';
}
view.addEventListener('wheel', stopAuto, { passive: true });
view.addEventListener('touchstart', stopAuto, { passive: true });
ctrl.addEventListener('click', function () {
playing = !playing;
ctrl.textContent = playing ? '일시정지' : '재생';
if (playing) { root.classList.add('is-demo'); }
});
ctrl.addEventListener('keydown', function (e) {
if (e.key === 'r' || e.key === 'R') {
frame = 0;
view.scrollTop = 0;
lastRatio = 0;
render(0);
playing = true;
root.classList.add('is-demo');
ctrl.textContent = '일시정지';
}
});
window.addEventListener('resize', function () {
rangeEnd = entryRangePct();
rangeText = '진입 구간 0%~' + rangeEnd + '%';
});
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
playing = false;
root.classList.remove('is-demo');
lastRatio = 1;
render(1);
panel.style.transform = 'skewX(0deg)';
badge.textContent = rangeText + ' · 최종 상태';
}
})();import { useEffect, useRef, useState } from "react";
import styles from "./SkewShearPanel.module.scss";
// 핵심: animation-timeline: view() + animation-range: entry 로 기울기 값을 진입
// 구간 값(0~1)에 직접 묶는다 — 시작 지점에 가까울수록 기울어져 있다가 구간 값이
// 끝에 닿는 순간 0으로 풀린다. 미지원 환경은 교차 비율로 같은 기울기 값을 계산한다.
const SKEW_START = -18;
export default function SkewShearPanel() {
const rootRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [badge, setBadge] = useState("진입 구간 0%~0%");
const [demo, setDemo] = useState(true);
const [supportsVt, setSupportsVt] = useState(false);
useEffect(() => {
const view = viewRef.current;
const panel = panelRef.current;
const root = rootRef.current;
if (!view || !panel || !root) { return; }
const vt = CSS.supports("animation-timeline: view()");
setSupportsVt(vt);
function entryRangePct() {
const elH = panel!.offsetHeight;
const viewH = view!.clientHeight;
return Math.round(Math.min(100, (elH / viewH) * 100));
}
let rangeEnd = entryRangePct();
let rangeText = "진입 구간 0%~" + rangeEnd + "%";
function render(ratio: number) {
if (!vt) {
const skew = SKEW_START * (1 - ratio);
panel!.style.transform = "skewX(" + skew.toFixed(1) + "deg)";
}
setBadge(rangeText + " · 진행 " + Math.round(ratio * 100) + "%");
}
let io: IntersectionObserver | null = null;
if (!vt) {
const STEPS: number[] = [];
for (let s = 0; s <= 50; s += 1) { STEPS.push(s / 50); }
io = new IntersectionObserver((entries) => {
entries.forEach((entry) => { render(entry.intersectionRatio); });
}, { root: view, threshold: STEPS });
io.observe(panel);
} else {
setBadge(rangeText);
}
function onScroll() {
if (vt) {
const max = view!.scrollHeight - view!.clientHeight;
const p = max > 0 ? Math.min(1, Math.max(0, view!.scrollTop / max)) : 0;
setBadge(rangeText + " · 스크롤 " + Math.round(p * 100) + "%");
}
}
view.addEventListener("scroll", onScroll, { passive: true });
let playing = true;
let frame = 0;
const PERIOD = 66;
let rafId = 0;
function tick() {
if (playing) {
frame += 1;
const max = view!.scrollHeight - view!.clientHeight;
const t = frame % (PERIOD * 2);
const f = t < PERIOD ? (t / PERIOD) : ((PERIOD * 2 - t) / PERIOD);
view!.scrollTop = f * max;
}
rafId = requestAnimationFrame(tick);
}
rafId = requestAnimationFrame(tick);
function stopAuto() {
setDemo(false);
playing = false;
}
view.addEventListener("wheel", stopAuto, { passive: true });
view.addEventListener("touchstart", stopAuto, { passive: true });
function onClick() {
playing = !playing;
setDemo(playing);
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === "r" || e.key === "R") {
frame = 0;
view!.scrollTop = 0;
render(0);
playing = true;
setDemo(true);
}
}
root.addEventListener("keydown", onKeyDown);
function onResize() {
rangeEnd = entryRangePct();
rangeText = "진입 구간 0%~" + rangeEnd + "%";
}
window.addEventListener("resize", onResize);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
playing = false;
setDemo(false);
render(1);
panel.style.transform = "skewX(0deg)";
setBadge(rangeText + " · 최종 상태");
}
const ctrl = root.querySelector("button");
ctrl?.addEventListener("click", onClick);
return () => {
cancelAnimationFrame(rafId);
io?.disconnect();
view.removeEventListener("scroll", onScroll);
view.removeEventListener("wheel", stopAuto);
view.removeEventListener("touchstart", stopAuto);
root.removeEventListener("keydown", onKeyDown);
window.removeEventListener("resize", onResize);
ctrl?.removeEventListener("click", onClick);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div ref={rootRef} className={`${styles.ssp} ${demo ? styles["is-demo"] : ""} ${supportsVt ? styles["supports-vt"] : styles["no-vt"]}`}>
<div className={styles["ssp__badge"]}>{badge}</div>
<button className={styles["ssp__ctrl"]} type="button">{demo ? "일시정지" : "재생"}</button>
<div ref={viewRef} className={styles["ssp__view"]}>
<div className={styles["ssp__track"]}>
<div className={`${styles["ssp__spacer"]} ${styles["ssp__spacer--top"]}`} />
<div ref={panelRef} className={styles["ssp__panel"]}>
<span className={styles["ssp__label"]}>요금제</span>
</div>
<div className={`${styles["ssp__spacer"]} ${styles["ssp__spacer--bottom"]}`} />
</div>
</div>
</div>
</main>
);
}06 · 기울기 값이 끝에서 풀리는 패널 — 측정 누적면적 17.512% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.supports-vt .ssp__panel {
animation-name: sspShear;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: view();
animation-range: entry;
}
@keyframes sspShear {
0% { transform: skewX(-18deg); }
100% { transform: skewX(0deg); }
}
측정: 누적면적 17.5%, 23/23 프레임에서 움직였다.
07구간 값이 색상 보간 지점이 되는 배지
배지 색이 한 색에서 다른 색으로 서서히 옮겨간다. entry 구간 진행률 값을 두 배경색(파랑 계열 → 주황 계열) 사이의 보간 지점으로 써서, 별도 트랜지션 없이도 색이 값 하나로 연속해서 바뀐다.

<main class="stage">
<div class="cmb is-demo" id="cmbRoot">
<div class="cmb__badge" id="cmbBadge">진입 구간 0%~0%</div>
<button class="cmb__ctrl" id="cmbCtrl" type="button">일시정지</button>
<div class="cmb__view" id="cmbView">
<div class="cmb__track" id="cmbTrack">
<div class="cmb__spacer cmb__spacer--top"></div>
<div class="cmb__pill" id="cmbPill">
<span class="cmb__label">신규</span>
</div>
<div class="cmb__spacer cmb__spacer--bottom"></div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: animation-timeline: view() + animation-range: entry 로 배지 배경색을 진입
// 구간 값(0~1)을 보간 지점 삼아 한 색에서 다른 색으로 옮긴다. 미지원 환경은 교차
// 비율을 같은 보간 지점으로 써서 RGB 값을 직접 계산한다.
$duration: $dur-loop;
$easing: linear;
$color: $subject-blue-deep;
@include stage($stage-orange);
.cmb {
position: relative;
width: min(100%, 280px);
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.cmb__badge {
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(23, 20, 26, .10);
color: $subject-ink;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.cmb__ctrl {
position: absolute;
top: 0;
right: 0;
border: 0;
border-radius: $r-xs;
padding: $sp-1 $sp-2;
background: rgba(23, 20, 26, .14);
color: $subject-ink;
font-weight: 600;
font-size: 10px;
line-height: 1.4;
cursor: pointer;
}
.cmb__ctrl:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.cmb__view {
box-sizing: border-box;
width: 100%;
height: 176px;
overflow-y: auto;
overflow-x: hidden;
border-radius: $r-card;
background: rgba(23, 20, 26, .08);
scrollbar-width: thin;
}
.cmb__track {
display: flex;
flex-direction: column;
align-items: center;
}
.cmb__spacer--top { height: 190px; flex: 0 0 auto; }
.cmb__spacer--bottom { height: 40px; flex: 0 0 auto; }
.cmb__pill {
flex: 0 0 auto;
box-sizing: border-box;
width: 132px;
height: 56px;
display: grid;
place-items: center;
border-radius: $r-pill;
background: $color;
box-shadow: $shadow-raised;
}
.cmb__label {
color: $subject-cream;
font-weight: 700;
font-size: 14px;
}
.supports-vt .cmb__pill {
animation-name: cmbMorph;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: view();
animation-range: entry;
}
@keyframes cmbMorph {
0% { background-color: $subject-blue-deep; }
100% { background-color: $subject-orange-deep; }
}
@media (max-width: 340px) {
.cmb { gap: $sp-2; }
.cmb__view { height: 130px; }
.cmb__spacer--top { height: 130px; }
.cmb__spacer--bottom { height: 26px; }
.cmb__pill { width: 112px; height: 48px; }
}
@media (prefers-reduced-motion: reduce) {
.cmb__pill { animation: none !important; background-color: $subject-orange-deep !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cmb {
position: relative;
width: min(100%, 280px);
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.cmb__badge {
padding: 4px 12px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.1);
color: #17141a;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.cmb__ctrl {
position: absolute;
top: 0;
right: 0;
border: 0;
border-radius: 4px;
padding: 4px 8px;
background: rgba(23, 20, 26, 0.14);
color: #17141a;
font-weight: 600;
font-size: 10px;
line-height: 1.4;
cursor: pointer;
}
.cmb__ctrl:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.cmb__view {
box-sizing: border-box;
width: 100%;
height: 176px;
overflow-y: auto;
overflow-x: hidden;
border-radius: 16px;
background: rgba(23, 20, 26, 0.08);
scrollbar-width: thin;
}
.cmb__track {
display: flex;
flex-direction: column;
align-items: center;
}
.cmb__spacer--top {
height: 190px;
flex: 0 0 auto;
}
.cmb__spacer--bottom {
height: 40px;
flex: 0 0 auto;
}
.cmb__pill {
flex: 0 0 auto;
box-sizing: border-box;
width: 132px;
height: 56px;
display: grid;
place-items: center;
border-radius: 999px;
background: #0a4fe8;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.cmb__label {
color: #fff7e6;
font-weight: 700;
font-size: 14px;
}
.supports-vt .cmb__pill {
animation-name: cmbMorph;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: view();
animation-range: entry;
}
@keyframes cmbMorph {
0% {
background-color: #0a4fe8;
}
100% {
background-color: #c93000;
}
}
@media (max-width: 340px) {
.cmb {
gap: 8px;
}
.cmb__view {
height: 130px;
}
.cmb__spacer--top {
height: 130px;
}
.cmb__spacer--bottom {
height: 26px;
}
.cmb__pill {
width: 112px;
height: 48px;
}
}
@media (prefers-reduced-motion: reduce) {
.cmb__pill {
animation: none !important;
background-color: #c93000 !important;
}
}(function () {
var supportsVt = CSS.supports('animation-timeline: view()');
var root = document.getElementById('cmbRoot');
var view = document.getElementById('cmbView');
var pill = document.getElementById('cmbPill');
var badge = document.getElementById('cmbBadge');
var ctrl = document.getElementById('cmbCtrl');
root.classList.add(supportsVt ? 'supports-vt' : 'no-vt');
// 색상 보간 지점의 양 끝 값(RGB) — CSS 토큰의 실제 값을 그대로 옮겨 왔다.
var COLOR_FROM = [10, 79, 232];
var COLOR_TO = [201, 48, 0];
// 계산 한 겹: 요소 높이·뷰포트 높이에서 진입 구간(시작~끝 %) 값을 구해 배지에 표시한다.
function entryRangePct() {
var elH = pill.offsetHeight;
var viewH = view.clientHeight;
return Math.round(Math.min(100, (elH / viewH) * 100));
}
var rangeEnd = entryRangePct();
var rangeText = '진입 구간 0%~' + rangeEnd + '%';
function lerp(a, b, t) { return Math.round(a + (b - a) * t); }
function render(ratio) {
if (!supportsVt) {
var r = lerp(COLOR_FROM[0], COLOR_TO[0], ratio);
var g = lerp(COLOR_FROM[1], COLOR_TO[1], ratio);
var b = lerp(COLOR_FROM[2], COLOR_TO[2], ratio);
pill.style.backgroundColor = 'rgb(' + r + ', ' + g + ', ' + b + ')';
}
badge.textContent = rangeText + ' · 진행 ' + Math.round(ratio * 100) + '%';
}
var lastRatio = 0;
if (!supportsVt) {
var STEPS = [];
for (var s = 0; s <= 50; s += 1) { STEPS.push(s / 50); }
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
lastRatio = entry.intersectionRatio;
render(lastRatio);
});
}, { root: view, threshold: STEPS });
io.observe(pill);
} else {
badge.textContent = rangeText;
}
view.addEventListener('scroll', function () {
if (supportsVt) {
var max = view.scrollHeight - view.clientHeight;
var p = max > 0 ? Math.min(1, Math.max(0, view.scrollTop / max)) : 0;
badge.textContent = rangeText + ' · 스크롤 ' + Math.round(p * 100) + '%';
}
}, { passive: true });
var playing = true;
var frame = 0;
var PERIOD = 66;
function tick() {
if (playing) {
frame += 1;
var max = view.scrollHeight - view.clientHeight;
var t = frame % (PERIOD * 2);
var f = t < PERIOD ? (t / PERIOD) : ((PERIOD * 2 - t) / PERIOD);
view.scrollTop = f * max;
}
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
function stopAuto() {
if (root.classList.contains('is-demo')) { root.classList.remove('is-demo'); }
playing = false;
ctrl.textContent = '재생';
}
view.addEventListener('wheel', stopAuto, { passive: true });
view.addEventListener('touchstart', stopAuto, { passive: true });
ctrl.addEventListener('click', function () {
playing = !playing;
ctrl.textContent = playing ? '일시정지' : '재생';
if (playing) { root.classList.add('is-demo'); }
});
ctrl.addEventListener('keydown', function (e) {
if (e.key === 'r' || e.key === 'R') {
frame = 0;
view.scrollTop = 0;
lastRatio = 0;
render(0);
playing = true;
root.classList.add('is-demo');
ctrl.textContent = '일시정지';
}
});
window.addEventListener('resize', function () {
rangeEnd = entryRangePct();
rangeText = '진입 구간 0%~' + rangeEnd + '%';
});
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
playing = false;
root.classList.remove('is-demo');
lastRatio = 1;
render(1);
pill.style.backgroundColor = 'rgb(201, 48, 0)';
badge.textContent = rangeText + ' · 최종 상태';
}
})();import { useEffect, useRef, useState } from "react";
import styles from "./ColorMorphBadge.module.scss";
// 핵심: animation-timeline: view() + animation-range: entry 로 배지 배경색을 진입
// 구간 값(0~1)을 보간 지점 삼아 한 색에서 다른 색으로 옮긴다. 미지원 환경은 교차
// 비율을 같은 보간 지점으로 써서 RGB 값을 직접 계산한다.
const COLOR_FROM = [10, 79, 232];
const COLOR_TO = [201, 48, 0];
function lerp(a: number, b: number, t: number) { return Math.round(a + (b - a) * t); }
export default function ColorMorphBadge() {
const rootRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<HTMLDivElement>(null);
const pillRef = useRef<HTMLDivElement>(null);
const [badge, setBadge] = useState("진입 구간 0%~0%");
const [demo, setDemo] = useState(true);
const [supportsVt, setSupportsVt] = useState(false);
useEffect(() => {
const view = viewRef.current;
const pill = pillRef.current;
const root = rootRef.current;
if (!view || !pill || !root) { return; }
const vt = CSS.supports("animation-timeline: view()");
setSupportsVt(vt);
function entryRangePct() {
const elH = pill!.offsetHeight;
const viewH = view!.clientHeight;
return Math.round(Math.min(100, (elH / viewH) * 100));
}
let rangeEnd = entryRangePct();
let rangeText = "진입 구간 0%~" + rangeEnd + "%";
function render(ratio: number) {
if (!vt) {
const r = lerp(COLOR_FROM[0], COLOR_TO[0], ratio);
const g = lerp(COLOR_FROM[1], COLOR_TO[1], ratio);
const b = lerp(COLOR_FROM[2], COLOR_TO[2], ratio);
pill!.style.backgroundColor = "rgb(" + r + ", " + g + ", " + b + ")";
}
setBadge(rangeText + " · 진행 " + Math.round(ratio * 100) + "%");
}
let io: IntersectionObserver | null = null;
if (!vt) {
const STEPS: number[] = [];
for (let s = 0; s <= 50; s += 1) { STEPS.push(s / 50); }
io = new IntersectionObserver((entries) => {
entries.forEach((entry) => { render(entry.intersectionRatio); });
}, { root: view, threshold: STEPS });
io.observe(pill);
} else {
setBadge(rangeText);
}
function onScroll() {
if (vt) {
const max = view!.scrollHeight - view!.clientHeight;
const p = max > 0 ? Math.min(1, Math.max(0, view!.scrollTop / max)) : 0;
setBadge(rangeText + " · 스크롤 " + Math.round(p * 100) + "%");
}
}
view.addEventListener("scroll", onScroll, { passive: true });
let playing = true;
let frame = 0;
const PERIOD = 66;
let rafId = 0;
function tick() {
if (playing) {
frame += 1;
const max = view!.scrollHeight - view!.clientHeight;
const t = frame % (PERIOD * 2);
const f = t < PERIOD ? (t / PERIOD) : ((PERIOD * 2 - t) / PERIOD);
view!.scrollTop = f * max;
}
rafId = requestAnimationFrame(tick);
}
rafId = requestAnimationFrame(tick);
function stopAuto() {
setDemo(false);
playing = false;
}
view.addEventListener("wheel", stopAuto, { passive: true });
view.addEventListener("touchstart", stopAuto, { passive: true });
function onClick() {
playing = !playing;
setDemo(playing);
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === "r" || e.key === "R") {
frame = 0;
view!.scrollTop = 0;
render(0);
playing = true;
setDemo(true);
}
}
root.addEventListener("keydown", onKeyDown);
function onResize() {
rangeEnd = entryRangePct();
rangeText = "진입 구간 0%~" + rangeEnd + "%";
}
window.addEventListener("resize", onResize);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
playing = false;
setDemo(false);
render(1);
pill.style.backgroundColor = "rgb(201, 48, 0)";
setBadge(rangeText + " · 최종 상태");
}
const ctrl = root.querySelector("button");
ctrl?.addEventListener("click", onClick);
return () => {
cancelAnimationFrame(rafId);
io?.disconnect();
view.removeEventListener("scroll", onScroll);
view.removeEventListener("wheel", stopAuto);
view.removeEventListener("touchstart", stopAuto);
root.removeEventListener("keydown", onKeyDown);
window.removeEventListener("resize", onResize);
ctrl?.removeEventListener("click", onClick);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div ref={rootRef} className={`${styles.cmb} ${demo ? styles["is-demo"] : ""} ${supportsVt ? styles["supports-vt"] : styles["no-vt"]}`}>
<div className={styles["cmb__badge"]}>{badge}</div>
<button className={styles["cmb__ctrl"]} type="button">{demo ? "일시정지" : "재생"}</button>
<div ref={viewRef} className={styles["cmb__view"]}>
<div className={styles["cmb__track"]}>
<div className={`${styles["cmb__spacer"]} ${styles["cmb__spacer--top"]}`} />
<div ref={pillRef} className={styles["cmb__pill"]}>
<span className={styles["cmb__label"]}>신규</span>
</div>
<div className={`${styles["cmb__spacer"]} ${styles["cmb__spacer--bottom"]}`} />
</div>
</div>
</div>
</main>
);
}07 · 구간 값이 색상 값 보간 지점이 되는 배지 — 측정 누적면적 10.083% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.supports-vt .cmb__pill {
animation-name: cmbMorph;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: view();
animation-range: entry;
}
@keyframes cmbMorph {
0% { background-color: $subject-blue-deep; }
100% { background-color: $subject-orange-deep; }
}
측정: 누적면적 10.1%, 23/23 프레임에서 움직였다.
08구간 값만큼 떠오르는 그림자 카드
카드가 화면을 지나는 동안 점점 떠오르는 것처럼 보인다. cover 구간 진행률 값에 맞춰 box-shadow 의 번짐 거리·흐림 값을 calc(6px + --shadow-spread × 비율) 식으로 함께 늘리고, 동시에 translateY 로 살짝 띄운다.

<main class="stage">
<div class="sld is-demo" id="sldRoot">
<div class="sld__badge" id="sldBadge">통과 구간 0%~100%</div>
<button class="sld__ctrl" id="sldCtrl" type="button">일시정지</button>
<div class="sld__view" id="sldView">
<div class="sld__track" id="sldTrack">
<div class="sld__spacer sld__spacer--top"></div>
<div class="sld__card" id="sldCard">
<span class="sld__label">카드</span>
</div>
<div class="sld__spacer sld__spacer--bottom"></div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: animation-timeline: view() + animation-range: cover 로 요소가 뷰포트를
// 통과하는 전체 구간 값(0~1)에 그림자 번짐 거리·흐림 값과 들어올림 정도를 함께
// 묶는다. 미지원 환경은 스크롤 위치로 같은 통과 진행 값을 계산해 같은 그림자를 그린다.
$duration: $dur-loop;
$easing: linear;
$color: $subject-blue-deep;
@include stage($stage-paper);
.sld {
position: relative;
width: min(100%, 280px);
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.sld__badge {
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(20, 45, 120, .08);
color: $subject-blue-deep;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.sld__ctrl {
position: absolute;
top: 0;
right: 0;
border: 0;
border-radius: $r-xs;
padding: $sp-1 $sp-2;
background: rgba(20, 45, 120, .10);
color: $subject-blue-deep;
font-weight: 600;
font-size: 10px;
line-height: 1.4;
cursor: pointer;
}
.sld__ctrl:focus-visible { outline: 2px solid $subject-blue-deep; outline-offset: 2px; }
.sld__view {
box-sizing: border-box;
width: 100%;
height: 176px;
overflow-y: auto;
overflow-x: hidden;
border-radius: $r-card;
background: rgba(20, 45, 120, .05);
scrollbar-width: thin;
}
.sld__track {
display: flex;
flex-direction: column;
align-items: center;
}
.sld__spacer--top { height: 200px; flex: 0 0 auto; }
.sld__spacer--bottom { height: 200px; flex: 0 0 auto; }
.sld__card {
flex: 0 0 auto;
box-sizing: border-box;
width: 188px;
height: 76px;
display: grid;
place-items: center;
border-radius: $r-card;
background: $color;
box-shadow: 0 6px 10px 0px rgba(23, 20, 26, .35);
transform: translateY(0px);
}
.sld__label {
color: $subject-cream;
font-weight: 700;
font-size: 14px;
}
.supports-vt .sld__card {
animation-name: sldLift;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: view();
animation-range: cover;
}
@keyframes sldLift {
0% { box-shadow: 0 6px 10px 0px rgba(23, 20, 26, .35); transform: translateY(0px); }
100% {
box-shadow: 0 calc(6px + var(--shadow-spread, 24px) * .3) calc(10px + var(--shadow-spread, 24px) * .6) var(--shadow-spread, 24px) rgba(23, 20, 26, .35);
transform: translateY(-14px);
}
}
@media (max-width: 340px) {
.sld { gap: $sp-2; }
.sld__view { height: 130px; }
.sld__spacer--top { height: 150px; }
.sld__spacer--bottom { height: 150px; }
.sld__card { width: 160px; height: 64px; }
}
@media (prefers-reduced-motion: reduce) {
.sld__card {
animation: none !important;
box-shadow: 0 calc(6px + var(--shadow-spread, 24px) * .3) calc(10px + var(--shadow-spread, 24px) * .6) var(--shadow-spread, 24px) rgba(23, 20, 26, .35) !important;
transform: translateY(-14px) !important;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sld {
position: relative;
width: min(100%, 280px);
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.sld__badge {
padding: 4px 12px;
border-radius: 999px;
background: rgba(20, 45, 120, 0.08);
color: #0a4fe8;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.sld__ctrl {
position: absolute;
top: 0;
right: 0;
border: 0;
border-radius: 4px;
padding: 4px 8px;
background: rgba(20, 45, 120, 0.1);
color: #0a4fe8;
font-weight: 600;
font-size: 10px;
line-height: 1.4;
cursor: pointer;
}
.sld__ctrl:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
.sld__view {
box-sizing: border-box;
width: 100%;
height: 176px;
overflow-y: auto;
overflow-x: hidden;
border-radius: 16px;
background: rgba(20, 45, 120, 0.05);
scrollbar-width: thin;
}
.sld__track {
display: flex;
flex-direction: column;
align-items: center;
}
.sld__spacer--top {
height: 200px;
flex: 0 0 auto;
}
.sld__spacer--bottom {
height: 200px;
flex: 0 0 auto;
}
.sld__card {
flex: 0 0 auto;
box-sizing: border-box;
width: 188px;
height: 76px;
display: grid;
place-items: center;
border-radius: 16px;
background: #0a4fe8;
box-shadow: 0 6px 10px 0px rgba(23, 20, 26, 0.35);
transform: translateY(0px);
}
.sld__label {
color: #fff7e6;
font-weight: 700;
font-size: 14px;
}
.supports-vt .sld__card {
animation-name: sldLift;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: view();
animation-range: cover;
}
@keyframes sldLift {
0% {
box-shadow: 0 6px 10px 0px rgba(23, 20, 26, 0.35);
transform: translateY(0px);
}
100% {
box-shadow: 0 calc(6px + var(--shadow-spread, 24px) * 0.3) calc(10px + var(--shadow-spread, 24px) * 0.6) var(--shadow-spread, 24px) rgba(23, 20, 26, 0.35);
transform: translateY(-14px);
}
}
@media (max-width: 340px) {
.sld {
gap: 8px;
}
.sld__view {
height: 130px;
}
.sld__spacer--top {
height: 150px;
}
.sld__spacer--bottom {
height: 150px;
}
.sld__card {
width: 160px;
height: 64px;
}
}
@media (prefers-reduced-motion: reduce) {
.sld__card {
animation: none !important;
box-shadow: 0 calc(6px + var(--shadow-spread, 24px) * 0.3) calc(10px + var(--shadow-spread, 24px) * 0.6) var(--shadow-spread, 24px) rgba(23, 20, 26, 0.35) !important;
transform: translateY(-14px) !important;
}
}(function () {
// VIEWTL_SHADOW_SPREAD_PX — 변형 3: 24 → 48 로 늘리면 그림자가 멀리 번져 카드가 훨씬 높이 떠 보인다.
var VIEWTL_SHADOW_SPREAD_PX = 24;
document.documentElement.style.setProperty('--shadow-spread', VIEWTL_SHADOW_SPREAD_PX + 'px');
var supportsVt = CSS.supports('animation-timeline: view()');
var root = document.getElementById('sldRoot');
var view = document.getElementById('sldView');
var card = document.getElementById('sldCard');
var badge = document.getElementById('sldBadge');
var ctrl = document.getElementById('sldCtrl');
root.classList.add(supportsVt ? 'supports-vt' : 'no-vt');
// 계산 한 겹: 요소가 뷰포트를 통과하는 구간(cover) 진행 값을 요소 높이·뷰포트
// 높이·스크롤 위치로 직접 계산한다.
function coverProgress() {
var elTop = card.offsetTop;
var elH = card.offsetHeight;
var viewH = view.clientHeight;
var raw = (view.scrollTop - elTop + viewH) / (elH + viewH);
return Math.min(1, Math.max(0, raw));
}
function render(cov) {
var spread = VIEWTL_SHADOW_SPREAD_PX * cov;
if (!supportsVt) {
var y = (6 + spread * 0.3).toFixed(1);
var blur = (10 + spread * 0.6).toFixed(1);
card.style.boxShadow = '0 ' + y + 'px ' + blur + 'px ' + spread.toFixed(1) + 'px rgba(23, 20, 26, .35)';
card.style.transform = 'translateY(' + (-14 * cov).toFixed(1) + 'px)';
}
badge.textContent = '통과 진행 ' + Math.round(cov * 100) + '% · 번짐 ' + spread.toFixed(1) + 'px';
}
view.addEventListener('scroll', function () {
render(coverProgress());
}, { passive: true });
render(coverProgress());
var playing = true;
var frame = 0;
var PERIOD = 80;
function tick() {
if (playing) {
frame += 1;
var max = view.scrollHeight - view.clientHeight;
var t = frame % (PERIOD * 2);
var f = t < PERIOD ? (t / PERIOD) : ((PERIOD * 2 - t) / PERIOD);
view.scrollTop = f * max;
render(coverProgress());
}
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
function stopAuto() {
if (root.classList.contains('is-demo')) { root.classList.remove('is-demo'); }
playing = false;
ctrl.textContent = '재생';
}
view.addEventListener('wheel', stopAuto, { passive: true });
view.addEventListener('touchstart', stopAuto, { passive: true });
ctrl.addEventListener('click', function () {
playing = !playing;
ctrl.textContent = playing ? '일시정지' : '재생';
if (playing) { root.classList.add('is-demo'); }
render(coverProgress());
});
ctrl.addEventListener('keydown', function (e) {
if (e.key === 'r' || e.key === 'R') {
frame = 0;
view.scrollTop = 0;
playing = true;
root.classList.add('is-demo');
ctrl.textContent = '일시정지';
render(coverProgress());
}
});
window.addEventListener('resize', function () {
render(coverProgress());
});
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
playing = false;
root.classList.remove('is-demo');
render(1);
badge.textContent = '통과 진행 100% · 번짐 ' + VIEWTL_SHADOW_SPREAD_PX.toFixed(1) + 'px';
}
})();import { useEffect, useRef, useState } from "react";
import styles from "./ShadowLiftDepth.module.scss";
// 핵심: animation-timeline: view() + animation-range: cover 로 요소가 뷰포트를
// 통과하는 전체 구간 값(0~1)에 그림자 번짐 거리·흐림 값과 들어올림 정도를 함께
// 묶는다. 미지원 환경은 스크롤 위치로 같은 통과 진행 값을 계산해 같은 그림자를 그린다.
// VIEWTL_SHADOW_SPREAD_PX — 변형 3: 24 → 48 로 늘리면 그림자가 더 멀리 번져 보인다.
const VIEWTL_SHADOW_SPREAD_PX = 24;
export default function ShadowLiftDepth() {
const rootRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<HTMLDivElement>(null);
const cardRef = useRef<HTMLDivElement>(null);
const [badge, setBadge] = useState("통과 구간 0%~100%");
const [demo, setDemo] = useState(true);
const [supportsVt, setSupportsVt] = useState(false);
useEffect(() => {
const view = viewRef.current;
const card = cardRef.current;
const root = rootRef.current;
if (!view || !card || !root) { return; }
card.style.setProperty("--shadow-spread", VIEWTL_SHADOW_SPREAD_PX + "px");
const vt = CSS.supports("animation-timeline: view()");
setSupportsVt(vt);
function coverProgress() {
const elTop = card!.offsetTop;
const elH = card!.offsetHeight;
const viewH = view!.clientHeight;
const raw = (view!.scrollTop - elTop + viewH) / (elH + viewH);
return Math.min(1, Math.max(0, raw));
}
function render(cov: number) {
const spread = VIEWTL_SHADOW_SPREAD_PX * cov;
if (!vt) {
const y = (6 + spread * 0.3).toFixed(1);
const blur = (10 + spread * 0.6).toFixed(1);
card!.style.boxShadow = "0 " + y + "px " + blur + "px " + spread.toFixed(1) + "px rgba(23, 20, 26, .35)";
card!.style.transform = "translateY(" + (-14 * cov).toFixed(1) + "px)";
}
setBadge("통과 진행 " + Math.round(cov * 100) + "% · 번짐 " + spread.toFixed(1) + "px");
}
function onScroll() { render(coverProgress()); }
view.addEventListener("scroll", onScroll, { passive: true });
render(coverProgress());
let playing = true;
let frame = 0;
const PERIOD = 80;
let rafId = 0;
function tick() {
if (playing) {
frame += 1;
const max = view!.scrollHeight - view!.clientHeight;
const t = frame % (PERIOD * 2);
const f = t < PERIOD ? (t / PERIOD) : ((PERIOD * 2 - t) / PERIOD);
view!.scrollTop = f * max;
render(coverProgress());
}
rafId = requestAnimationFrame(tick);
}
rafId = requestAnimationFrame(tick);
function stopAuto() {
setDemo(false);
playing = false;
}
view.addEventListener("wheel", stopAuto, { passive: true });
view.addEventListener("touchstart", stopAuto, { passive: true });
function onClick() {
playing = !playing;
setDemo(playing);
render(coverProgress());
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === "r" || e.key === "R") {
frame = 0;
view!.scrollTop = 0;
playing = true;
setDemo(true);
render(coverProgress());
}
}
root.addEventListener("keydown", onKeyDown);
function onResize() { render(coverProgress()); }
window.addEventListener("resize", onResize);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
playing = false;
setDemo(false);
render(1);
setBadge("통과 진행 100% · 번짐 " + VIEWTL_SHADOW_SPREAD_PX.toFixed(1) + "px");
}
const ctrl = root.querySelector("button");
ctrl?.addEventListener("click", onClick);
return () => {
cancelAnimationFrame(rafId);
view.removeEventListener("scroll", onScroll);
view.removeEventListener("wheel", stopAuto);
view.removeEventListener("touchstart", stopAuto);
root.removeEventListener("keydown", onKeyDown);
window.removeEventListener("resize", onResize);
ctrl?.removeEventListener("click", onClick);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div ref={rootRef} className={`${styles.sld} ${demo ? styles["is-demo"] : ""} ${supportsVt ? styles["supports-vt"] : styles["no-vt"]}`}>
<div className={styles["sld__badge"]}>{badge}</div>
<button className={styles["sld__ctrl"]} type="button">{demo ? "일시정지" : "재생"}</button>
<div ref={viewRef} className={styles["sld__view"]}>
<div className={styles["sld__track"]}>
<div className={`${styles["sld__spacer"]} ${styles["sld__spacer--top"]}`} />
<div ref={cardRef} className={styles["sld__card"]}>
<span className={styles["sld__label"]}>카드</span>
</div>
<div className={`${styles["sld__spacer"]} ${styles["sld__spacer--bottom"]}`} />
</div>
</div>
</div>
</main>
);
}08 · 구간 값만큼 떠오르는 그림자 카드 — 측정 누적면적 28.618% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.supports-vt .sld__card {
animation-name: sldLift;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: view();
animation-range: cover;
}
@keyframes sldLift {
0% { box-shadow: 0 6px 10px 0px rgba(23, 20, 26, .35); transform: translateY(0px); }
100% {
box-shadow: 0 calc(6px + var(--shadow-spread, 24px) * .3) calc(10px + var(--shadow-spread, 24px) * .6) var(--shadow-spread, 24px) rgba(23, 20, 26, .35);
transform: translateY(-14px);
}
}
측정: 누적면적 28.6%, 23/23 프레임에서 움직였다.
09구간 값이 글자 간격 값으로 벌어지는 제목
제목이 좁게 뭉쳐 있다가 화면에 들어올수록 글자 사이가 넓게 벌어진다. entry 구간 진행률 값을 letter-spacing(-0.04em~0.4em)으로 매핑해, 값이 채워지는 만큼 글자 간격과 불투명도가 함께 커진다.

<main class="stage">
<div class="lss is-demo" id="lssRoot">
<div class="lss__badge" id="lssBadge">진입 구간 0%~0%</div>
<button class="lss__ctrl" id="lssCtrl" type="button">일시정지</button>
<div class="lss__view" id="lssView">
<div class="lss__track" id="lssTrack">
<div class="lss__spacer lss__spacer--top"></div>
<h2 class="lss__title" id="lssTitle">섹션 타이틀</h2>
<div class="lss__spacer lss__spacer--bottom"></div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: animation-timeline: view() + animation-range: entry 로 글자 사이 간격 값을
// 진입 구간 값(0~1)에 직접 묶는다 — 좁게 뭉쳐 있다가 값이 채워질수록 넓게 벌어진다.
// 미지원 환경은 교차 비율로 같은 글자 간격 값을 계산한다.
$duration: $dur-loop;
$easing: linear;
$color: $subject-cream;
@include stage($stage-ink);
.lss {
position: relative;
width: min(100%, 280px);
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.lss__badge {
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .08);
color: $subject-cream;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.lss__ctrl {
position: absolute;
top: 0;
right: 0;
border: 0;
border-radius: $r-xs;
padding: $sp-1 $sp-2;
background: rgba(255, 247, 230, .12);
color: $subject-cream;
font-weight: 600;
font-size: 10px;
line-height: 1.4;
cursor: pointer;
}
.lss__ctrl:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
.lss__view {
box-sizing: border-box;
width: 100%;
height: 176px;
overflow-y: auto;
overflow-x: hidden;
border-radius: $r-card;
background: rgba(255, 255, 255, .05);
scrollbar-width: thin;
}
.lss__track {
display: flex;
flex-direction: column;
align-items: center;
}
.lss__spacer--top { height: 190px; flex: 0 0 auto; }
.lss__spacer--bottom { height: 40px; flex: 0 0 auto; }
.lss__title {
flex: 0 0 auto;
margin: 0;
color: $color;
font-weight: 800;
font-size: 22px;
line-height: 1.3;
letter-spacing: -0.04em;
opacity: .35;
}
.supports-vt .lss__title {
animation-name: lssStretch;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: view();
animation-range: entry;
}
@keyframes lssStretch {
0% { letter-spacing: -0.04em; opacity: .35; }
100% { letter-spacing: 0.4em; opacity: 1; }
}
@media (max-width: 340px) {
.lss { gap: $sp-2; }
.lss__view { height: 130px; }
.lss__spacer--top { height: 130px; }
.lss__spacer--bottom { height: 26px; }
.lss__title { font-size: 18px; }
}
@media (prefers-reduced-motion: reduce) {
.lss__title { animation: none !important; letter-spacing: 0.4em !important; opacity: 1 !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lss {
position: relative;
width: min(100%, 280px);
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.lss__badge {
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.08);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.lss__ctrl {
position: absolute;
top: 0;
right: 0;
border: 0;
border-radius: 4px;
padding: 4px 8px;
background: rgba(255, 247, 230, 0.12);
color: #fff7e6;
font-weight: 600;
font-size: 10px;
line-height: 1.4;
cursor: pointer;
}
.lss__ctrl:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.lss__view {
box-sizing: border-box;
width: 100%;
height: 176px;
overflow-y: auto;
overflow-x: hidden;
border-radius: 16px;
background: rgba(255, 255, 255, 0.05);
scrollbar-width: thin;
}
.lss__track {
display: flex;
flex-direction: column;
align-items: center;
}
.lss__spacer--top {
height: 190px;
flex: 0 0 auto;
}
.lss__spacer--bottom {
height: 40px;
flex: 0 0 auto;
}
.lss__title {
flex: 0 0 auto;
margin: 0;
color: #fff7e6;
font-weight: 800;
font-size: 22px;
line-height: 1.3;
letter-spacing: -0.04em;
opacity: 0.35;
}
.supports-vt .lss__title {
animation-name: lssStretch;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: view();
animation-range: entry;
}
@keyframes lssStretch {
0% {
letter-spacing: -0.04em;
opacity: 0.35;
}
100% {
letter-spacing: 0.4em;
opacity: 1;
}
}
@media (max-width: 340px) {
.lss {
gap: 8px;
}
.lss__view {
height: 130px;
}
.lss__spacer--top {
height: 130px;
}
.lss__spacer--bottom {
height: 26px;
}
.lss__title {
font-size: 18px;
}
}
@media (prefers-reduced-motion: reduce) {
.lss__title {
animation: none !important;
letter-spacing: 0.4em !important;
opacity: 1 !important;
}
}(function () {
var supportsVt = CSS.supports('animation-timeline: view()');
var root = document.getElementById('lssRoot');
var view = document.getElementById('lssView');
var title = document.getElementById('lssTitle');
var badge = document.getElementById('lssBadge');
var ctrl = document.getElementById('lssCtrl');
root.classList.add(supportsVt ? 'supports-vt' : 'no-vt');
var SPACING_START = -0.04;
var SPACING_END = 0.4;
// 계산 한 겹: 요소 높이·뷰포트 높이에서 진입 구간(시작~끝 %) 값을 구해 배지에 표시한다.
function entryRangePct() {
var elH = title.offsetHeight;
var viewH = view.clientHeight;
return Math.round(Math.min(100, (elH / viewH) * 100));
}
var rangeEnd = entryRangePct();
var rangeText = '진입 구간 0%~' + rangeEnd + '%';
function render(ratio) {
if (!supportsVt) {
var spacing = SPACING_START + (SPACING_END - SPACING_START) * ratio;
title.style.letterSpacing = spacing.toFixed(3) + 'em';
title.style.opacity = String(0.35 + 0.65 * ratio);
}
badge.textContent = rangeText + ' · 진행 ' + Math.round(ratio * 100) + '%';
}
var lastRatio = 0;
if (!supportsVt) {
var STEPS = [];
for (var s = 0; s <= 50; s += 1) { STEPS.push(s / 50); }
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
lastRatio = entry.intersectionRatio;
render(lastRatio);
});
}, { root: view, threshold: STEPS });
io.observe(title);
} else {
badge.textContent = rangeText;
}
view.addEventListener('scroll', function () {
if (supportsVt) {
var max = view.scrollHeight - view.clientHeight;
var p = max > 0 ? Math.min(1, Math.max(0, view.scrollTop / max)) : 0;
badge.textContent = rangeText + ' · 스크롤 ' + Math.round(p * 100) + '%';
}
}, { passive: true });
var playing = true;
var frame = 0;
var PERIOD = 66;
function tick() {
if (playing) {
frame += 1;
var max = view.scrollHeight - view.clientHeight;
var t = frame % (PERIOD * 2);
var f = t < PERIOD ? (t / PERIOD) : ((PERIOD * 2 - t) / PERIOD);
view.scrollTop = f * max;
}
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
function stopAuto() {
if (root.classList.contains('is-demo')) { root.classList.remove('is-demo'); }
playing = false;
ctrl.textContent = '재생';
}
view.addEventListener('wheel', stopAuto, { passive: true });
view.addEventListener('touchstart', stopAuto, { passive: true });
ctrl.addEventListener('click', function () {
playing = !playing;
ctrl.textContent = playing ? '일시정지' : '재생';
if (playing) { root.classList.add('is-demo'); }
});
ctrl.addEventListener('keydown', function (e) {
if (e.key === 'r' || e.key === 'R') {
frame = 0;
view.scrollTop = 0;
lastRatio = 0;
render(0);
playing = true;
root.classList.add('is-demo');
ctrl.textContent = '일시정지';
}
});
window.addEventListener('resize', function () {
rangeEnd = entryRangePct();
rangeText = '진입 구간 0%~' + rangeEnd + '%';
});
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
playing = false;
root.classList.remove('is-demo');
lastRatio = 1;
render(1);
title.style.letterSpacing = SPACING_END.toFixed(3) + 'em';
title.style.opacity = '1';
badge.textContent = rangeText + ' · 최종 상태';
}
})();import { useEffect, useRef, useState } from "react";
import styles from "./LetterSpacingStretch.module.scss";
// 핵심: animation-timeline: view() + animation-range: entry 로 글자 사이 간격 값을
// 진입 구간 값(0~1)에 직접 묶는다 — 좁게 뭉쳐 있다가 값이 채워질수록 넓게 벌어지며
// 함께 밝아진다. 미지원 환경은 교차 비율로 같은 글자 간격·투명도 값을 계산한다.
const SPACING_START = -0.04;
const SPACING_END = 0.4;
export default function LetterSpacingStretch() {
const rootRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<HTMLDivElement>(null);
const titleRef = useRef<HTMLHeadingElement>(null);
const [badge, setBadge] = useState("진입 구간 0%~0%");
const [demo, setDemo] = useState(true);
const [supportsVt, setSupportsVt] = useState(false);
useEffect(() => {
const view = viewRef.current;
const title = titleRef.current;
const root = rootRef.current;
if (!view || !title || !root) { return; }
const vt = CSS.supports("animation-timeline: view()");
setSupportsVt(vt);
function entryRangePct() {
const elH = title!.offsetHeight;
const viewH = view!.clientHeight;
return Math.round(Math.min(100, (elH / viewH) * 100));
}
let rangeEnd = entryRangePct();
let rangeText = "진입 구간 0%~" + rangeEnd + "%";
function render(ratio: number) {
if (!vt) {
const spacing = SPACING_START + (SPACING_END - SPACING_START) * ratio;
title!.style.letterSpacing = spacing.toFixed(3) + "em";
title!.style.opacity = String(0.35 + 0.65 * ratio);
}
setBadge(rangeText + " · 진행 " + Math.round(ratio * 100) + "%");
}
let io: IntersectionObserver | null = null;
if (!vt) {
const STEPS: number[] = [];
for (let s = 0; s <= 50; s += 1) { STEPS.push(s / 50); }
io = new IntersectionObserver((entries) => {
entries.forEach((entry) => { render(entry.intersectionRatio); });
}, { root: view, threshold: STEPS });
io.observe(title);
} else {
setBadge(rangeText);
}
function onScroll() {
if (vt) {
const max = view!.scrollHeight - view!.clientHeight;
const p = max > 0 ? Math.min(1, Math.max(0, view!.scrollTop / max)) : 0;
setBadge(rangeText + " · 스크롤 " + Math.round(p * 100) + "%");
}
}
view.addEventListener("scroll", onScroll, { passive: true });
let playing = true;
let frame = 0;
const PERIOD = 66;
let rafId = 0;
function tick() {
if (playing) {
frame += 1;
const max = view!.scrollHeight - view!.clientHeight;
const t = frame % (PERIOD * 2);
const f = t < PERIOD ? (t / PERIOD) : ((PERIOD * 2 - t) / PERIOD);
view!.scrollTop = f * max;
}
rafId = requestAnimationFrame(tick);
}
rafId = requestAnimationFrame(tick);
function stopAuto() {
setDemo(false);
playing = false;
}
view.addEventListener("wheel", stopAuto, { passive: true });
view.addEventListener("touchstart", stopAuto, { passive: true });
function onClick() {
playing = !playing;
setDemo(playing);
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === "r" || e.key === "R") {
frame = 0;
view!.scrollTop = 0;
render(0);
playing = true;
setDemo(true);
}
}
root.addEventListener("keydown", onKeyDown);
function onResize() {
rangeEnd = entryRangePct();
rangeText = "진입 구간 0%~" + rangeEnd + "%";
}
window.addEventListener("resize", onResize);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
playing = false;
setDemo(false);
render(1);
title.style.letterSpacing = SPACING_END.toFixed(3) + "em";
title.style.opacity = "1";
setBadge(rangeText + " · 최종 상태");
}
const ctrl = root.querySelector("button");
ctrl?.addEventListener("click", onClick);
return () => {
cancelAnimationFrame(rafId);
io?.disconnect();
view.removeEventListener("scroll", onScroll);
view.removeEventListener("wheel", stopAuto);
view.removeEventListener("touchstart", stopAuto);
root.removeEventListener("keydown", onKeyDown);
window.removeEventListener("resize", onResize);
ctrl?.removeEventListener("click", onClick);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div ref={rootRef} className={`${styles.lss} ${demo ? styles["is-demo"] : ""} ${supportsVt ? styles["supports-vt"] : styles["no-vt"]}`}>
<div className={styles["lss__badge"]}>{badge}</div>
<button className={styles["lss__ctrl"]} type="button">{demo ? "일시정지" : "재생"}</button>
<div ref={viewRef} className={styles["lss__view"]}>
<div className={styles["lss__track"]}>
<div className={`${styles["lss__spacer"]} ${styles["lss__spacer--top"]}`} />
<h2 ref={titleRef} className={styles["lss__title"]}>섹션 타이틀</h2>
<div className={`${styles["lss__spacer"]} ${styles["lss__spacer--bottom"]}`} />
</div>
</div>
</div>
</main>
);
}09 · 구간 값이 글자 간격 값으로 벌어지는 제목 — 측정 누적면적 4.267% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.supports-vt .lss__title {
animation-name: lssStretch;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: view();
animation-range: entry;
}
@keyframes lssStretch {
0% { letter-spacing: -0.04em; opacity: .35; }
100% { letter-spacing: 0.4em; opacity: 1; }
}
측정: 누적면적 4.3%, 23/23 프레임에서 움직였다.
구간 값 아홉 개가 실제로 얼마나 움직였는가 — 23구간 델타 실측
24프레임을 캡처해 화면이 바뀐 넓이를 잰 값이다. 움직인 프레임은 인접한 두 캡처 사이 23개 구간(델타) 중 픽셀이 달라진 구간 수다.
| 항목 | 누적면적 | 움직인 프레임 |
|---|---|---|
| 01 구간 값에 맞춰 커지며 또렷해지는 카드 | 16.1% | 23/23 |
| 02 경계 값이 옮겨가며 드러나는 썸네일 | 17.1% | 23/23 |
| 03 대각선 경계 값이 훑고 지나가는 배경 이미지 | 21.8% | 23/23 |
| 04 구간 값 따라 초점이 풀렸다 맞는 카드 | 28.2% | 23/23 |
| 05 구간 값이 각도 값으로 바뀌는 3D 카드 | 17.4% | 23/23 |
| 06 기울기 값이 끝에서 풀리는 패널 | 17.5% | 23/23 |
| 07 구간 값이 색상 보간 지점이 되는 배지 | 10.1% | 23/23 |
| 08 구간 값만큼 떠오르는 그림자 카드 | 28.6% | 23/23 |
| 09 구간 값이 글자 간격 값으로 벌어지는 제목 | 4.3% | 23/23 |
값 하나만 바꾸면 장면이 어떻게 달라지는가 — 변형 세 가지
칩 세 개의 상수 값만 바꿔도 같은 계산식이 전혀 다른 인상을 만든다.
| 이름 | 바꾼 값 | 느낌 |
|---|---|---|
| 카드 등장 시작 크기 값 조정 | 01 VIEWTL_FADE_SCALE_START 0.8 → 0.6 | 시작 크기를 더 줄이면 카드가 자라는 폭이 넓어져 등장이 힘 있게 보이고, 반대로 1 에 가깝게 올리면 자라는 폭이 거의 없어 있는 듯 없는 듯 지나간다. |
| 초점 전환 흐림 최대 값 조정 | 04 VIEWTL_BLUR_MAX_PX 12 → 24 | 최대 흐림을 낮추면 초점이 맞아가는 과정이 눈에 잘 안 띄고, 24px 까지 올리면 카드가 화면에 걸칠 때마다 초점이 크게 흔들리는 게 또렷이 보인다. |
| 그림자 번짐 거리 값 조정 | 08 VIEWTL_SHADOW_SPREAD_PX 24 → 48 | 번짐 거리를 줄이면 그림자가 카드 밑에 바짝 붙어 낮게 얹힌 느낌이고, 48px 로 늘리면 그림자가 멀리 퍼져 카드가 화면 위로 훌쩍 떠오른 것처럼 보인다. |
css 스크롤 내리면 나타나는 애니메이션을 실제 화면 어디에 붙이나
아홉 개 원본을 전부 담은 zip 은 yagmcszp 을 넣어야 열리는데, 그 안 파일이 위 표의 변형 값까지 그대로 재현해 둔 것이다. 랜딩 페이지 기능 소개 줄에는 01 처럼 카드가 커지며 또렷해지는 구간 값을 붙이면 스크롤할 때마다 다음 기능이 순서대로 강조된다. 포트폴리오 목록에는 02 의 경계 값 공개를, 히어로 섹션 배경에는 03 의 대각선 와이프를 붙이면 첫 화면부터 구간 값이 시선을 끈다.
제품 소개 섹션에는 04 의 초점 전환이, 서비스 카드 묶음에는 05 의 3D 뒤집기가 어울린다. 가격표 패널에는 06 의 기울기 정렬을, 신규 배지에는 07 의 색상 전환을, 카드 목록에는 08 의 그림자 뜨는 효과를 붙이면 화면이 밋밋하지 않다. 섹션 타이틀에는 09 의 글자 간격 확장이 구간 값 하나로도 제목이 살아 있게 만든다.
구간 계산을 스킵하면 깨지는 지점
01·02·06·07·09 처럼 entry 구간을 쓰는 항목은 요소 높이와 뷰 높이로 구간 끝 지점을 스스로 잰다. 뷰보다 요소가 크면 이 값이 100% 를 넘지 않게 잘라 두지 않으면 구간이 절대 안 끝나 애니메이션이 멈춘 것처럼 보이는 함정이 생긴다. 04·05·08 처럼 cover 구간을 쓰는 항목은 스크롤 컨테이너의 스크롤 위치에서 직접 진행률을 계산하는데, 컨테이너가 아예 스크롤되지 않는 화면에 그대로 옮기면 진행률이 0 에 고정돼 깨진다. 03 의 --wipe 처럼 커스텀 프로퍼티로 값을 보간하려면 @property 로 타입(<percentage>)을 등록해 둬야 한다 — 등록이 빠지면 브라우저가 문자열로 취급해 중간값 없이 시작과 끝만 툭 튀어 버린다.
동작 줄임 설정에서 아홉 개가 함께 멈추는 접근성 처리
아홉 항목 모두 prefers-reduced-motion: reduce 아래에서 animation: none !important 로 구간 애니메이션을 끄고, 가장 읽기 편한 상태 하나로 값을 고정한다. 01 은 다 자라서 또렷한 상태, 02·03 은 가려진 부분 없이 전부 드러난 상태, 04 는 가장 흐릿한 값이 아니라 완전히 선명한 값으로, 06 은 기울기 0, 07 은 최종 색, 08 은 그림자가 다 번진 뜬 상태, 09 는 글자 간격이 가장 넓게 벌어진 상태로 멈춘다. 05 만 예외로 뒷면이 아니라 앞면(rotateY 0deg)에 고정하는데, 뒤집힌 채 멈추면 라벨이 안 보이기 때문이다.
css 스크롤 애니메이션 효과는 어디서 더 볼 수 있나?
구간 값 대신 요소가 화면에 걸리는 순간을 감지해서 클래스를 붙이는 쪽을 보고 싶다면 CSS 스크롤 애니메이션 9종 — AOS·GSAP 없이 JS 0줄로 가 있다. 값을 직접 계산하는 엔진이 궁금하면 스크롤 트리거 애니메이션 9종 — GSAP 없이 바닐라 JS 엔진으로 를, 멈춘 뒤에도 값이 스스로 줄어드는 관성 계산이 궁금하면 관성 스크롤 9종 — 멈춘 뒤에도 값이 스스로 줄어드는 아홉 가지 방식 을 본다. 스크롤 애니메이션 css 를 이렇게 세 각도(구간 값·트리거 감지·관성 계산)로 나눠 보면 어떤 화면에 어떤 계산이 맞는지 고르기 쉽다.
구간 값 스크롤 애니메이션을 적용하기 전에 자주 묻는 질문
css 스크롤 애니메이션 효과는 라이브러리 없이도 되나요?
그렇다. 아홉 개 전부 animation-timeline: view() 와 animation-range 만으로 구간 값을 계산하고, 미지원 환경을 위한 짧은 폴백 스크립트 외에 외부 라이브러리는 쓰지 않는다.
스크롤 애니메이션 css 구간 값은 entry 와 cover 중 뭘 골라야 하나요?
요소가 화면에 들어오는 순간의 변화만 보여주고 싶으면 entry(01·02·03·06·07·09), 요소가 화면에 걸쳐 있는 내내 값이 바뀌길 바라면 cover(04·05·08)를 고른다. 같은 계산식이라도 구간을 바꾸면 언제 값이 0 에서 1 로 채워지는지가 통째로 달라진다.
구형 브라우저에서는 어떻게 보이나요?
CSS.supports('animation-timeline: view()') 로 지원 여부를 먼저 확인해, 지원하지 않으면 entry 항목은 교차 비율을, cover 항목은 스크롤 위치를 직접 계산해 같은 속성에 대입한다. 움직임이 사라지는 게 아니라 같은 값을 다른 방식으로 구하는 것뿐이다.









