# 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 설치와 등록 ```bash npm i gsap@3.15.0 ``` ```js 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 타임라인 설계 — 위치 파라미터가 핵심 ```js 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(); ``` **타임라인 제어** ```js 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 — 전체 설정 레퍼런스 ```js 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 완성 코드 — 핀 고정 + 스크럽 시퀀스 ```html

첫 번째

두 번째

세 번째

``` ```css .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; } ``` ```js 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 완성 코드 — 수평 스크롤 섹션 ```js 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를 직접 쓰는 것보다 낫다. 동시에 들어온 요소들을 **묶어서** 콜백을 준다. ```js 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) — 텍스트 등장 ```js 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"`)는 쓰지 않는다.** ```html

여기 텍스트

여기 텍스트

``` ```css .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; } ``` > `
`에 `aria-label`을 붙이는 방식은 **동작하지 않는다.** generic role은 author naming이 > 금지되어 있다. `

`, `

` 같은 의미론적 요소이거나 `role="text"`(Safari 전용)가 필요하다. > 위의 "원문 + `aria-hidden` 시각 사본" 패턴이 가장 안전하다. ### 1.10 Flip — 레이아웃 변화 애니메이션 ```js 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 — 패스 위 이동 ```js 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`는 하나의 트윈을 재사용한다. ```js 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`) ```bash npm i gsap@3.15.0 @gsap/react ``` ```jsx '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 (

{items.map((item) => (
{item.title}
))}
); } ``` ### 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) 표준 패턴 — 프레임워크 무관** ```js 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와 경쟁한다. ```html
``` ```js 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(); } ``` ```html

제목

지연 추종
``` **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 패키지 구조와 번들 크기 ```bash 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-motion`은 `motion`과 **동일 코드의 별칭 패키지**다. 신규 프로젝트는 `motion`을 쓴다. ### 2.2 바닐라 JS ```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 옵션 전체 ```jsx ``` ### 2.4 완성 코드 — AnimatePresence (진입/퇴장) ```jsx '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 ( <> {/* mode="wait" = 나가는 요소가 완전히 사라진 뒤 들어오는 요소 시작 */} {open && ( <> setOpen(false)} />

드로어 내용

)}
); } ``` ```css .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의 킬러 기능) ```jsx '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 (
{TABS.map((tab) => ( ))}
); } export function ReorderableList({ items, onRemove }) { return ( ); } ``` ```css .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 시차 ```jsx '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 (

제목

); } return (

제목

); } ``` ### 2.7 React Server Components와의 공존 Motion 컴포넌트는 **클라이언트 컴포넌트**다. 하지만 서버 컴포넌트 트리 안에서 쓰는 두 가지 방법이 있다. ```jsx // 방법 1: motion/react-client — 서버 컴포넌트 파일에서 직접 사용 가능 // (내부적으로 "use client" 경계가 이미 설정된 re-export) import * as motion from 'motion/react-client'; export default function Page() { // 서버 컴포넌트 — "use client" 없음 return (

서버에서 렌더된 콘텐츠

); } ``` ```jsx // 방법 2: 작은 클라이언트 래퍼를 만들고 children을 서버에서 넘긴다 — 번들에 유리 'use client'; import { motion } from 'motion/react'; export function Reveal({ children, delay = 0 }) { return ( {children} ); } ``` ```jsx // 서버 컴포넌트에서 import { Reveal } from './reveal'; export default async function Page() { const posts = await getPosts(); // 서버에서 데이터 페치 return ( <> {posts.map((p, i) => (
{p.title}
{/* 이 부분은 서버 컴포넌트 */}
))} ); } ``` > **SSR 초기 상태 주의**: `initial={{ opacity: 0 }}`는 서버 HTML에도 반영된다. > JS가 실패하거나 늦게 로드되면 **콘텐츠가 영원히 보이지 않는다.** > 중요한 콘텐츠에는 `initial={false}`를 쓰거나, CSS `@supports`/`.no-js` 폴백을 둔다. ```css /* JS 비활성/실패 시 안전망 */ html.no-js [style*="opacity: 0"] { opacity: 1 !important; transform: none !important; } ``` ### 2.8 번들 최적화 ```jsx // app/providers.tsx 'use client'; import { LazyMotion, domAnimation, MotionConfig } from 'motion/react'; export function MotionProvider({ children }) { return ( {/* strict: motion.div 사용 시 에러를 던져 m.div만 쓰도록 강제 → 번들 보호 */} {children} ); } ``` ```jsx // 컴포넌트에서는 m을 쓴다 import * as m from 'motion/react-m'; export function Card() { return ; } ``` ```jsx // 드래그/레이아웃이 필요한 페이지에서만 domMax를 지연 로드 const loadMax = () => import('motion/react').then((m) => m.domMax); {children} ``` --- ## 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. `duration`은 **1.0~1.2 이하**. 그 이상은 "느리다"가 된다 3. `syncTouch: false` (기본값). 터치 기기에서 네이티브 스크롤을 건드리지 않는다 4. 모달/드로어가 열릴 때 `lenis.stop()` 5. 스크롤 가능한 내부 요소(코드 블록, 지도)에 `data-lenis-prevent` 6. 키보드 스크롤(Space, PageUp/Down, Home/End)이 정상 동작하는지 실제로 테스트 ### 3.2 완성 코드 — Lenis + GSAP ScrollTrigger ```bash npm i lenis@1.3.26 gsap@3.15.0 ``` ```js 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' }); } } ``` ```html
…긴 코드…
``` ### 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 ```jsx '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 ( {children} ); } 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** | `` | — | 폴백 내장, reduced-motion 자동 대응 | | **Next.js App Router** | `document.startViewTransition` 수동 래핑 | `next-view-transitions` 패키지 | React ``은 아직 `unstable_` | | **React Router / TanStack** | Motion `AnimatePresence mode="wait"` | View Transitions | 라우터가 exit를 기다려줘야 함 | | **Nuxt / Vue Router** | 내장 `` + View Transitions | — | Nuxt는 `experimental.viewTransition` | ### 4.2 Astro — 완성 코드 ```astro --- // src/layouts/Base.astro import { ClientRouter, fade } from 'astro:transitions'; ---
``` ```astro --- // src/pages/work/index.astro — 썸네일에 이름 부여 const items = await getWorkItems(); --- {items.map((item) => ( {item.title} ))} ``` ```astro --- // src/pages/work/[slug].astro — 같은 이름으로 받는다 const { slug } = Astro.params; const item = await getWorkItem(slug); --- {item.title} ``` ```astro --- 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)' }, }, }; ---
``` ```html ``` > Astro의 ``는 `prefers-reduced-motion`을 **자동으로 존중**해 모든 애니메이션을 > 비활성화한다. 별도 처리가 필요 없다. 또한 페이지 ``을 스크린리더에 자동 안내한다. ### 4.3 Next.js App Router — 완성 코드 ```tsx // 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} /> ); } ``` ```css /* 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 기준 ```js // next.config.js module.exports = { experimental: { viewTransition: true } }; ``` ```jsx 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`로 페이지 전환 (라우터 무관) ```jsx '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()` | ```js // 전환 + 스무스 스크롤 통합 처리 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로 타임라인 없는 명령형 애니메이션이 필요할 때. ```js 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 (스크롤 연동, 컴포지터 실행)** ```js 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 (고정 캔버스, 대부분의 경우 이걸로 충분) ```html <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> ``` ```css #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; } ``` ```js 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 (프록시 요소 픽셀 정합) ```html <div class="proxy" data-model="chair" style="aspect-ratio: 1;"></div> ``` ```js /** * 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` 하나로 통일**한다.