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

1797 lines
60 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
<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>
```
```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
<!-- 최선: 줄/단어 단위까지만 쪼갠다 -->
<h1 class="headline">여기 텍스트</h1>
<!-- 문자 단위가 꼭 필요하면: 원문을 별도 요소로 남기고 시각 요소는 숨긴다 -->
<h1 class="headline-wrap">
<span class="sr-only">여기 텍스트</span>
<span class="headline" aria-hidden="true">여기 텍스트</span>
</h1>
```
```css
.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 — 레이아웃 변화 애니메이션
```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 (
<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) 표준 패턴 — 프레임워크 무관**
```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
<body>
<div id="smooth-wrapper">
<div id="smooth-content">
<!-- 모든 콘텐츠 -->
</div>
</div>
<!-- position: fixed 요소는 wrapper 밖에 둔다 (transform이 containing block을 만들기 때문) -->
</body>
```
```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
<!-- 시차 효과: 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 패키지 구조와 번들 크기
```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
<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 (진입/퇴장)
```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 (
<>
<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>
</>
);
}
```
```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 (
<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>
);
}
```
```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 (
<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 컴포넌트는 **클라이언트 컴포넌트**다. 하지만 서버 컴포넌트 트리 안에서 쓰는 두 가지 방법이 있다.
```jsx
// 방법 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>
);
}
```
```jsx
// 방법 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>
);
}
```
```jsx
// 서버 컴포넌트에서
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` 폴백을 둔다.
```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 (
<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>
);
}
```
```jsx
// 컴포넌트에서는 m을 쓴다
import * as m from 'motion/react-m';
export function Card() {
return <m.div whileHover={{ y: -6 }} />;
}
```
```jsx
// 드래그/레이아웃이 필요한 페이지에서만 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. `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
<!-- 내부 스크롤 영역은 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
```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 (
<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 — 완성 코드
```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>
```
```astro
---
// 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>
))}
```
```astro
---
// 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}`} />
```
```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)' },
},
};
---
<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>
```
```html
<!-- 라이프사이클 이벤트: 전환 후 스크립트 재실행 -->
<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 — 완성 코드
```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` 하나로 통일**한다.