Artoke
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 몇 줄로 재현한 것입니다.

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>
  );
}