);
}
```
```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) => (
))}
```
```astro
---
// src/pages/work/[slug].astro — 같은 이름으로 받는다
const { slug } = Astro.params;
const item = await getWorkItem(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)' },
},
};
---
```
```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) {
const router = useRouter();
return (
{
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 `` (실험적)** — 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 {children};
}
```
> `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 (
window.scrollTo({ top: 0, behavior: 'instant' })}
>
{children}
);
}
```
> **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
Chapter 1
Chapter 2
Chapter 3
```
```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
```
```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` 하나로 통일**한다.