01구간 값에 맞춰 커지며 또렷해지는 카드
카드가 화면에 들어오는 구간 값이 0에서 1로 채워지는 동안 투명도 값과 크기 값이 함께 늘어나며 점점 또렷하고 크게 나타난다.
쓰임랜딩 페이지 기능 카드 등장
animation-range: entryanimation-timeline: view()--fade-scale-start

<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 포함 · css 스크롤 애니메이션 효과 9가지 — 뷰포트 구간 값으로 전환
이 데모가 속한 글
css 스크롤 애니메이션 효과 9가지 — 뷰포트 구간 값으로 전환css 스크롤 애니메이션 효과는 값 하나가 아홉 가지 다른 움직임을 만든다. 화면을 내리면 카드 한 장이 흐릿하게 떠 있다가 점점 또렷해진다. 그 흐림 정도는 스크롤 위치가 아니라 그 요소가 화면을 통과하는 구간 값 이 글의 데모 전부 보기


