05
Scroll-driven Animation
스크롤 기반 애니메이션
스크롤 ↓ — 진행률이 곧 타임라인
Scene file
Rig check
Bake keys
Retarget
Polish pass
Export
데모 안을 스크롤해보세요
↑ 데모 안을 스크롤하면 카드가 위치에 비례해 나타납니다
시간이 아니라 스크롤이 타임라인입니다. 카드가 데모 하단에서 올라올수록 투명도·이동·배율이 진행률에 1:1로 묶여(스크럽) 움직여, 스크롤을 멈추면 애니메이션도 그 자리에 멈춥니다. CSS의 animation-timeline: view() (Scroll-driven Animations API)가 표준화한 바로 그 동작을 JS 몇 줄로 재현한 것입니다.
- 카드마다 '컨테이너 하단에서 얼마나 올라왔나'를 0~1로 정규화해 진행률로 씁니다.
- 진행률을 opacity·translateY·scale에 그대로 매핑 — 시간 기반 트랜지션이 없어서 스크롤과 완전히 동기화됩니다.
- 최신 브라우저에선 JS 없이 CSS animation-timeline: view()만으로 같은 효과를 낼 수 있습니다(아래 CSS 스니펫).
section.tsx — 사용법
// 1) 아래 scroll-driven-demo.tsx 파일을 프로젝트에 넣습니다.
// 2) 컨테이너 안을 스크롤하면 카드가 진행률에 1:1로 나타납니다(스크럽).
import { ScrollDrivenDemo } from "@/components/scroll-driven-demo";
export function Section() {
return (
<section className="relative h-[80vh] overflow-hidden rounded-xl bg-[#08090d]">
<ScrollDrivenDemo />
</section>
);
}reveal.css — 순수 CSS 버전 (animation-timeline)
/* 최신 브라우저: JS 없이 같은 효과 (Scroll-driven Animations API) */
@keyframes reveal {
from { opacity: 0; transform: translateY(90px) scale(0.9); }
to { opacity: 1; transform: none; }
}
.card {
animation: reveal linear both;
animation-timeline: view(); /* 스크롤이 타임라인 */
animation-range: entry 0% cover 50%; /* 하단 진입~절반 지점까지 */
}scroll-driven-demo.tsx — 전체 컴포넌트 (복사해서 그대로 사용)
"use client";
import { type RefObject, useEffect, useRef } from "react";
/**
* 스크롤 기반(스크럽) 애니메이션 — each card's opacity/translate/scale is a
* pure function of how far it has risen from the container's bottom edge, so
* the animation is locked 1:1 to the scroll position (stop scrolling and it
* freezes mid-way). This is the behaviour CSS standardised as
* `animation-timeline: view()`, reproduced in a few lines of JS.
*
* The measured wrapper and the transformed inner element are separate — since
* getBoundingClientRect() includes transforms, styling the element you measure
* would feed back into the next frame's measurement.
*/
export interface ScrollDrivenConfig {
/** px the card travels while revealing. */
distance: number;
/** Scale at the start of the reveal. */
fromScale: number;
/** Fraction of the container height over which the reveal completes. */
range: number;
}
export const SCROLL_DRIVEN_DEFAULTS: ScrollDrivenConfig = {
distance: 90,
fromScale: 0.9,
range: 0.5,
};
const CARDS = [
{ color: "#fbbf24", label: "Scene file" },
{ color: "#38bdf8", label: "Rig check" },
{ color: "#34d399", label: "Bake keys" },
{ color: "#f472b6", label: "Retarget" },
{ color: "#a78bfa", label: "Polish pass" },
{ color: "#f87171", label: "Export" },
];
const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
export function ScrollDrivenDemo({
configRef,
auto = false,
}: {
configRef?: RefObject<Partial<ScrollDrivenConfig>>;
auto?: boolean;
}) {
const scrollerRef = useRef<HTMLDivElement>(null);
const barRef = useRef<HTMLDivElement>(null);
const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
useEffect(() => {
const scroller = scrollerRef.current;
if (!scroller) return;
const reduced = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
let raf = 0;
const t0 = performance.now();
const frame = (now: number) => {
const cfg = { ...SCROLL_DRIVEN_DEFAULTS, ...configRef?.current };
const max = scroller.scrollHeight - scroller.clientHeight;
if (auto && !reduced && max > 0) {
const p = ((now - t0) / 8000) % 2;
scroller.scrollTop = (p < 1 ? p : 2 - p) * max;
}
if (barRef.current) {
const progress = max > 0 ? scroller.scrollTop / max : 0;
barRef.current.style.width = `${progress * 100}%`;
}
const box = scroller.getBoundingClientRect();
for (const wrap of cardRefs.current) {
const inner = wrap?.firstElementChild as HTMLElement | null;
if (!wrap || !inner) continue;
// 0 when the card's top sits at the container bottom; 1 once it has
// risen `range` × container height. Scrubbed — no time involved.
const t = clamp01(
(box.bottom - wrap.getBoundingClientRect().top) /
(box.height * cfg.range),
);
inner.style.opacity = String(t);
inner.style.transform = `translateY(${(1 - t) * cfg.distance}px) scale(${
cfg.fromScale + (1 - cfg.fromScale) * t
})`;
}
raf = requestAnimationFrame(frame);
};
raf = requestAnimationFrame(frame);
return () => cancelAnimationFrame(raf);
}, [configRef, auto]);
return (
<div className="absolute inset-0">
{/* Scroll progress bar — the whole timeline at a glance. */}
<div className="absolute inset-x-0 top-0 z-10 h-0.5 bg-white/10">
<div
ref={barRef}
className="h-full w-0"
style={{ background: "#fbbf24" }}
/>
</div>
<div
ref={scrollerRef}
className="h-full overflow-y-auto px-6"
style={{ scrollbarWidth: "thin" }}
>
<div className="flex h-[55%] items-end pb-4">
<p className="font-mono text-[11px] text-white/50">
스크롤 ↓ — 진행률이 곧 타임라인
</p>
</div>
<div className="space-y-5 pb-[45%]">
{CARDS.map((card, i) => (
<div
key={card.label}
ref={(el) => {
cardRefs.current[i] = el;
}}
>
<div className="rounded-xl border border-white/10 bg-white/[0.04] p-4 will-change-transform">
<div className="flex items-center gap-2.5">
<span
className="size-2.5 rounded-full"
style={{ background: card.color }}
/>
<p className="text-sm font-semibold text-white">
{card.label}
</p>
</div>
<div className="mt-3 h-1.5 w-3/4 rounded bg-white/10" />
<div className="mt-1.5 h-1.5 w-1/2 rounded bg-white/10" />
</div>
</div>
))}
</div>
</div>
</div>
);
}