designpaca/research/motion/03-js-stacks.md
Yun Chan 8808c672dc designpaca 초기 구현 — 스킬 · 설치 CLI · 배포 파이프라인
웹 디자인 파이프라인 스킬과 이를 5개 에이전트에 설치하는 CLI 를 담은 모노레포.

스킬 (packages/skill)
- SKILL.md 261줄 + 참조 문서 16개 3,349줄. progressive disclosure 로
  본문은 절차와 인덱스만, 지식은 references/ 로 분리
- 0~6단계 파이프라인. 규모에 따라 전체·연장·국소 세 경로로 분기
- 하드 게이트 12개는 grep·카운트로 검증 가능한 것만. 취향 판단은 제외
- 미학 프리셋 5종, AI 슬롭 지문 목록, 한글 조판 규칙,
  SVG 필터·three.js·인터랙티브 모션·HTML-in-Canvas 실전 지침

설치 CLI (packages/cli, packages/core)
- npx designpaca 온보딩 TUI. Claude Code · Codex · Cursor · Windsurf · AGENTS.md
- 매니페스트에 설치 시점 해시를 기록해 사용자가 고친 파일은 update 가 건너뛴다
- 타깃별로 본문의 references/ 경로를 실제 설치 위치로 재작성
- AGENTS.md 는 항상 로드되므로 본문 대신 303자 포인터만 주입
- Windsurf 는 12,000자 상한 초과 시 설치를 차단

배포 (build/ci, .forgejo/workflows)
- 태그 v* → 검사·테스트·빌드 → npmjs 배포 + Forgejo 레지스트리 미러
  → draft 릴리스 → Cloudflare Pages. 재실행 멱등

근거 (research/)
- 약 250개 웹 소스 조사 결과와 도그푸딩 검증 2건. 스킬의 모든 수치는 여기서 나온다

테스트 22개 통과 (core 16 · cli 6)
2026-08-20 10:48:00 +09:00

60 KiB
Raw Permalink Blame History

03. JS 모션 스택 실전 — GSAP / Motion / Lenis

버전 확인 시점: 2026-08-20, npm 레지스트리 직접 조회. gsap@3.15.0 · motion@13.1.0 (framer-motion@13.1.0은 동일 코드의 별칭 패키지) · lenis@1.3.26


0. 선택 기준 — 결정 트리

[1] CSS(02번 문서)로 되는가?
    YES → 라이브러리 쓰지 않는다. 끝.
    NO ↓

[2] React 프로젝트인가?
    YES ↓                                  NO ↓
    [2a] 레이아웃 변화(FLIP)·                [2b] 스크롤 시퀀스/핀 고정/
         공유 요소·제스처가 핵심인가?              텍스트 분해/SVG 모핑이 필요한가?
         YES → Motion (motion/react)              YES → GSAP
         NO  → CSS + 최소 JS 또는 Motion mini      NO  → WAAPI (Element.animate)

[3] 스크롤 핀 고정 + 다단계 시퀀스 + 스냅이 필요한가?
    YES → GSAP ScrollTrigger. (Motion에는 pin/scrub 조합 등가물이 없다)

[4] 번들 크기가 최우선 제약인가? (< 10KB)
    YES → WAAPI 또는 Motion의 `animate` mini (2.3KB)

0.1 비교표

항목 CSS 네이티브 WAAPI GSAP 3.15 Motion 13.1
번들 0 0 ~24KB(core, min+gz) + 플러그인 2.3KB(mini) ~ 34KB(full React)
라이선스 무료(상업 포함), Webflow 경쟁 제한 조항 있음 MIT
실행 스레드 컴포지터(대부분) 컴포지터(합성 속성) 메인 메인 + WAAPI 하이브리드
타임라인 시퀀싱 제한적 최고 있음(sequence)
스크롤 핀/스크럽/스냅 ✗(핀은 sticky로 유사) ScrollTrigger scroll()만(핀 없음)
레이아웃 애니메이션(FLIP) Flip 플러그인 layout prop (최고)
스프링(속도 승계) ✗(linear() 근사) 있음(elastic 이징, InertiaPlugin) 최고
텍스트 분해 SplitText ✗(직접 구현)
SVG 모핑/패스 MorphSVG/MotionPath/DrawSVG 부분(path 애니메이션)
React 통합 수동 @gsap/react 네이티브
서버 컴포넌트 클라이언트 전용 motion/react-client 지원
디버깅 도구 DevTools DevTools markers, GSDevTools React DevTools

1. GSAP 3.15.0

1.1 라이선스 — 2025년에 실제로 바뀌었다 (1차 출처 확인)

gsap.com/community/standard-license/ 원문 확인 결과 (발효일 2025년 4월 30일):

  • 모든 GSAP은 상업 프로젝트에서 무료다. "Commercial usage is covered under the standard license."
  • 과거 Club GreenSock 유료 플러그인 전부 포함: SplitText, MorphSVG, DrawSVG, ScrollTrigger, ScrollSmoother, Inertia, MotionPath, Flip, Observer 등. 원문: "All of GSAP including the plugins that were formerly 'members-only' like SplitText and MorphSVG can be used in commercial projects at no charge."
  • 배경: 2024년 10월 Webflow가 GreenSock 인수 → 2025년 4월 전면 무료화.

단 하나 남은 제약 (반드시 읽을 것)

GSAP을 "코드 없이 시각적으로 애니메이션을 만드는 도구"에 넣어 Webflow의 비주얼 애니메이션 빌더와 경쟁하는 솔루션을 만드는 데 사용할 수 없다. 또한 "경쟁 제품을 만들 목적으로 리버스 엔지니어링" 금지(§III.2).

FAQ가 명시적으로 허용한 것: WordPress 플러그인, 시각적 인터페이스를 가진 니치 도구, AI가 생성한 코드(ChatGPT/Cursor 등). 일반적인 웹사이트·앱 제작에는 아무 제약이 없다.

designpaca 판단: GSAP은 이제 "돈 때문에 못 쓰는 라이브러리"가 아니다. 스크롤 시퀀스와 텍스트 분해가 필요하면 주저 없이 쓴다.

1.2 설치와 등록

npm i gsap@3.15.0
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { SplitText }     from 'gsap/SplitText';
import { Flip }          from 'gsap/Flip';
import { MotionPathPlugin } from 'gsap/MotionPathPlugin';
import { Observer }      from 'gsap/Observer';

gsap.registerPlugin(ScrollTrigger, SplitText, Flip, MotionPathPlugin, Observer);

// 프로젝트 전역 기본값 — 이걸 안 하면 매번 duration/ease를 반복하게 된다
gsap.defaults({ duration: 0.6, ease: 'power3.out' });

// ScrollTrigger 전역 설정
ScrollTrigger.config({
  // 리사이즈/방향 전환 시 불필요한 refresh 방지 (모바일 주소창 대응)
  ignoreMobileResize: true,
});

1.3 이징 이름 — GSAP의 문자열 이징

GSAP 문자열 대략 대응
none linear
power1.out ~ power4.out ease-out (숫자가 클수록 급격)
power2.inOut ease-in-out
expo.out 가장 강한 감속. "고급스러운" 느낌
back.out(1.7) 오버슛. 괄호 값이 오버슛 강도
elastic.out(1, 0.3) 스프링 유사. (진폭, 주기)
circ.out 원호 감속
steps(12) 스텝 애니메이션

실무 기본값: power3.out(진입) / power3.in(퇴장) / power2.inOut(이동) / none(스크럽).

1.4 타임라인 설계 — 위치 파라미터가 핵심

const tl = gsap.timeline({
  defaults: { duration: 0.6, ease: 'power3.out' },
  paused: true,
});

tl.from('.hero__panel', { yPercent: 100, duration: 0.8, ease: 'expo.out' })
  // "<"      = 직전 애니메이션의 시작 시점
  // "<0.15"  = 직전 시작 + 0.15s   ← 오버랩. 이게 "고급스러움"의 정체다
  // ">"      = 직전 애니메이션의 종료 시점
  // ">-0.2"  = 직전 종료 0.2s 전
  // "+=0.3"  = 타임라인 끝 + 0.3s
  // 1.2      = 절대 시각 1.2초
  // "label"  = 라벨 위치
  .from('.hero__title', { yPercent: 110, opacity: 0 }, '<0.15')
  .from('.hero__sub',   { y: 24, opacity: 0 },          '<0.1')
  .from('.hero__cta',   { y: 16, opacity: 0, scale: 0.96 }, '<0.1')
  .addLabel('heroReady')
  .from('.hero__scroll-hint', { opacity: 0, y: -8, repeat: -1, yoyo: true, duration: 1.2 }, 'heroReady');

tl.play();

타임라인 제어

tl.play(); tl.pause(); tl.reverse(); tl.restart();
tl.seek('heroReady');
tl.timeScale(1.5);           // 1.5배속
tl.progress(0.5);            // 절반 지점으로
tl.tweenTo('heroReady', { duration: 0.4 });  // 부드럽게 라벨로 이동
tl.kill();                   // 완전 제거

1.5 ScrollTrigger — 전체 설정 레퍼런스

ScrollTrigger.create({
  trigger: '.section',        // 기준 요소
  endTrigger: '.other',       // (선택) end 계산 기준을 다른 요소로
  start: 'top 80%',           // "트리거의 top이 뷰포트의 80% 지점에 닿을 때"
  end: 'bottom 20%',          // 또는 "+=1000"(트리거 시작에서 1000px), 숫자, 함수
  scrub: 1,                   // true=즉시 연동, 숫자=n초 지연 스무딩
  pin: true,                  // 트리거를 화면에 고정 (또는 다른 요소 지정)
  pinSpacing: true,           // 고정 중 레이아웃 붕괴 방지용 여백 (기본 true)
  anticipatePin: 1,           // 빠른 스크롤 시 핀 시작을 미리 계산 (깜빡임 방지)
  snap: {
    snapTo: 'labels',         // 숫자(간격) | 배열 | 함수 | "labels" | "labelsDirectional"
    duration: { min: 0.2, max: 0.6 },
    delay: 0.1,               // 스크롤 멈춘 뒤 대기
    ease: 'power2.inOut',
    directional: true,        // 스크롤 방향 쪽으로만 스냅 (기본 true)
    inertia: true,
  },
  toggleActions: 'play none none reverse',  // onEnter onLeave onEnterBack onLeaveBack
  toggleClass: { targets: '.nav-item', className: 'is-active' },
  once: false,                // true면 1회 후 ScrollTrigger 파괴
  markers: false,             // 개발 중에만 true
  invalidateOnRefresh: true,  // refresh 시 캐시된 시작값 무효화 (반응형에 필수)
  id: 'hero-pin',
  refreshPriority: 0,         // 여러 핀이 겹칠 때 refresh 순서 제어 (위쪽일수록 높게)
  horizontal: false,
  containerAnimation: null,   // 수평 스크롤 안의 트리거일 때 그 트윈을 넘긴다
  onEnter:      (self) => {},
  onLeave:      (self) => {},
  onEnterBack:  (self) => {},
  onLeaveBack:  (self) => {},
  onUpdate:     (self) => { /* self.progress, self.direction, self.velocity */ },
  onRefresh:    (self) => {},
  onToggle:     (self) => { /* self.isActive */ },
});

start / end 문자열 문법: "<트리거의 위치> <뷰포트의 위치>"

  • "top bottom" = 트리거의 top이 뷰포트 bottom에 닿을 때 (요소가 막 보이기 시작)
  • "top center" = 트리거의 top이 뷰포트 중앙에 닿을 때
  • "center center", "bottom top", "top top+=100" 등 조합 가능
  • "+=1000" = 시작 지점에서 1000px 더 스크롤한 지점

1.6 완성 코드 — 핀 고정 + 스크럽 시퀀스

<section class="pin-section">
  <div class="pin-section__stage">
    <h2 class="pin-line" data-i="0">첫 번째</h2>
    <h2 class="pin-line" data-i="1">두 번째</h2>
    <h2 class="pin-line" data-i="2">세 번째</h2>
  </div>
</section>
.pin-section { min-height: 100svh; }
.pin-section__stage {
  height: 100svh;
  display: grid;
  place-items: center;
  position: relative;
}
.pin-line {
  grid-area: 1 / 1;
  font-size: clamp(2rem, 8vw, 6rem);
  margin: 0;
  opacity: 0;
}
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
gsap.registerPlugin(ScrollTrigger);

function initPinSequence() {
  const mm = gsap.matchMedia();

  mm.add(
    {
      isDesktop: '(min-width: 900px)',
      isMobile: '(max-width: 899px)',
      reduce: '(prefers-reduced-motion: reduce)',
    },
    (ctx) => {
      const { isDesktop, reduce } = ctx.conditions;

      if (reduce) {
        // 감소 모드: 핀도 스크럽도 없이 전부 표시
        gsap.set('.pin-line', { opacity: 1, y: 0 });
        return;
      }

      const lines = gsap.utils.toArray('.pin-line');

      const tl = gsap.timeline({
        scrollTrigger: {
          trigger: '.pin-section',
          start: 'top top',
          end: () => `+=${window.innerHeight * lines.length}`,
          scrub: 0.6,
          pin: '.pin-section__stage',
          pinSpacing: true,
          anticipatePin: 1,
          invalidateOnRefresh: true,
        },
        defaults: { ease: 'none' },
      });

      lines.forEach((line, i) => {
        tl.fromTo(
          line,
          { opacity: 0, yPercent: isDesktop ? 40 : 20 },
          { opacity: 1, yPercent: 0, duration: 0.4 },
          i
        );
        if (i < lines.length - 1) {
          tl.to(line, { opacity: 0, yPercent: isDesktop ? -40 : -20, duration: 0.4 }, i + 0.5);
        }
      });

      // 조건이 바뀌면 자동으로 revert됨. 추가 정리가 필요하면 함수를 반환
      return () => {
        tl.scrollTrigger?.kill();
        tl.kill();
      };
    }
  );

  return () => mm.revert();
}

const cleanupPin = initPinSequence();

1.7 완성 코드 — 수평 스크롤 섹션

function initHorizontal() {
  const track = document.querySelector('.h-track');
  const panels = gsap.utils.toArray('.h-panel');
  if (!track || panels.length === 0) return () => {};

  const ctx = gsap.context(() => {
    const scrollTween = gsap.to(panels, {
      xPercent: -100 * (panels.length - 1),
      ease: 'none',
      scrollTrigger: {
        trigger: '.h-wrapper',
        pin: true,
        scrub: 1,
        snap: {
          snapTo: 1 / (panels.length - 1),
          duration: { min: 0.2, max: 0.5 },
          delay: 0.05,
          ease: 'power2.inOut',
        },
        end: () => `+=${track.scrollWidth - window.innerWidth}`,
        invalidateOnRefresh: true,
      },
    });

    // 수평 스크롤 "안"의 요소에 트리거를 걸려면 containerAnimation이 필수
    panels.forEach((panel) => {
      gsap.from(panel.querySelector('.h-panel__title'), {
        opacity: 0,
        y: 40,
        duration: 0.6,
        scrollTrigger: {
          trigger: panel,
          containerAnimation: scrollTween,   // ← 이게 핵심
          start: 'left 70%',
          end: 'left 30%',
          scrub: true,
        },
      });
    });
  });

  return () => ctx.revert();
}

1.8 ScrollTrigger.batch() — 리스트 stagger 리빌

IntersectionObserver를 직접 쓰는 것보다 낫다. 동시에 들어온 요소들을 묶어서 콜백을 준다.

gsap.set('.grid-card', { opacity: 0, y: 32 });

ScrollTrigger.batch('.grid-card', {
  interval: 0.1,     // 이 시간 안에 들어온 요소를 한 배치로 묶는다
  batchMax: 6,       // 한 배치 최대 개수 — stagger가 길어지는 것 방지
  start: 'top 88%',
  once: true,        // 다시 스크롤해도 재생하지 않는다 (읽기 방해 방지)
  onEnter: (batch) =>
    gsap.to(batch, {
      opacity: 1,
      y: 0,
      duration: 0.6,
      ease: 'power3.out',
      stagger: { each: 0.06, grid: 'auto', from: 'start' },
      overwrite: true,
    }),
});

// 초기 y 오프셋이 ScrollTrigger의 위치 계산을 망치는 것을 방지
ScrollTrigger.addEventListener('refreshInit', () => gsap.set('.grid-card', { y: 0 }));

1.9 SplitText (3.13+ 신 API) — 텍스트 등장

import { SplitText } from 'gsap/SplitText';
gsap.registerPlugin(SplitText);

const split = SplitText.create('.headline', {
  type: 'lines,words',
  mask: 'lines',        // 3.13+ : 각 line을 overflow:hidden 래퍼로 감싼다
  autoSplit: true,      // 폰트 로드/리사이즈 시 자동 재분할
  linesClass: 'split-line',
  onSplit(self) {
    // autoSplit이 재분할할 때마다 호출된다.
    // 여기서 반환한 애니메이션은 다음 재분할 때 자동으로 kill된다.
    return gsap.from(self.lines, {
      yPercent: 110,
      opacity: 0,
      duration: 0.8,
      ease: 'expo.out',
      stagger: 0.08,
      scrollTrigger: {
        trigger: '.headline',
        start: 'top 82%',
        once: true,
      },
    });
  },
});

// 정리
// split.revert();

SplitText 접근성 — 반드시 지킬 것

문자 단위로 쪼개면 스크린리더가 "ㄱ-ㅏ-ㄴ-ㅏ-ㄷ-ㅏ"처럼 한 글자씩 읽는다. 2026년 기준 접근성 커뮤니티의 결론은 명확하다: 글자 단위 분해(type: "chars")는 쓰지 않는다.

<!-- 최선: 줄/단어 단위까지만 쪼갠다 -->
<h1 class="headline">여기 텍스트</h1>

<!-- 문자 단위가 꼭 필요하면: 원문을 별도 요소로 남기고 시각 요소는 숨긴다 -->
<h1 class="headline-wrap">
  <span class="sr-only">여기 텍스트</span>
  <span class="headline" aria-hidden="true">여기 텍스트</span>
</h1>
.sr-only {
  position: absolute;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

<div>aria-label을 붙이는 방식은 동작하지 않는다. generic role은 author naming이 금지되어 있다. <h1>, <p> 같은 의미론적 요소이거나 role="text"(Safari 전용)가 필요하다. 위의 "원문 + aria-hidden 시각 사본" 패턴이 가장 안전하다.

1.10 Flip — 레이아웃 변화 애니메이션

import { Flip } from 'gsap/Flip';
gsap.registerPlugin(Flip);

const grid = document.querySelector('.gallery');

function setLayout(mode) {
  // 1) 현재 상태 캡처
  const state = Flip.getState('.gallery__item', {
    props: 'borderRadius,backgroundColor',   // transform 외에 추적할 속성
  });

  // 2) DOM/클래스를 자유롭게 바꾼다 (레이아웃이 즉시 점프해도 됨)
  grid.dataset.layout = mode;

  // 3) 이전 상태에서 현재 상태로 애니메이션
  Flip.from(state, {
    duration: 0.6,
    ease: 'power3.inOut',
    stagger: 0.02,
    absolute: true,        // 애니메이션 중 position:absolute로 띄워 레이아웃 흔들림 방지
    onEnter: (els) => gsap.fromTo(els, { opacity: 0, scale: 0.9 }, { opacity: 1, scale: 1, duration: 0.4 }),
    onLeave: (els) => gsap.to(els, { opacity: 0, scale: 0.9, duration: 0.3 }),
  });
}

document.querySelectorAll('[data-layout-btn]').forEach((btn) => {
  btn.addEventListener('click', () => setLayout(btn.dataset.layoutBtn));
});

1.11 MotionPath — 패스 위 이동

import { MotionPathPlugin } from 'gsap/MotionPathPlugin';
gsap.registerPlugin(MotionPathPlugin);

gsap.to('.plane', {
  motionPath: {
    path: '#flight-path',        // SVG path의 셀렉터 또는 좌표 배열
    align: '#flight-path',       // 패스와 같은 좌표계로 정렬
    alignOrigin: [0.5, 0.5],     // 요소의 중심을 패스에 맞춤
    autoRotate: true,            // 진행 방향으로 회전
    start: 0,
    end: 1,
  },
  duration: 6,
  ease: 'none',
  repeat: -1,
});

1.12 gsap.quickTo() — 고빈도 이벤트 (커서 추적)

gsap.to()를 mousemove마다 호출하면 매번 새 트윈 객체가 생성된다. quickTo는 하나의 트윈을 재사용한다.

const cursor = document.querySelector('.cursor');
const xTo = gsap.quickTo(cursor, 'x', { duration: 0.35, ease: 'power3' });
const yTo = gsap.quickTo(cursor, 'y', { duration: 0.35, ease: 'power3' });

window.addEventListener('pointermove', (e) => {
  xTo(e.clientX);
  yTo(e.clientY);
});

1.13 GSAP + React (@gsap/react)

npm i gsap@3.15.0 @gsap/react
'use client';

import { useRef } from 'react';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { useGSAP } from '@gsap/react';

gsap.registerPlugin(useGSAP, ScrollTrigger);

export function Hero({ items }) {
  const container = useRef(null);

  const { contextSafe } = useGSAP(
    () => {
      // 여기서 만든 모든 애니메이션은 언마운트 시 자동 revert된다.
      // 셀렉터 문자열은 scope 안으로 한정된다.
      gsap.from('.hero__item', {
        opacity: 0,
        y: 28,
        duration: 0.6,
        ease: 'power3.out',
        stagger: 0.06,
      });

      ScrollTrigger.create({
        trigger: container.current,
        start: 'top 70%',
        once: true,
        onEnter: () => gsap.to('.hero__bar', { scaleX: 1, duration: 0.8, ease: 'expo.out' }),
      });
    },
    { scope: container, dependencies: [items.length], revertOnUpdate: true }
  );

  // 이벤트 핸들러 안의 애니메이션은 useGSAP 실행 시점 이후라 자동 정리되지 않는다.
  // contextSafe로 감싸야 정리 대상에 포함된다.
  const onEnter = contextSafe((e) => {
    gsap.to(e.currentTarget, { scale: 1.04, duration: 0.25, ease: 'power2.out' });
  });
  const onLeave = contextSafe((e) => {
    gsap.to(e.currentTarget, { scale: 1, duration: 0.35, ease: 'power2.out' });
  });

  return (
    <section ref={container} className="hero">
      <div className="hero__bar" style={{ transform: 'scaleX(0)', transformOrigin: '0 50%' }} />
      {items.map((item) => (
        <article
          key={item.id}
          className="hero__item"
          onPointerEnter={onEnter}
          onPointerLeave={onLeave}
        >
          {item.title}
        </article>
      ))}
    </section>
  );
}

1.14 GSAP 흔한 실수 10가지

# 실수 결과 처방
1 플러그인을 registerPlugin 하지 않음 조용히 무시되거나 Invalid property 경고 진입점에서 한 번에 등록
2 React에서 useEffect cleanup 누락 디태치된 DOM 노드 참조로 메모리 누수, 리렌더 시 애니메이션 중첩 useGSAP 또는 gsap.context() + ctx.revert()
3 이벤트 핸들러 애니메이션을 contextSafe로 안 감쌈 언마운트 후에도 살아있음 contextSafe()
4 left/top/width/height를 애니메이션 매 프레임 레이아웃 x/y/xPercent/yPercent/scale 사용
5 이미지 로드 전에 ScrollTrigger 생성 시작/끝 위치가 어긋남 ScrollTrigger.refresh()load 또는 이미지 디코드 후 호출
6 반응형에서 invalidateOnRefresh 누락 리사이즈 후 시작값이 옛날 값으로 고정 invalidateOnRefresh: true + 함수형 end: () => ...
7 .from()을 여러 번 재실행 시작값이 누적되어 요소가 사라짐 .fromTo()를 쓰거나 immediateRender: false
8 여러 트윈이 같은 속성을 경쟁 깜빡임 overwrite: 'auto' 또는 true
9 핀 요소에 position: fixed 자식 transform 컨테이닝 블록 때문에 좌표가 틀어짐 핀 래퍼 밖으로 빼거나 pinType: 'fixed' 검토
10 markers: true를 프로덕션에 남김 화면에 개발용 마커 표시 빌드 시 제거 또는 markers: import.meta.env.DEV

정리(cleanup) 표준 패턴 — 프레임워크 무관

function mountMotion(root) {
  const ctx = gsap.context(() => {
    // 이 안에서 만든 모든 GSAP 객체(트윈/타임라인/ScrollTrigger)가 추적된다
    gsap.from('.item', { opacity: 0, y: 20, stagger: 0.05 });
    ScrollTrigger.create({ trigger: '.section', start: 'top 80%', onEnter: () => {} });
  }, root);   // root를 넘기면 셀렉터가 root 하위로 한정된다

  return () => ctx.revert();   // 애니메이션 제거 + 인라인 스타일 원복
}

1.15 ScrollSmoother — 쓸까 말까

GSAP의 스무스 스크롤 플러그인. Lenis와 경쟁한다.

<body>
  <div id="smooth-wrapper">
    <div id="smooth-content">
      <!-- 모든 콘텐츠 -->
    </div>
  </div>
  <!-- position: fixed 요소는 wrapper 밖에 둔다 (transform이 containing block을 만들기 때문) -->
</body>
import { ScrollSmoother } from 'gsap/ScrollSmoother';
gsap.registerPlugin(ScrollTrigger, ScrollSmoother);

const smoother = ScrollSmoother.create({
  wrapper: '#smooth-wrapper',
  content: '#smooth-content',
  smooth: 1,              // 네이티브 스크롤 위치를 따라잡는 시간(초)
  smoothTouch: false,     // 터치에서는 끄는 것이 기본이자 권장
  effects: true,          // data-speed / data-lag 속성 활성화
  normalizeScroll: true,  // 모바일 주소창 리사이즈 대응
  ignoreMobileResize: true,
});

// prefers-reduced-motion 대응 — 자동이 아니다. 직접 처리해야 한다.
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
  smoother.kill();
}
<!-- 시차 효과: data-speed -->
<img data-speed="0.85" src="/bg.jpg" alt="">   <!-- 느리게 (뒤에 있는 느낌) -->
<h2 data-speed="1.15">제목</h2>                 <!-- 빠르게 (앞에 있는 느낌) -->
<div data-lag="0.4">지연 추종</div>

ScrollSmoother vs Lenis

ScrollSmoother Lenis
방식 #smooth-content를 transform으로 이동 네이티브 scrollTop을 이징
position: fixed wrapper 밖으로 빼야 함 그대로 동작
position: sticky 동작하나 주의 필요 그대로 동작
ScrollTrigger 통합 완벽(같은 팀) 수동 연결 필요
reduced-motion 수동 처리 필요 respectReducedMotion: true 기본
번들 GSAP 생태계 안 ~3KB
접근성 transform 방식이라 스크롤바/문서 위치 괴리 발생 가능 네이티브 스크롤 유지

designpaca 판단: 접근성 관점에서 Lenis가 우위. ScrollSmoother는 이미 GSAP 중심 프로젝트이고 ScrollTrigger와의 완벽한 동기화가 필수일 때만.


2. Motion 13.1.0 (구 Framer Motion)

2.1 패키지 구조와 번들 크기

npm i motion@13.1.0
import 크기(min+gzip, Rollup 기준) 용도
import { animate } from 'motion' 미니 빌드 ~2.3KB 바닐라 JS, 단순 애니메이션
useAnimate mini (motion/react-mini) 2.3KB React, 명령형 애니메이션만
m + LazyMotion + domAnimation 4.6KB + 15KB(지연 로드) React, 선언적
m + LazyMotion + domMax 4.6KB + 25KB + 드래그/레이아웃
motion (풀) ~34KB 전부

framer-motionmotion동일 코드의 별칭 패키지다. 신규 프로젝트는 motion을 쓴다.

2.2 바닐라 JS

import { animate, scroll, inView, stagger, press, hover } from 'motion';

// 기본 애니메이션
animate('.box', { opacity: [0, 1], y: [24, 0] }, {
  duration: 0.6,
  ease: [0.16, 1, 0.3, 1],
  delay: stagger(0.06),
});

// 스크롤 연동 (컴포지터에서 실행되는 WAAPI 경로를 자동 선택)
const progress = animate('.progress-bar', { scaleX: [0, 1] }, { ease: 'linear' });
scroll(progress);

// 요소 기준 스크롤
scroll(
  animate('.hero-img', { opacity: [0, 1, 1, 0] }, { ease: 'linear' }),
  { target: document.querySelector('.hero'), offset: ['start end', 'end start'] }
);

// 뷰포트 진입
inView('.reveal', (element) => {
  animate(element, { opacity: [0, 1], y: [32, 0] }, { duration: 0.6, ease: [0.16, 1, 0.3, 1] });
  return () => {};   // 반환 함수는 요소가 뷰포트를 벗어날 때 실행
}, { amount: 0.25 });

// 제스처
press('button', (element) => {
  animate(element, { scale: 0.96 }, { duration: 0.08 });
  return () => animate(element, { scale: 1 }, { type: 'spring', stiffness: 500, damping: 25 });
});

hover('.card', (element) => {
  animate(element, { y: -6 }, { duration: 0.15 });
  return () => animate(element, { y: 0 }, { duration: 0.35 });
});

2.3 React — transition 옵션 전체

<motion.div
  animate={{ x: 100 }}
  transition={{
    // ── 타입 ─────────────────────────
    type: 'spring',        // 'tween' | 'spring' | 'inertia'

    // ── spring (물리 기반) ──────────
    stiffness: 300,        // 기본 1 (실무 170~400)
    damping: 30,           // 기본 10
    mass: 1,               // 기본 1
    velocity: 0,           // 초기 속도
    restSpeed: 0.1,
    restDelta: 0.01,

    // ── spring (지속시간 기반) — 디자이너 친화적. 이걸 권장 ──
    // visualDuration: 0.35,   // 시각적으로 목표에 닿는 시간
    // bounce: 0.2,            // 0=바운스 없음, 1=극단

    // ── tween ───────────────────────
    // duration: 0.3,       // 기본 0.3 (키프레임 여러 개면 0.8)
    // ease: [0.16, 1, 0.3, 1],
    // times: [0, 0.4, 1],  // 키프레임 위치 정규화

    // ── 반복 ────────────────────────
    repeat: 0,             // Infinity 가능
    repeatType: 'loop',    // 'loop' | 'reverse' | 'mirror'
    repeatDelay: 0,
    delay: 0,

    // ── 값별 개별 transition ────────
    // default: { type: 'spring' },
    // opacity: { ease: 'linear', duration: 0.2 },
  }}
/>

2.4 완성 코드 — AnimatePresence (진입/퇴장)

'use client';

import { useState } from 'react';
import { AnimatePresence, motion, useReducedMotion } from 'motion/react';

export function Drawer() {
  const [open, setOpen] = useState(false);
  const reduce = useReducedMotion();

  const panel = reduce
    ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 },
        transition: { duration: 0.12 } }
    : { initial: { x: '100%' }, animate: { x: 0 }, exit: { x: '100%' },
        transition: { type: 'spring', visualDuration: 0.35, bounce: 0 } };

  return (
    <>
      <button onClick={() => setOpen(true)}>열기</button>

      {/* mode="wait" = 나가는 요소가 완전히 사라진 뒤 들어오는 요소 시작 */}
      <AnimatePresence>
        {open && (
          <>
            <motion.div
              key="scrim"
              className="drawer__scrim"
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              transition={{ duration: 0.2 }}
              onClick={() => setOpen(false)}
            />
            <motion.aside
              key="panel"
              className="drawer__panel"
              role="dialog"
              aria-modal="true"
              aria-label="설정"
              {...panel}
            >
              <button onClick={() => setOpen(false)}>닫기</button>
              <p>드로어 내용</p>
            </motion.aside>
          </>
        )}
      </AnimatePresence>
    </>
  );
}
.drawer__scrim {
  position: fixed; inset: 0;
  background: rgb(0 0 0 / 0.45);
  z-index: 40;
}
.drawer__panel {
  position: fixed; inset-block: 0; inset-inline-end: 0;
  width: min(400px, 90vw);
  background: Canvas;
  padding: 24px;
  z-index: 41;
  box-shadow: -12px 0 40px rgb(0 0 0 / 0.2);
}

AnimatePresence 3가지 mode

  • mode="sync" (기본): 진입/퇴장 동시
  • mode="wait": 퇴장 완료 후 진입 — 페이지 전환에 적합
  • mode="popLayout": 퇴장하는 요소를 레이아웃에서 즉시 빼고 나머지가 자리를 메움 — 리스트 삭제에 필수

2.5 완성 코드 — layout 애니메이션 (Motion의 킬러 기능)

'use client';

import { useState } from 'react';
import { AnimatePresence, motion, LayoutGroup } from 'motion/react';

const TABS = [
  { id: 'a', label: '개요' },
  { id: 'b', label: '가격' },
  { id: 'c', label: '문서' },
];

export function Tabs() {
  const [active, setActive] = useState('a');

  return (
    <LayoutGroup>
      <div className="tabs" role="tablist">
        {TABS.map((tab) => (
          <button
            key={tab.id}
            role="tab"
            aria-selected={active === tab.id}
            className="tabs__btn"
            onClick={() => setActive(tab.id)}
          >
            {tab.label}
            {active === tab.id && (
              // layoutId가 같은 요소끼리 위치/크기를 자동 보간한다.
              // 이게 shared element transition의 React 버전이다.
              <motion.span
                layoutId="tab-underline"
                className="tabs__underline"
                transition={{ type: 'spring', visualDuration: 0.3, bounce: 0.15 }}
              />
            )}
          </button>
        ))}
      </div>
    </LayoutGroup>
  );
}

export function ReorderableList({ items, onRemove }) {
  return (
    <ul className="rlist">
      <AnimatePresence mode="popLayout" initial={false}>
        {items.map((item) => (
          <motion.li
            key={item.id}
            layout                       // 위치가 바뀌면 자동으로 애니메이션
            initial={{ opacity: 0, scale: 0.94 }}
            animate={{ opacity: 1, scale: 1 }}
            exit={{ opacity: 0, scale: 0.94 }}
            transition={{ type: 'spring', visualDuration: 0.28, bounce: 0.1 }}
            className="rlist__item"
          >
            {item.label}
            <button onClick={() => onRemove(item.id)} aria-label={`${item.label} 삭제`}>×</button>
          </motion.li>
        ))}
      </AnimatePresence>
    </ul>
  );
}
.tabs__btn { position: relative; padding: 10px 16px; border: 0; background: none; cursor: pointer; }
.tabs__underline {
  position: absolute;
  inset-inline: 8px;
  inset-block-end: 0;
  height: 2px;
  background: currentColor;
  border-radius: 1px;
}

layout 애니메이션 주의사항

  • layout prop은 FLIP 기법이다: 레이아웃을 한 번 측정하고 transform으로만 애니메이션한다. 따라서 프레임마다 레이아웃을 다시 계산하지 않는다(성능 B등급).
  • border-radius, box-shadow는 scale로 왜곡된다. Motion이 borderRadius는 자동 보정하지만 padding/font-size 같은 것은 왜곡된다. 왜곡을 피하려면 자식에 layout="position"을 준다.
  • layout 요소가 많으면(50개+) 초기 측정 비용이 커진다. layoutDependency로 측정 시점을 제한한다.

2.6 완성 코드 — useScroll 시차

'use client';

import { useRef } from 'react';
import { motion, useScroll, useTransform, useSpring, useReducedMotion } from 'motion/react';

export function ParallaxSection() {
  const ref = useRef(null);
  const reduce = useReducedMotion();

  const { scrollYProgress } = useScroll({
    target: ref,
    // 'start end' = 타깃의 시작이 뷰포트 끝에 닿는 지점 → progress 0
    // 'end start'  = 타깃의 끝이 뷰포트 시작에 닿는 지점 → progress 1
    offset: ['start end', 'end start'],
  });

  // 스프링으로 부드럽게 (선택) — 스크럽에 과하면 오히려 지연으로 느껴진다
  const smooth = useSpring(scrollYProgress, {
    stiffness: 120,
    damping: 30,
    restDelta: 0.001,
  });

  const y = useTransform(reduce ? scrollYProgress : smooth, [0, 1], ['-8%', '8%']);
  const opacity = useTransform(scrollYProgress, [0, 0.25, 0.75, 1], [0, 1, 1, 0]);

  if (reduce) {
    return (
      <section ref={ref} className="px">
        <div className="px__bg" />
        <div className="px__content"><h2>제목</h2></div>
      </section>
    );
  }

  return (
    <section ref={ref} className="px">
      <motion.div className="px__bg" style={{ y }} />
      <motion.div className="px__content" style={{ opacity }}>
        <h2>제목</h2>
      </motion.div>
    </section>
  );
}

2.7 React Server Components와의 공존

Motion 컴포넌트는 클라이언트 컴포넌트다. 하지만 서버 컴포넌트 트리 안에서 쓰는 두 가지 방법이 있다.

// 방법 1: motion/react-client — 서버 컴포넌트 파일에서 직접 사용 가능
// (내부적으로 "use client" 경계가 이미 설정된 re-export)
import * as motion from 'motion/react-client';

export default function Page() {   // 서버 컴포넌트 — "use client" 없음
  return (
    <motion.section
      initial={{ opacity: 0, y: 20 }}
      whileInView={{ opacity: 1, y: 0 }}
      viewport={{ once: true, amount: 0.3 }}
      transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
    >
      <h1>서버에서 렌더된 콘텐츠</h1>
    </motion.section>
  );
}
// 방법 2: 작은 클라이언트 래퍼를 만들고 children을 서버에서 넘긴다 — 번들에 유리
'use client';
import { motion } from 'motion/react';

export function Reveal({ children, delay = 0 }) {
  return (
    <motion.div
      initial={{ opacity: 0, y: 24 }}
      whileInView={{ opacity: 1, y: 0 }}
      viewport={{ once: true, amount: 0.25 }}
      transition={{ duration: 0.55, delay, ease: [0.16, 1, 0.3, 1] }}
    >
      {children}
    </motion.div>
  );
}
// 서버 컴포넌트에서
import { Reveal } from './reveal';

export default async function Page() {
  const posts = await getPosts();   // 서버에서 데이터 페치
  return (
    <>
      {posts.map((p, i) => (
        <Reveal key={p.id} delay={i * 0.05}>
          <article>{p.title}</article>   {/* 이 부분은 서버 컴포넌트 */}
        </Reveal>
      ))}
    </>
  );
}

SSR 초기 상태 주의: initial={{ opacity: 0 }}는 서버 HTML에도 반영된다. JS가 실패하거나 늦게 로드되면 콘텐츠가 영원히 보이지 않는다. 중요한 콘텐츠에는 initial={false}를 쓰거나, CSS @supports/.no-js 폴백을 둔다.

/* JS 비활성/실패 시 안전망 */
html.no-js [style*="opacity: 0"] { opacity: 1 !important; transform: none !important; }

2.8 번들 최적화

// app/providers.tsx
'use client';
import { LazyMotion, domAnimation, MotionConfig } from 'motion/react';

export function MotionProvider({ children }) {
  return (
    <LazyMotion features={domAnimation} strict>
      {/* strict: motion.div 사용 시 에러를 던져 m.div만 쓰도록 강제 → 번들 보호 */}
      <MotionConfig
        reducedMotion="user"    /* 'user' | 'always' | 'never'  'user' 기본이자 권장 */
        transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
      >
        {children}
      </MotionConfig>
    </LazyMotion>
  );
}
// 컴포넌트에서는 m을 쓴다
import * as m from 'motion/react-m';

export function Card() {
  return <m.div whileHover={{ y: -6 }} />;
}
// 드래그/레이아웃이 필요한 페이지에서만 domMax를 지연 로드
const loadMax = () => import('motion/react').then((m) => m.domMax);

<LazyMotion features={loadMax} strict>{children}</LazyMotion>

3. Lenis 1.3.26 — 스무스 스크롤

3.1 논쟁: 써야 하나 말아야 하나

반대 논거 (정당하다)

  • 스크롤은 사용자가 기대하는 기기 고유의 물리를 갖는다. 이것을 바꾸는 것은 시스템 관습 침해다.
  • 스크롤 재킹의 역사가 나쁘다: position: fixed 컨테이너를 transform으로 밀던 구식 구현은 스크롤바, 키보드 스크롤(Space/PageDown), 스크린리더 커서, 브라우저 검색(Ctrl+F)의 스크롤을 전부 깨뜨렸다.
  • 관성이 붙으면 정확한 위치에 멈추기가 어렵다. 운동 장애가 있는 사용자에게 치명적이다.
  • 지연은 모든 사용자에게 인지 비용이다. 프레임이 부드러워 보이는 대신 반응이 늦어진다.

찬성 논거 (역시 정당하다)

  • Lenis는 구식 재킹이 아니다. 네이티브 스크롤 위치(scrollTop)를 이징할 뿐이다. 스크롤바가 정상 동작하고, 문서 좌표계가 유지되며, position: sticky, 앵커 링크, 스크린리더 탐색이 모두 그대로 작동한다.
  • WebGL/Canvas와 DOM을 동기화할 때는 스크롤을 메인 스레드에서 통제해야만 드리프트가 사라진다.
  • 브랜드/포트폴리오 사이트에서 스크롤 감각은 실제 디자인 자산이다.

designpaca 판단 규칙

프로젝트 유형 판단
대시보드, 관리도구, 문서, 커머스 목록, 폼 중심 쓰지 않는다. scroll-behavior: smooth로 충분
콘텐츠 사이트, 블로그, 뉴스 쓰지 않는다. 읽기를 방해한다
브랜드 랜딩, 포트폴리오, 캠페인 쓸 수 있다. 단 아래 조건 전부 충족 시
WebGL과 DOM을 동기화해야 하는 경우 사실상 필수

Lenis를 쓸 때 반드시 충족할 조건

  1. respectReducedMotion: true (기본값이지만 명시)
  2. duration1.0~1.2 이하. 그 이상은 "느리다"가 된다
  3. syncTouch: false (기본값). 터치 기기에서 네이티브 스크롤을 건드리지 않는다
  4. 모달/드로어가 열릴 때 lenis.stop()
  5. 스크롤 가능한 내부 요소(코드 블록, 지도)에 data-lenis-prevent
  6. 키보드 스크롤(Space, PageUp/Down, Home/End)이 정상 동작하는지 실제로 테스트

3.2 완성 코드 — Lenis + GSAP ScrollTrigger

npm i lenis@1.3.26 gsap@3.15.0
import Lenis from 'lenis';
import 'lenis/dist/lenis.css';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';

gsap.registerPlugin(ScrollTrigger);

let lenis = null;

export function initSmoothScroll() {
  const reduce = window.matchMedia('(prefers-reduced-motion: reduce)');

  // 감소 모드에서는 아예 생성하지 않는다 (Lenis 내부 처리보다 확실하다)
  if (reduce.matches) {
    document.documentElement.style.scrollBehavior = 'auto';
    return () => {};
  }

  lenis = new Lenis({
    duration: 1.05,
    easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),  // expo.out
    orientation: 'vertical',
    gestureOrientation: 'vertical',
    smoothWheel: true,
    syncTouch: false,          // 터치는 네이티브 그대로
    wheelMultiplier: 1,
    touchMultiplier: 1,
    infinite: false,
    autoRaf: false,            // GSAP ticker로 직접 구동하므로 false
    respectReducedMotion: true,
    anchors: true,             // 앵커 링크를 Lenis가 처리
    allowNestedScroll: true,   // 중첩 스크롤 컨테이너 자동 처리
    prevent: (node) => node.hasAttribute?.('data-lenis-prevent'),
  });

  // 1) Lenis 스크롤 → ScrollTrigger 갱신
  lenis.on('scroll', ScrollTrigger.update);

  // 2) GSAP ticker가 Lenis를 구동 (RAF 루프 하나로 통일 — 이게 핵심)
  const raf = (time) => lenis.raf(time * 1000);   // gsap는 초, lenis는 ms
  gsap.ticker.add(raf);
  gsap.ticker.lagSmoothing(0);   // 탭 전환 후 점프 방지

  // 3) 감소 모드로 전환되면 즉시 파괴
  const onPrefChange = (e) => { if (e.matches) destroySmoothScroll(); };
  reduce.addEventListener('change', onPrefChange);

  return () => {
    reduce.removeEventListener('change', onPrefChange);
    destroySmoothScroll();
  };
}

export function destroySmoothScroll() {
  if (!lenis) return;
  gsap.ticker.remove((time) => lenis.raf(time * 1000));
  lenis.destroy();
  lenis = null;
  ScrollTrigger.refresh();
}

// 모달 열림/닫힘 시 스크롤 잠금
export function lockScroll(locked) {
  if (!lenis) {
    document.documentElement.style.overflow = locked ? 'hidden' : '';
    return;
  }
  locked ? lenis.stop() : lenis.start();
}

// 프로그램적 스크롤
export function scrollToElement(target, offset = -80) {
  if (lenis) {
    lenis.scrollTo(target, { offset, duration: 1.1 });
  } else {
    document.querySelector(target)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
  }
}
<!-- 내부 스크롤 영역은 Lenis에서 제외 -->
<pre data-lenis-prevent><code>…긴 코드…</code></pre>
<div class="map" data-lenis-prevent></div>

3.3 Lenis 주요 옵션 (v1.3.26 README 기준)

옵션 기본값 설명
duration 1.2 애니메이션 지속시간(초). 1.0~1.2 권장
easing (t) => Math.min(1, 1.001 - 2**(-10*t)) 이징 함수
lerp 0.1 선형 보간 강도. duration과 배타적
smoothWheel true 휠 이벤트 스무딩
syncTouch false 터치를 스무스 스크롤로 동기화. 켜지 말 것
syncTouchLerp 0.075 터치 관성 lerp
touchInertiaExponent 1.7 터치 관성 강도
orientation 'vertical' vertical | horizontal
gestureOrientation 'vertical' 제스처 방향
wheelMultiplier 1 휠 배율
touchMultiplier 1 터치 배율
infinite false 무한 스크롤
autoRaf false 내부 RAF 자동 실행
autoResize true ResizeObserver로 자동 리사이즈
autoToggle false 래퍼 overflow에 따라 자동 시작/정지
anchors false 앵커 링크 처리
allowNestedScroll false 중첩 스크롤 요소 자동 처리
overscroll true 오버스크롤 동작
respectReducedMotion true reduced-motion 시 lerp를 1로 강제, 프로그램적 스크롤은 즉시 점프
prevent undefined (node) => boolean — 제외할 요소 판정
stopInertiaOnNavigate false 내부 링크 클릭 시 관성 정지

3.4 Lenis + React

'use client';

import { ReactLenis, useLenis } from 'lenis/react';
import { useEffect } from 'react';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';

gsap.registerPlugin(ScrollTrigger);

export function SmoothScrollProvider({ children }) {
  return (
    <ReactLenis
      root
      options={{
        duration: 1.05,
        smoothWheel: true,
        syncTouch: false,
        autoRaf: false,
        respectReducedMotion: true,
      }}
    >
      <LenisGsapBridge />
      {children}
    </ReactLenis>
  );
}

function LenisGsapBridge() {
  const lenis = useLenis();

  useEffect(() => {
    if (!lenis) return;
    const onScroll = () => ScrollTrigger.update();
    lenis.on('scroll', onScroll);

    const raf = (time) => lenis.raf(time * 1000);
    gsap.ticker.add(raf);
    gsap.ticker.lagSmoothing(0);

    return () => {
      lenis.off('scroll', onScroll);
      gsap.ticker.remove(raf);
    };
  }, [lenis]);

  return null;
}

4. 페이지 전환

4.1 방법 매트릭스

아키텍처 1순위 2순위 비고
MPA (일반 HTML/PHP/Rails) @view-transition { navigation: auto; } Barba.js / Swup Firefox 미지원 → 점진 향상
Astro <ClientRouter /> 폴백 내장, reduced-motion 자동 대응
Next.js App Router document.startViewTransition 수동 래핑 next-view-transitions 패키지 React <ViewTransition>은 아직 unstable_
React Router / TanStack Motion AnimatePresence mode="wait" View Transitions 라우터가 exit를 기다려줘야 함
Nuxt / Vue Router 내장 <Transition> + View Transitions Nuxt는 experimental.viewTransition

4.2 Astro — 완성 코드

---
// src/layouts/Base.astro
import { ClientRouter, fade } from 'astro:transitions';
---
<html lang="ko">
  <head>
    <meta charset="utf-8" />
    <ClientRouter fallback="animate" />
  </head>
  <body>
    <header transition:persist="site-header">
      <nav><a href="/">홈</a> <a href="/work">작업</a></nav>
    </header>

    <main transition:animate={fade({ duration: '0.28s' })}>
      <slot />
    </main>
  </body>
</html>
---
// src/pages/work/index.astro — 썸네일에 이름 부여
const items = await getWorkItems();
---
{items.map((item) => (
  <a href={`/work/${item.slug}`}>
    <img
      src={item.thumb}
      alt={item.title}
      transition:name={`work-${item.slug}`}
    />
  </a>
))}
---
// src/pages/work/[slug].astro — 같은 이름으로 받는다
const { slug } = Astro.params;
const item = await getWorkItem(slug);
---
<img src={item.hero} alt={item.title} transition:name={`work-${slug}`} />
<!-- 커스텀 전환 정의 -->
---
const slideUp = {
  forwards: {
    old: { name: 'slide-out-up',   duration: '0.24s', easing: 'cubic-bezier(0.5,0,0.75,0)' },
    new: { name: 'slide-in-up',    duration: '0.34s', easing: 'cubic-bezier(0.16,1,0.3,1)' },
  },
  backwards: {
    old: { name: 'slide-out-down', duration: '0.24s', easing: 'cubic-bezier(0.5,0,0.75,0)' },
    new: { name: 'slide-in-down',  duration: '0.34s', easing: 'cubic-bezier(0.16,1,0.3,1)' },
  },
};
---
<section transition:animate={slideUp}><slot /></section>

<style is:global>
  @keyframes slide-out-up   { to   { opacity: 0; transform: translateY(-16px); } }
  @keyframes slide-in-up    { from { opacity: 0; transform: translateY(16px);  } }
  @keyframes slide-out-down { to   { opacity: 0; transform: translateY(16px);  } }
  @keyframes slide-in-down  { from { opacity: 0; transform: translateY(-16px); } }
</style>
<!-- 라이프사이클 이벤트: 전환 후 스크립트 재실행 -->
<script>
  document.addEventListener('astro:page-load', () => {
    // 매 페이지 전환 후 실행 — GSAP ScrollTrigger 재초기화 등
    ScrollTrigger.refresh();
  });

  document.addEventListener('astro:before-swap', (e) => {
    // 새 문서가 들어오기 직전 — 테마 클래스 등을 이관
    e.newDocument.documentElement.dataset.theme =
      document.documentElement.dataset.theme;
  });

  document.addEventListener('astro:after-swap', () => {
    // DOM 교체 직후, 렌더 전 — 스크롤 위치 복원 등
  });
</script>

Astro의 <ClientRouter />prefers-reduced-motion자동으로 존중해 모든 애니메이션을 비활성화한다. 별도 처리가 필요 없다. 또한 페이지 <title>을 스크린리더에 자동 안내한다.

4.3 Next.js App Router — 완성 코드

// components/view-transition-link.tsx
'use client';

import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { startTransition, type ComponentProps } from 'react';

export function VTLink({ href, onClick, ...props }: ComponentProps<typeof Link>) {
  const router = useRouter();

  return (
    <Link
      href={href}
      onClick={(e) => {
        onClick?.(e);
        if (e.defaultPrevented) return;
        // 새 탭/수정키는 브라우저에 맡긴다
        if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
        if (!document.startViewTransition) return;
        if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;

        e.preventDefault();
        document.startViewTransition(() => {
          // React의 전환을 동기적으로 flush하도록 startTransition으로 감싼다
          startTransition(() => {
            router.push(String(href));
          });
        });
      }}
      {...props}
    />
  );
}
/* app/globals.css */
@view-transition { navigation: auto; }   /* MPA 폴백용. App Router에선 무시돼도 무해 */

::view-transition-old(root) {
  animation: 180ms cubic-bezier(0.5, 0, 0.75, 0) both vt-out;
}
::view-transition-new(root) {
  animation: 280ms cubic-bezier(0.16, 1, 0.3, 1) both vt-in;
}

@keyframes vt-out { to   { opacity: 0; transform: translateY(-8px); } }
@keyframes vt-in  { from { opacity: 0; transform: translateY(8px);  } }

@media (prefers-reduced-motion: reduce) {
  ::view-transition-old(root),
  ::view-transition-new(root) {
    animation-duration: 100ms;
    animation-name: vt-fade;
  }
  @keyframes vt-fade { from { opacity: 0; } }
}

React <ViewTransition> (실험적) — Next.js 16 기준

// next.config.js
module.exports = { experimental: { viewTransition: true } };
import { unstable_ViewTransition as ViewTransition } from 'react';

export default function Layout({ children }) {
  return <ViewTransition>{children}</ViewTransition>;
}

unstable_ 접두사는 View Transitions Level 2 사양이 아직 진화 중이기 때문이다. 프로덕션에서는 위의 document.startViewTransition 수동 래핑을 권장한다. 안정화되면 API 이름이 바뀔 예정이다.

4.4 Motion AnimatePresence로 페이지 전환 (라우터 무관)

'use client';

import { AnimatePresence, motion } from 'motion/react';
import { usePathname } from 'next/navigation';

export function PageTransition({ children }) {
  const pathname = usePathname();

  return (
    <AnimatePresence mode="wait" initial={false}>
      <motion.main
        key={pathname}
        initial={{ opacity: 0, y: 8 }}
        animate={{ opacity: 1, y: 0 }}
        exit={{ opacity: 0, y: -8 }}
        transition={{ duration: 0.24, ease: [0.16, 1, 0.3, 1] }}
        onAnimationComplete={() => window.scrollTo({ top: 0, behavior: 'instant' })}
      >
        {children}
      </motion.main>
    </AnimatePresence>
  );
}

Next.js App Router의 근본 한계: App Router는 exit 애니메이션이 끝날 때까지 라우팅을 기다려주지 않는다. mode="wait"을 써도 새 페이지가 이미 렌더된 상태다. 이 때문에 App Router에서는 View Transitions API가 더 적합하다.

4.5 전환 중 스크롤 위치 처리 — 규칙

상황 처리
새 페이지로 이동(push) 최상단으로. DOM 업데이트 콜백 안에서 behavior: 'instant'
뒤로 가기(pop) 이전 스크롤 위치 복원. history.scrollRestoration = 'auto' 유지
shared element 전환 스크롤이 애니메이션 중 움직이면 스냅샷이 어긋난다. 전환 시작 전에 위치를 확정
앵커 이동(#hash) scroll-margin-block-start로 고정 헤더 보정
스무스 스크롤 라이브러리 사용 중 전환 직전 lenis.stop(), 전환 후 lenis.scrollTo(0, { immediate: true }) + lenis.start()
// 전환 + 스무스 스크롤 통합 처리
async function navigateWithTransition(url) {
  lockScroll(true);                     // lenis.stop()
  const t = document.startViewTransition(() => {
    renderPage(url);
    window.scrollTo({ top: 0, behavior: 'instant' });
  });
  await t.finished;
  lockScroll(false);                    // lenis.start()
  ScrollTrigger.refresh();
}

5. Web Animations API — 라이브러리 없는 명령형 제어

번들 0KB로 타임라인 없는 명령형 애니메이션이 필요할 때.

const el = document.querySelector('.box');

const anim = el.animate(
  [
    { opacity: 0, transform: 'translateY(24px) scale(0.96)' },
    { opacity: 1, transform: 'translateY(0) scale(1)' },
  ],
  {
    duration: 480,                       // ms (CSS와 달리 숫자)
    easing: 'cubic-bezier(0.16, 1, 0.3, 1)',   // 기본값은 'linear' (CSS는 'ease')
    fill: 'both',
    iterations: 1,                       // Infinity 사용 가능 ('infinite' 아님)
    delay: 0,
  }
);

// 제어
anim.pause();
anim.play();
anim.reverse();
anim.finish();
anim.cancel();
anim.playbackRate = 0.5;
anim.currentTime = 240;
anim.updatePlaybackRate(0.9);   // 속도를 부드럽게 변경

// 완료 대기
await anim.finished;

// fill: 'forwards'를 영구 유지하는 대신 계산된 값을 스타일에 커밋 (권장)
anim.commitStyles();
anim.cancel();

// 페이지의 모든 애니메이션 조회 (디버깅/일괄 정지에 유용)
document.getAnimations().forEach((a) => a.pause());

WAAPI + ScrollTimeline (스크롤 연동, 컴포지터 실행)

if ('ScrollTimeline' in window) {
  const timeline = new ScrollTimeline({ source: document.documentElement, axis: 'block' });
  document.querySelector('.progress').animate(
    { transform: ['scaleX(0)', 'scaleX(1)'] },
    { timeline, fill: 'both' }
  );
}

if ('ViewTimeline' in window) {
  document.querySelectorAll('.reveal').forEach((el) => {
    const timeline = new ViewTimeline({ subject: el, axis: 'block' });
    el.animate(
      { opacity: [0, 1], transform: ['translateY(32px)', 'translateY(0)'] },
      { timeline, rangeStart: 'entry 20%', rangeEnd: 'cover 40%', fill: 'both' }
    );
  });
}

6. three.js ↔ DOM 스크롤 동기화

6.1 아키텍처 3가지

방식 구조 장점 단점
A. 고정 캔버스 + 진행률 position: fixed 캔버스, DOM이 그 위에 스크롤 가장 단순, 안정적 3D와 DOM의 픽셀 정합은 안 됨
B. 프록시 요소 추적 DOM에 빈 플레이스홀더를 두고 3D 오브젝트를 그 좌표에 맞춤 DOM과 픽셀 단위 정합 스크롤 중 드리프트 발생 가능
C. 스크롤 통제(Lenis/ScrollSmoother) 스크롤 자체를 메인 스레드에서 이징 드리프트 완전 제거 스크롤 재킹 비용

핵심 문제: 브라우저의 네이티브 스크롤은 컴포지터에서 처리되지만 WebGL 렌더는 메인 스레드다. 따라서 빠르게 스크롤하면 DOM이 먼저 움직이고 캔버스가 한 프레임 늦게 따라온다. 픽셀 단위 정합이 필요하면 C 방식(스크롤 통제)이 사실상 유일한 해법이다.

6.2 완성 코드 — 방식 A (고정 캔버스, 대부분의 경우 이걸로 충분)

<canvas id="webgl"></canvas>
<div class="content">
  <section class="chapter" data-chapter="0"><h2>Chapter 1</h2></section>
  <section class="chapter" data-chapter="1"><h2>Chapter 2</h2></section>
  <section class="chapter" data-chapter="2"><h2>Chapter 3</h2></section>
</div>
#webgl {
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: 0;
  pointer-events: none;
}
.content { position: relative; z-index: 1; }
.chapter { min-height: 100svh; display: grid; place-items: center; }
import * as THREE from 'three';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
gsap.registerPlugin(ScrollTrigger);

const canvas = document.getElementById('webgl');
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, innerWidth / innerHeight, 0.1, 100);
camera.position.z = 6;

const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
renderer.setSize(innerWidth, innerHeight);
// DPR 상한 — 이걸 안 하면 고해상도 모바일에서 프레임이 반토막 난다
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));

const mesh = new THREE.Mesh(
  new THREE.TorusKnotGeometry(1, 0.32, 160, 24),
  new THREE.MeshStandardMaterial({ color: 0x7c3aed, roughness: 0.35, metalness: 0.1 })
);
scene.add(mesh);
scene.add(new THREE.DirectionalLight(0xffffff, 2.4).translateZ(5));
scene.add(new THREE.AmbientLight(0xffffff, 0.6));

// ── 스크롤 상태를 "목표값"으로만 저장하고, 렌더 루프에서 보간한다 ──
const state = { targetProgress: 0, currentProgress: 0, targetRotY: 0, currentRotY: 0 };

const reduce = matchMedia('(prefers-reduced-motion: reduce)');

ScrollTrigger.create({
  trigger: '.content',
  start: 'top top',
  end: 'bottom bottom',
  onUpdate: (self) => {
    state.targetProgress = self.progress;
    state.targetRotY = self.progress * Math.PI * 2;
  },
});

// 챕터별 이산적 상태 변화
gsap.utils.toArray('.chapter').forEach((section, i) => {
  ScrollTrigger.create({
    trigger: section,
    start: 'top 60%',
    end: 'bottom 40%',
    onToggle: (self) => {
      if (!self.isActive) return;
      gsap.to(mesh.material.color, {
        r: [0.49, 0.93, 0.96][i],
        g: [0.35, 0.28, 0.62][i],
        b: [0.93, 0.6, 0.04][i],
        duration: reduce.matches ? 0 : 0.8,
        ease: 'power2.out',
      });
    },
  });
});

// ── 렌더 루프: GSAP ticker 하나로 통일 (RAF 루프를 두 개 돌리지 않는다) ──
const LERP = 0.09;

gsap.ticker.add(() => {
  const k = reduce.matches ? 1 : LERP;
  state.currentProgress += (state.targetProgress - state.currentProgress) * k;
  state.currentRotY     += (state.targetRotY     - state.currentRotY)     * k;

  mesh.rotation.y = state.currentRotY;
  mesh.position.y = -state.currentProgress * 2;
  camera.position.z = 6 - state.currentProgress * 1.5;

  renderer.render(scene, camera);
});

// 리사이즈
let resizeRaf = null;
addEventListener('resize', () => {
  if (resizeRaf) cancelAnimationFrame(resizeRaf);
  resizeRaf = requestAnimationFrame(() => {
    camera.aspect = innerWidth / innerHeight;
    camera.updateProjectionMatrix();
    renderer.setSize(innerWidth, innerHeight);
    renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
    ScrollTrigger.refresh();
  });
});

// 탭이 백그라운드일 때 렌더 중단 — 배터리 절약
document.addEventListener('visibilitychange', () => {
  document.hidden ? gsap.ticker.sleep() : gsap.ticker.wake();
});

6.3 완성 코드 — 방식 B (프록시 요소 픽셀 정합)

<div class="proxy" data-model="chair" style="aspect-ratio: 1;"></div>
/**
 * DOM 요소의 화면 좌표를 three.js 월드 좌표로 변환한다.
 * 카메라가 원점을 바라보는 PerspectiveCamera 기준.
 */
function domToWorld(el, camera, distance) {
  const rect = el.getBoundingClientRect();

  // 1) 화면 중심 기준 NDC(-1~1)
  const ndcX = ((rect.left + rect.width / 2) / innerWidth) * 2 - 1;
  const ndcY = -((rect.top + rect.height / 2) / innerHeight) * 2 + 1;

  // 2) 주어진 거리에서의 뷰 평면 크기
  const vFov = (camera.fov * Math.PI) / 180;
  const planeH = 2 * Math.tan(vFov / 2) * distance;
  const planeW = planeH * camera.aspect;

  // 3) 월드 좌표
  const x = (ndcX * planeW) / 2;
  const y = (ndcY * planeH) / 2;

  // 4) DOM 픽셀 크기 → 월드 단위 스케일
  const scale = (rect.width / innerWidth) * planeW;

  return { x, y, scale, visible: rect.bottom > 0 && rect.top < innerHeight };
}

const proxies = [...document.querySelectorAll('.proxy')].map((el) => ({
  el,
  object: modelsByName[el.dataset.model],
}));

gsap.ticker.add(() => {
  for (const { el, object } of proxies) {
    const { x, y, scale, visible } = domToWorld(el, camera, camera.position.z);
    object.visible = visible;               // 화면 밖이면 렌더 스킵
    if (!visible) continue;
    object.position.set(x, y, 0);
    object.scale.setScalar(scale);
  }
  renderer.render(scene, camera);
});

방식 B의 필수 조건: 스크롤이 메인 스레드에서 통제되어야 한다(Lenis/ScrollSmoother). 네이티브 스크롤에서는 getBoundingClientRect()가 컴포지터의 최신 위치를 반영하지 못해 빠른 스크롤 시 3D 오브젝트가 DOM보다 뒤처진다.

6.4 React Three Fiber를 쓴다면

@14islands/r3f-scroll-rig가 위의 프록시 방식(방식 B)을 프로덕션 수준으로 구현해 둔 라이브러리다. 직접 구현하기 전에 검토한다.


7. 스택별 최종 판단 요약

CSS 네이티브     ← 기본값. 여기서 시작한다.
   ↓ 부족하면
WAAPI            ← 명령형 제어가 필요하지만 번들을 늘리기 싫을 때
   ↓ 부족하면
Motion (React)   ← 레이아웃 변화, 제스처, 스프링, AnimatePresence
GSAP (전부)      ← 스크롤 시퀀스, 핀 고정, 텍스트 분해, SVG, 복잡한 타임라인
   ↓ 스크롤 감각이 브랜드 요구사항이면
Lenis            ← 단, 위 3.1의 조건 전부 충족 시에만

같은 프로젝트에서 GSAP과 Motion을 둘 다 쓰는 것은 대체로 실수다. 번들이 두 배가 되고 두 개의 RAF 루프가 돌아간다. 예외: Motion으로 컴포넌트 마이크로 인터랙션을, GSAP ScrollTrigger로 페이지 레벨 스크롤 시퀀스를 담당하는 명확한 역할 분리가 있을 때. 그 경우에도 RAF 루프는 gsap.ticker 하나로 통일한다.