# motion — 움직임과 인터랙션

4-4에서 읽는다. **레이아웃·재질·입체가 끝난 뒤에 온다.** 마지막인 이유는 앞의 셋이 완성돼야 무엇이 움직여야 하는지 알 수 있기 때문이다.

## 이 문서를 읽는 법

| 상황 | 읽을 곳 |
|---|---|
| **모션을 넣을지 아직 안 정했다** | **§0만.** 절반은 여기서 "안 넣는다"로 끝나고 그게 정답이다 |
| duration·이징만 고르면 된다 | §1 결정 표. 여기서 끝내라 |
| 하고 싶은 게 게이트 #8에 걸린다 | §2 우회표 |
| 스크롤에 뭔가 물려야 한다 | §3 코드 B·C — 게이트 #10의 정답이 여기 있다 |
| 모달·페이지 전환 / 텍스트·숫자 연출 | §3 코드 D·E / F·G |
| 라이브러리를 깔지 말지 | §4 |
| 감사 직전 | §5 체크리스트 |

---

## 0. "넣지 않는다"를 먼저 통과시켜라

애니메이션을 쓰기 전에 **아래 넷 중 어디에 해당하는지 한 문장으로** 답해라. 못 답하면 넣지 않는다.

| 역할 | 답해야 할 질문 | 예 |
|---|---|---|
| **인과** | "이게 왜 여기 나타났나?" | 누른 버튼 자리에서 시트가 자라남 |
| **연속성** | "이게 어디서 와서 어디로 갔나?" | 썸네일 → 상세 이미지 |
| **피드백** | "내 입력이 접수됐나?" | 프레스, 토글, 검증 실패 |
| **주의** | "지금 어디를 봐야 하나?" | 오류 필드로의 이동 |

다섯 번째 **"멋있어서"는 이유가 아니다.** 예외는 브랜드 표현이 브리프의 명시적 요구일 때뿐이고, 그때도 (a) 사용자가 스크롤·호버로 통제하거나 (b) 1회성이며 (c) `prefers-reduced-motion`에서 완전히 사라져야 한다.

**넣지 말아야 할 곳**: 고빈도 반복 작업(폼·표·필터) · 오류 복구 경로 · 결과가 이미 예측되는 전환(탭) · **첫 화면**(콘텐츠는 즉시 읽혀야 한다) · 숫자가 계속 바뀌는 곳.

### 즉시 실격 — 하나라도 있으면 고친다

| 징후 | 처방 |
|---|---|
| 애니메이션 때문에 콘텐츠가 **읽히기까지 지연**됨 | 첫 화면은 모션 없이 즉시 표시. 리빌은 스크롤 이후 |
| 스크롤 리빌이 위아래로 오갈 때 **매번 재생** | `both` / `once` / `unobserve()` |
| 애니메이션 중 **레이아웃 시프트** | 게이트 #8. `transform`/`opacity`만 |
| **동시에 3개 이상** 독립 애니메이션 | 시선이 분산된다. 순차화하거나 통합 |
| 400ms 넘게 **사용자를 막는** 전환, 인터럽트 불가 | 줄이고, 애니메이션 중에도 입력을 받아라 |
| 무한 반복되는 **큰 면적** 움직임 | 전정기관 자극. 정지 수단을 주거나 제거 |
| 이징이 전부 `ease`·`linear`거나, 진입과 퇴장 duration이 같음 | 방향을 구분하지 않았다. §1로 |

---

## 1. 결정 표 — 이 상황에는 이 duration·이징

**토큰은 3단계 `tokens.md` §4에서 이미 정해졌다. 새 duration을 만들지 마라.** 표에 없으면 가장 가까운 행을 쓴다.

| 상황 | duration | 이징 | 거리 |
|---|---|---|---|
| **마이크로 인터랙션** — 호버·포커스·프레스·토글·체크 | `--dur-instant` (100ms) | `--ease-out` | `--shift-sm` 이하 |
| **작은 요소 등장** — 툴팁·드롭다운·토스트·칩 | `--dur-quick` (200ms) | `--ease-out` | `--shift-sm` |
| **작은 요소 퇴장** | `calc(var(--dur-quick) * 0.65)` | `--ease-in` | 동일 |
| **표면 등장** — 모달·시트·패널·아코디언 | `--dur-normal` (350ms) | `--ease-out` | `--shift-lg` |
| **표면 퇴장** | `calc(var(--dur-normal) * 0.65)` | `--ease-in` | 동일 |
| **위치 이동** — 화면 안 A→B, 탭 슬라이드, 캐러셀 | `--dur-normal` | `--ease-soft` | — |
| **페이지·뷰 전환** — 라우트 이동, shared element | `--dur-slow` (600ms) | `--ease-soft` | — |
| **스크롤 리빌** — 섹션 등장, 1회성 | `--dur-slow` | `--ease-out` | `--shift-lg` |
| **스크롤에 직접 물린 것** — 진행 바·시차·스크럽 | (없음) | `linear` | — |
| **앰비언트 루프** — 배경 드리프트, 마퀴 | 8~20s | `linear` | — |

**네 줄 규칙.** 이것만 지켜도 대부분 맞는다.

- 화면 **안으로 들어오는** 것 → `--ease-out`. 도착감. 급정거하면 충돌처럼 보인다
- 화면 **밖으로 나가는** 것 → `--ease-in`, duration은 진입의 0.65배. 나가는 건 볼 이유가 없다
- 화면 **안에서 이동하는** 것 → `--ease-soft`
- **스크롤에 물린** 것 → `linear`. 스크롤 자체가 이미 이징이다. 곡선을 덧씌우면 이중 이징이 된다

**모션에만 필요한 토큰 세 줄.** 3단계 토큰 블록에 이것만 더한다. **duration은 더하지 마라 — `tokens.md` §4의 넷이 전부다.**

```css
:root { --stagger: 60ms; --shift-sm: 8px; --shift-lg: 24px; }
```

**stagger 상한**: `(항목수 − 1) × --stagger + duration ≤ 800ms`. 넘으면 `--stagger`를 줄이거나 첫 화면 항목에만 건다. 12개를 넘으면 stagger를 쓰지 않는다. 방향은 읽기 방향(좌→우, 상→하)과 일치시킨다.

---

## 2. 하드 게이트 #8을 지키면서 하고 싶은 걸 다 하는 법

**`transform`/`opacity` 외의 속성을 애니메이션하면 실패다. 오버라이드 없다.** 그런데 거의 모든 요구는 우회할 수 있다.

| 하고 싶은 것 | 하지 마라 | 대신 |
|---|---|---|
| 배경색이 바뀐다 | `transition: background-color` | 목표 색을 칠한 `::before`를 깔고 그 **`opacity`** 전환 |
| 그림자가 짙어진다 | `transition: box-shadow` | 짙은 그림자를 가진 `::after`를 미리 만들고 **`opacity`** 전환. 그림자 재계산이 사라져 더 빠르다 |
| 테두리가 나타난다 | `transition: border-color` | `::after`에 `border`를 두고 **`opacity`** 전환. 레이아웃도 안 흔들린다 |
| 글자색이 바뀐다 | `transition: color` | 전환하지 말고 즉시 바꿔라. 100ms짜리 색 변화는 아무도 못 본다 |
| 진행 바가 찬다 | `transition: width` | `transform-origin: left` + **`scaleX()`** |
| 이미지가 흐려진다 | `transition: filter` | 선명본과 미리 블러된 사본을 겹치고 **`opacity`** 크로스페이드 |
| 그라디언트가 회전한다 | `@property`로 각도 애니메이션 | 원뿔 그라디언트 레이어를 **`rotate`** |
| 마스크가 열린다 | `transition: clip-path` | `overflow: hidden` 래퍼 안에서 자식을 **`translate`** |
| 숫자가 올라간다 | `@property --count` | 자릿수 스트립을 **`translate`** (§3 코드 G) |
| 패널 높이가 자란다 | `transition: height`·`max-height` | 아래 |

**높이.** `transition: height`는 매 프레임 레이아웃을 돌리고 형제를 밀어 CLS를 만든다. 순서대로 시도해라.

1. **구조를 바꾼다.** 정말 인라인으로 자라야 하는가? 모달·팝오버로 만들면 높이 문제가 사라지고 스케일+페이드로 끝난다
2. **높이는 즉시 확정하고 내용만 전환한다.** 래퍼를 `display: grid; grid-template-rows: 0fr` ↔ `1fr`로 만들되 **`grid-template-rows`에 transition을 걸지 않는다.** 자식의 `opacity`·`translate`만 전환하면 게이트를 완전히 통과한다
3. `grid-template-rows`에 transition을 거는 관용구가 널리 쓰이지만 **그건 여전히 레이아웃 애니메이션이고 게이트 #8 grep에 걸린다.** `max-height`보다 정확할 뿐 합성 가능하지는 않다. 굳이 쓰겠다면 `design.md`에 예외로 적어라

**오해 방지 — 게이트 #8이 허용하는 것**

- `translate` / `rotate` / `scale` **개별 속성은 transform 계열이다. 통과다.** 축약형보다 낫다(속성끼리 안 덮어쓴다)
- `display`·`overlay`를 `transition-behavior: allow-discrete`로 거는 것은 **통과다.** 보간되지 않고 전환 종료까지 값을 유지시킬 뿐이라 프레임당 계산이 없다. 예외는 이 둘뿐이다
- View Transitions의 기본 애니메이션은 브라우저가 만든다. **저자 키프레임은 `opacity`와 transform 계열만** 쓴다

---

## 3. CSS 네이티브 우선 — 라이브러리를 끌어오기 전에

| CSS로 충분한 것 (라이브러리 금지) | CSS로 안 되는 것 (§4로) |
|---|---|
| 호버·포커스·프레스 상태 전환 | 속도를 이어받는 인터럽트(드래그 던지기) |
| 모달·팝오버·툴팁 진입/퇴장 (`@starting-style`) | 복잡한 타임라인 시퀀싱 (A 끝나고 B, B 중간에 C) |
| 스크롤 리빌·진행 바·시차·스티키 헤더 | 스크롤 **핀 고정 + 다단계 시퀀스** |
| 페이지 전환 (View Transitions) | 레이아웃 변화 FLIP (그리드 → 리스트) |
| 무한 루프, `linear()` 스프링 근사 | 텍스트 자동 분해, SVG 패스 모핑, 포인터 추종 |

**지원 현황(2026-08).** `prefers-reduced-motion`·`linear()`는 Baseline widely — 무조건 쓴다. `@starting-style`·`transition-behavior`(2024-08), same-document View Transitions(2025-10)는 Baseline newly — 폴백 두고 쓴다. **scroll-driven animations와 cross-document View Transitions는 Firefox 미지원이라 `@supports` 가드가 필수다.**

> **가장 흔한 사고**: 미지원 브라우저에서 `opacity: 0`이 남아 콘텐츠가 영영 안 보이는 것. **초기 상태를 `@supports` 블록 *안에* 넣어라.** 밖에 두면 Firefox에서 백지가 된다.

### 코드 A — 버튼·링크 마이크로 인터랙션

> 언제: 모든 인터랙티브 요소. 선택이 아니라 기본이다. 색과 그림자를 전부 `opacity`로 바꾼 것이 요점이다.

```css
.btn {
  position: relative; isolation: isolate;
  background: var(--accent); color: var(--accent-ink);
  border: 0; padding: 0.75rem 1.5rem; border-radius: var(--radius-md);
  transition: translate var(--dur-instant) var(--ease-out),
              scale     var(--dur-instant) var(--ease-out);
}
/* 밝기도 그림자도 색이 아니라 레이어의 opacity로 바꾼다 */
.btn::before, .btn::after {
  content: ''; position: absolute; inset: 0; border-radius: inherit; opacity: 0;
  transition: opacity var(--dur-instant) var(--ease-out);
}
.btn::before { z-index: -1; background: var(--ink); }                    /* 밝기 */
.btn::after  { z-index: -2; box-shadow: 0 8px 24px rgb(0 0 0 / 0.18); }  /* 그림자 */

.btn:hover, .btn:focus-visible { translate: 0 -2px; }
.btn:hover::before, .btn:focus-visible::before { opacity: 0.12; }
.btn:hover::after,  .btn:focus-visible::after  { opacity: 1; }
.btn:active { translate: 0 0; scale: 0.98; }   /* squash는 2~4%까지만 */

/* 포커스 링은 절대 애니메이션하지 않는다. 즉시 보여야 한다 */
.btn:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; }

/* 링크 밑줄: width가 아니라 scaleX */
.link { position: relative; text-decoration: none; color: inherit; }
.link::after {
  content: ''; position: absolute; inset-inline: 0; bottom: -2px;
  height: 1px; background: currentColor;
  scale: 0 1; transform-origin: left;
  transition: scale var(--dur-quick) var(--ease-out);
}
.link:hover::after, .link:focus-visible::after { scale: 1 1; }
```

### 코드 B — 스크롤 진입 리빌 (scroll-driven animation)

> 언제: **기본값.** 리빌·진행 바·시차·헤더 축소. 컴포지터 스레드에서 돌아 메인 스레드가 막혀도 끊기지 않는다. 게이트 #10의 정답이 이것이다.

```css
/* 초기 상태를 @supports 안에 둔다 — 미지원 브라우저에서는 그냥 보인다 */
@supports (animation-timeline: view()) {
  .reveal {
    animation: reveal-in linear both;        /* both 필수. 없으면 범위 밖에서 깜빡인다 */
    animation-timeline: view();
    animation-range: entry 15% cover 35%;
    animation-delay: calc(var(--i, 0) * var(--stagger));
    /* animation-duration은 auto가 기본. 초를 넣으면 무시되거나 오작동한다 */
  }
  @keyframes reveal-in {
    from { opacity: 0; translate: 0 var(--shift-lg); }
    to   { opacity: 1; translate: 0 0; }
  }
}
/* 읽기 진행 바 — width가 아니라 scaleX (게이트 #8) */
@supports (animation-timeline: scroll()) {
  .progress {
    position: fixed; inset-block-start: 0; inset-inline: 0; height: 3px;
    background: var(--accent); transform-origin: left; scale: 0 1;
    animation: progress-fill linear both;
    animation-timeline: scroll(root block);
  }
  @keyframes progress-fill { to { scale: 1 1; } }
}
@media (prefers-reduced-motion: reduce) {
  .reveal   { animation: none; opacity: 1; translate: 0 0; }
  .progress { animation: none; scale: 1 1; }   /* 진행 표시는 정보다. 죽이지 않는다 */
}
```

**`animation-range` 이름**: `entry`(닿기 시작 → 완전히 들어옴) · `cover`(닿기 시작 → 완전히 벗어남) · `exit`(나가기 시작 → 벗어남) · `contain`(요소가 뷰포트보다 작을 때 완전히 담긴 구간).

### 코드 C — 스크롤 진입 리빌 (IntersectionObserver)

> 언제: Firefox 지원이 **요구사항**일 때, 또는 진입 시점에 **DOM을 바꿔야** 할 때(카운트업 시작, 지연 로드, 3D 씬 기동). CSS는 스타일만 바꾼다.
> `window.addEventListener('scroll')`은 **게이트 #10**이다. 절대 쓰지 마라.

```css
/* .js가 붙기 전에는 그냥 보인다 — JS가 실패해도 콘텐츠가 사라지지 않는다 */
.js .reveal-js {
  opacity: 0; translate: 0 var(--shift-lg);
  transition: opacity   var(--dur-slow) var(--ease-out),
              translate var(--dur-slow) var(--ease-out);
  transition-delay: calc(var(--i, 0) * var(--stagger));
}
.js .reveal-js[data-shown] { opacity: 1; translate: 0 0; }
/* reduced-motion 대응은 코드 H가 --shift-lg·--stagger를 0으로 만들어 전역 처리한다 */
```

```js
document.documentElement.classList.add('js');
const targets = document.querySelectorAll('.reveal-js');

if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
  targets.forEach((el) => { el.dataset.shown = ''; });
} else {
  const io = new IntersectionObserver((entries) => {
    for (const entry of entries) {
      if (!entry.isIntersecting) continue;
      entry.target.dataset.shown = '';
      io.unobserve(entry.target);          // 1회성. 재생 반복은 즉시 실격이다
    }
  }, { rootMargin: '0px 0px -15% 0px', threshold: 0 });
  targets.forEach((el) => io.observe(el));
}
```

### 코드 D — 모달·팝오버 진입/퇴장 (`@starting-style`)

> 언제: `display: none`에서 나타나거나 top layer에 올라가는 모든 것. 라이브러리가 필요 없다.

```html
<button popovertarget="tip">도움말</button>
<div id="tip" popover="auto" role="tooltip" class="tip">계정 복구에만 사용됩니다.</div>
```

```css
.tip {
  margin: 0; padding: 0.75rem 1rem; border: 0; border-radius: var(--radius-md);
  background: var(--surface-raised); color: var(--ink); max-width: 18rem;
  /* 닫힌 상태 = 퇴장의 종착점 */
  opacity: 0; translate: 0 var(--shift-sm); scale: 0.96;
  transition: opacity   calc(var(--dur-quick) * 0.65) var(--ease-in),
              translate calc(var(--dur-quick) * 0.65) var(--ease-in),
              scale     calc(var(--dur-quick) * 0.65) var(--ease-in),
              display   calc(var(--dur-quick) * 0.65) allow-discrete,
              overlay   calc(var(--dur-quick) * 0.65) allow-discrete;
}
.tip:popover-open {
  opacity: 1; translate: 0 0; scale: 1;
  transition-duration: var(--dur-quick);
  transition-timing-function: var(--ease-out);
}
/* @starting-style 블록은 반드시 원본 규칙 *뒤에* 온다. 명시도가 같아 순서로 승부난다 */
@starting-style {
  .tip:popover-open { opacity: 0; translate: 0 var(--shift-sm); scale: 0.96; }
}
@media (prefers-reduced-motion: reduce) { .tip { scale: 1; } }  /* 이동은 --shift-sm이 0이 되며 자동 처리 */
```

**팝오버는 클릭한 곳에서 나와야 한다.** 화면 정중앙에서 페이드인하는 팝오버는 "어디서 왔는지"를 버리는 것이다. `transform-origin`을 트리거 위치로 잡아라.

### 코드 E — 페이지·뷰 전환 (View Transitions API)

> 언제: 라우트 이동, 리스트 필터링, 썸네일 → 상세. 미지원 브라우저에서는 즉시 바뀐다(점진 향상).

```js
/** DOM을 바꾸는 함수를 감싼다. 지원·모션감소 판정을 여기 한 곳에 모은다 */
function transition(updateDOM) {
  const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
  if (!document.startViewTransition || reduced) {
    updateDOM();
    return { finished: Promise.resolve() };
  }
  return document.startViewTransition(updateDOM);
}

// shared element: 목록과 상세에 같은 이름을 준다. 이름이 중복되면 즉시 에러다
document.querySelectorAll('.card').forEach((card) => {
  card.style.viewTransitionName = `card-${card.dataset.id}`;
});

// 사용: 리스트 필터링, 라우트 교체 등 DOM을 바꾸는 모든 지점
filterBtn.addEventListener('click', () => transition(() => renderList(filterBtn.dataset.filter)));
```

```css
/* 저자 키프레임은 opacity와 transform 계열만 쓴다. 위치·크기 보간은 브라우저가 한다 */
::view-transition-group(*) { animation-duration: var(--dur-slow); animation-timing-function: var(--ease-soft); }
::view-transition-old(root) { animation: calc(var(--dur-slow) * 0.65) var(--ease-in) both vt-out; }
::view-transition-new(root) { animation: var(--dur-slow) var(--ease-out) both vt-in; }
@keyframes vt-out { to   { opacity: 0; translate: 0 calc(var(--shift-lg) * -1); } }
@keyframes vt-in  { from { opacity: 0; translate: 0 var(--shift-lg); } }

@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*) { animation-name: none !important; }   /* 위치·크기 보간 제거 */
  ::view-transition-old(*), ::view-transition-new(*) {              /* 크로스페이드만 남긴다 */
    animation-duration: var(--dur-instant) !important; animation-timing-function: linear !important;
  }
}
```

문서 간 전환(MPA)은 양쪽 문서에 `@view-transition { navigation: auto; }`를 선언하면 켜진다. Firefox 미지원이라 **점진 향상 전용**이다.

### 코드 F — 텍스트 등장 (줄 단위 스플릿·스태거)

> 언제: 히어로 헤드라인 하나. **페이지에 한 번만.** 두 곳에서 텍스트가 날아오면 둘 다 죽는다.

**규칙 셋. 어기면 SEO와 스크린리더가 동시에 깨진다.**
1. **원문 텍스트를 DOM에 그대로 남긴다.** 크롤러가 읽는 것은 마크업이지 렌더 결과가 아니다
2. **글자 단위(`chars`) 분해는 쓰지 않는다.** 스크린리더가 "ㄱ-ㅏ-ㄴ-ㅏ"로 읽는다. 줄·단어까지만
3. `<div>`에 `aria-label`을 붙이는 방식은 **동작하지 않는다.** generic role은 author naming이 금지돼 있다

```html
<!-- 줄바꿈을 직접 마크업에 넣는다. 텍스트는 온전히 DOM에 있다 -->
<h1 class="split">
  <span class="split__line" style="--i:0"><span>여백이 말을 대신하는</span></span>
  <span class="split__line" style="--i:1"><span>스튜디오</span></span>
</h1>
```

```css
.split__line { display: block; overflow: hidden; }  /* 마스크. clip-path를 애니메이션하지 않는다 */
.split__line > span { display: block; translate: 0 0; }

@supports (animation-timeline: view()) {
  .split__line > span {
    animation: line-up linear both;
    animation-timeline: view();
    animation-range: entry 10% entry 70%;
    animation-delay: calc(var(--i) * var(--stagger));
  }
  @keyframes line-up {
    from { translate: 0 110%; opacity: 0; }
    to   { translate: 0 0;    opacity: 1; }
  }
}
@media (prefers-reduced-motion: reduce) { .split__line > span { animation: none; opacity: 1; } }
```

**자동 분해가 꼭 필요하면** GSAP SplitText를 쓴다(§4, 무료). `type: 'lines,words'`까지만, `mask: 'lines'`, `autoSplit: true`. 글자 단위가 브리프의 요구라면 원문을 `.sr-only` 사본으로 남기고 시각 요소에 `aria-hidden="true"`를 건다.

### 코드 G — 숫자 카운트업 (자릿수 스트립, transform만)

> 언제: 실적 숫자 하나. **사용자가 준 진짜 숫자에만 쓴다** — 지어낸 숫자는 게이트 #11이다.
> `@property --count` 방식은 커스텀 속성을 매 프레임 바꿔 게이트 #8에 걸린다. 자릿수를 굴려라.

```html
<p class="stat">
  <span class="sr-only">1284</span>
  <span class="odo" data-value="1284" aria-hidden="true"></span>
  <span>개의 프로젝트</span>
</p>
```

```css
.odo { display: inline-flex; font-variant-numeric: tabular-nums; }
.odo__col { display: block; height: 1em; overflow: hidden; line-height: 1; }
.odo__strip {
  display: block; translate: 0 0;
  transition: translate var(--dur-slow) var(--ease-out);
  transition-delay: calc(var(--i) * var(--stagger));
}
.odo__strip > span { display: block; height: 1em; }
.odo[data-run] .odo__strip { translate: 0 calc(var(--d) * -1em); }
.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px;
           overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) { .odo__strip { transition: none; } }
```

```js
const DIGITS = '0123456789'.split('').map((n) => `<span>${n}</span>`).join('');

document.querySelectorAll('.odo').forEach((odo) => {
  odo.innerHTML = odo.dataset.value.split('').map((d, i) =>
    `<span class="odo__col"><span class="odo__strip" style="--d:${d};--i:${i}">${DIGITS}</span></span>`
  ).join('');

  const io = new IntersectionObserver(([entry]) => {
    if (!entry.isIntersecting) return;
    odo.dataset.run = '';
    io.disconnect();
  }, { threshold: 0.6 });
  io.observe(odo);
});
```

### 코드 H — `prefers-reduced-motion` 대응

> 언제: 전부. **접근성은 협상하지 않는다.** 이것 없이 5단계를 통과할 수 없다.

**전부 끄는 것은 오답이다.** 셋으로 나눠라.

| 죽인다 | 남긴다 | 대체한다 |
|---|---|---|
| 위치 이동, 시차, 스크롤 스크럽 | `opacity` 크로스페이드 | 슬라이드 → 크로스페이드 |
| 회전·확대, 오버슛·바운스 | 호버·포커스 상태 변화 | 시차 → 정지 배경 |
| 자동재생 루프, 마퀴 | 진행 표시·로딩(정보다) | 스크롤 리빌 → 즉시 표시 |
| stagger(순차 지연) | 포커스 링 | 카운트업 → 최종값 즉시 |

WCAG 2.3.3은 **인터랙션으로 촉발된 모션 애니메이션을 끌 수 있어야** 한다고 요구한다. 색·투명도·블러만 바뀌는 것은 해당하지 않는다 — **위치·크기·형태가 바뀌는 것**이 대상이다. 자동 재생되는 것은 별도로 2.2.2(5초 넘게 움직이면 정지 수단 필요)에 걸린다. 근거는 취향이 아니라 **어지럼·구역질·두통이라는 실제 신체 반응**이다.

```css
@media (prefers-reduced-motion: reduce) {
  :root {
    /* 이동 거리를 0으로 → 모든 슬라이드가 자동으로 크로스페이드가 된다. 컴포넌트 코드를
       한 줄도 안 고치고, animationend/transitionend 의존 코드도 안 깨진다 */
    --shift-sm: 0px; --shift-lg: 0px; --stagger: 0ms;
    /* 페이드는 남긴다. 0으로 만들면 상태 변화가 뚝뚝 끊긴다 */
    --dur-normal: var(--dur-instant); --dur-slow: var(--dur-instant);
    --ease-out: ease-out; --ease-in: ease-out; --ease-soft: ease-out;  /* 오버슛 제거 */
  }
  .marquee, .ambient, [data-loop], .parallax { animation: none !important; }
}
```

JS 쪽 판정도 한 곳에 모은다. 설정이 도중에 바뀌면 반영한다: `matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change', () => location.reload())`.

---

## 패럴랙스는 섹션마다 다른 말을 해야 한다

같은 패럴랙스를 모든 섹션에 똑같이 걸면 그건 문법이 아니라 벽지다.
**그 섹션이 주장하는 것이 무엇인지 먼저 말하고, 그 주장을 움직임으로 옮겨라.**

실측 사례 — designpaca 소개 페이지의 섹션별 배정:

| 섹션 | 주장 | 움직임 | 왜 |
|---|---|---|---|
| 히어로 | "결정하지 않으면 기본값이 나온다" | 글자 ↑ / 배경 ↓ (**반대 방향**) | 결정이 떠오르고 기본값이 가라앉는다. 같은 방향이면 그냥 느린 스크롤로 읽힌다 |
| 파이프라인 | 단계를 밟으면 결과가 나온다 | 단계명 **고정**, 산출물만 지연 | 옆의 것이 고정이어야 "저것 때문에 이것"이 보인다. 둘 다 움직이면 인과가 사라진다 |
| 사례 | 서로 다른 두 결과물 | 두 판이 다른 속도로 어긋남 | 같은 스킬에서 다른 것이 나온다 |
| 재료 | 표면은 한 겹이 아니다 | 타일이 서로 다른 깊이 | 주장 자체가 레이어다 |
| 프리플라이트 | 시간순 기록 | 뒤 항목일수록 더 늦게 따라옴 | 로그는 흐른다 |
| 수치 | 공개한 값은 검증 가능하다 | **없음** | 숫자가 흔들리면 값도 흔들려 보인다. 계기판의 바늘은 떨지 않는다 |

**마지막 줄이 가장 중요하다.** 움직이지 않는 것도 결정이다.
안 움직이는 섹션이 하나도 없으면 움직임에 의미가 없다는 뜻이고,
`design.md` 에 "여기는 의도적으로 정지"라고 적어두지 않으면 다음 사람이 "빠뜨린 것"으로 보고 채운다.

### 패럴랙스의 원형은 "고정"이다

**배경이 뷰포트에 멈춰 있고 콘텐츠만 그 위를 지나갈 때 원경이 된다.**
느리게 같이 움직이는 것으로는 그 인상이 안 나온다 — 속도만 다를 뿐 방향이 같기 때문이다.
"패럴랙스를 넣었는데 티가 안 난다"의 절반은 이것이다.

**`background-attachment: fixed` 는 쓰지 마라.** iOS Safari 가 GPU 메모리 때문에 그것을
throttle 해서 스크롤 중 배경이 튀거나 리셋된다.

대신 **부모를 `position: fixed` 의 컨테이닝 블록으로 만든다**:

```css
.section { position: relative; clip-path: inset(0); }  /* 컨테이닝 블록 + 클립 */
.section > .bg { position: fixed; inset: 0; z-index: -1; }
.section > .bg img { width: 100%; height: 100%; object-fit: cover; }
```

`clip-path` 는 두 가지를 동시에 한다 — 배경을 섹션 밖으로 못 나가게 자르고,
fixed 자식의 기준을 뷰포트가 아니라 **자기 자신**으로 잡아준다.
그래서 배경이 화면에 고정되면서도 그 섹션 안에서만 보인다.

**두 가지 대가가 있다. 알고 써라.**

1. **조상에 `transform`·`filter`·`mask` 를 추가하면 즉시 깨진다.** 그것들도 컨테이닝 블록을
   만들기 때문에 fixed 가 섹션 기준이 되어 고정이 사라진다. 위아래 페이드 마스크를 얹고 싶어도
   못 얹는다 — 경계는 clip 으로 딱 잘리는 것을 받아들여야 한다
2. `prefers-reduced-motion` 에서는 `position: absolute` 로 되돌려라. 고정 배경은
   스크롤 중 시각 부하가 크다

**전부 고정하지는 마라.** 가까이 있어야 할 재질(종이·질감)까지 원경으로 두면 거짓말이 된다.
원경은 `fixed`, 중경은 아래의 비율 이동, 근경은 콘텐츠와 함께.

### 이동량은 절대값이 아니라 컨테이너 대비 비율이다

**패럴랙스가 "안 보인다"의 대부분은 이동량이 모자란 것이다.**

공식은 하나다. **배경을 컨테이너보다 크게 만들고, 그 초과분 전체를 움직인다.**

```
이미지 높이 = 컨테이너 × (1 + depth)
이동량      = 컨테이너 × depth
          = 이미지 높이의 depth / (1 + depth)
```

`depth: 0.45` 면 이미지는 145%, 이동은 이미지 자기 높이의 **31%**.
섹션이 2000px 이면 **900px** 을 움직인다.

```css
.bg img {
  height: 145%;                    /* 1 + depth */
  animation: bg-par linear both;
  animation-timeline: view();
  animation-range: cover 0% cover 100%;
}
@keyframes bg-par {
  from { transform: translateY(0); }
  to   { transform: translateY(-31.03%); }   /* depth / (1 + depth) */
}
```

`translateY(%)` 는 **자기 크기 기준**이라 컨테이너 높이를 몰라도 정확히 맞는다.

**실측 사례**: 여유를 28% 잡아놓고 `±46px`(총 92px)만 움직였더니 스크롤 1800px 구간에서
이동 비율이 5% 였다. 아무도 시차를 느끼지 못했다. 같은 구간에서 **374px** 로 올리자 보였다.
CSS-Tricks 의 예제도 `background-position: bottom 0px → bottom -400px`, 즉 400px 이다.

> 참고: `background-position` 을 애니메이션하는 방법도 있다(CSS-Tricks). 다만 그러면
> `<img loading="lazy">` 의 지연 로딩과 반응형 `srcset` 을 잃는다. 무거운 사진이면 `<img>` 를
> 절대배치하고 `transform` 을 쓰는 쪽이 낫다.

### `overflow: hidden` 이 view() 타임라인을 죽인다

**이것 하나에 패럴랙스가 통째로 멈춘다. 그리고 조용히 멈춘다.**

`overflow: hidden` 은 **스크롤 컨테이너를 만든다.** 그러면 그 안에 있는 자식의
`animation-timeline: view()` 는 문서가 아니라 **그 상자**를 기준으로 잡힌다.
자식은 상자 안에서 위치가 변하지 않으므로 진행도가 고정되고, 애니메이션은
어중간한 한 프레임에서 굳는다.

- 콘솔 에러 없음. `animation-name`·`animation-timeline` computed 값도 전부 정상
- `getComputedStyle(el).transform` 을 두 스크롤 위치에서 재봐야 알 수 있다. **실측에서 이동 0px**

**잘라내야 한다면 `overflow: clip` 을 쓰거나 조상 섹션에서 처리해라.**
`clip` 은 스크롤 컨테이너를 만들지 않는다.

### 어두운 배경 위 광원 이미지는 opacity 로 누르지 마라

`opacity` 는 이미지 전체를 균일하게 죽인다 — 검정도, 빛도 같이. 그래서 어두운 광원 사진을
`opacity: 0.5` 로 깔면 **있는지조차 안 보인다.**

```css
.bg img { mix-blend-mode: screen; opacity: 0.9; }
```

`screen` 은 **검정을 투명하게 만들고 밝은 곳만 더한다.** 그래서 불투명도를 높게 유지해도
본문 대비를 해치지 않는다. 밝은 이미지(종이·질감)는 반대로 `normal` 이 맞다.

**생성형 이미지를 배경으로 쓸 때 특히 조심해라.** 프롬프트에 "본문 뒤에 깔리니 어둡게"를
너무 강하게 넣으면 안전한 이미지가 아니라 **없는 이미지**가 나온다.
대비는 합성 방식으로 풀고, 이미지 자체는 볼 수 있게 만들어라.

### sticky 캔버스의 진행도는 섹션 높이가 아니다

캔버스를 `position: sticky; top: 0; height: 100dvh` 로 붙여두고 섹션이 지나가게 하는 패턴에서,
진행도를 **섹션 높이로 나누면 안 된다.**

캔버스가 실제로 화면에 머무는 거리는 **(섹션 높이 − 뷰포트 높이)** 다.
섹션 높이를 쓰면 캔버스가 위로 사라진 뒤에도 진행이 남아 **끝에 영영 도달하지 못한다.**

```js
const travel = Math.max(section.getBoundingClientRect().height - innerHeight, 1);
const progress = clamp(-section.getBoundingClientRect().top / travel, 0, 1);
```

실측: 섹션 1493px · 뷰포트 900px 에서 섹션 높이로 나눴더니 6단계 중 **3단계에서 멈췄다.**
`height - innerHeight` 로 바꾸자 6단계를 다 돌았다.

그리고 진행도의 기준 요소는 **캔버스가 아니라 섹션**이다. sticky/fixed 캔버스는 rect 가
변하지 않으므로 자기 rect 로 재면 진행도가 고정된다.

### 배경도 한 섹션에 하나다

CSS 격자와 사진을 같이 깔았다가 걷어냈다. 두 배경이 서로 경쟁했고,
격자는 배경이라기보다 **표**로 읽혔다. 앞에서 "한 층은 고정"이라고 한 것과 같은 이야기다 —
층을 늘리는 것과 층이 싸우게 두는 것은 다르다.

### 패럴랙스에는 기준면이 필요하다

**배경이 단색이면 패럴랙스는 성립하지 않는다.** 앞의 것이 몇 px 어긋나 봐야
비교할 대상이 없어서 아무도 못 느낀다. 시차는 두 층 사이에서만 보인다.

그렇다고 배경 이미지를 사 오지 마라. **그 페이지가 이미 주장하는 것을 배경으로 그려라.**

| 페이지가 주장하는 것 | 배경으로 그릴 것 |
|---|---|
| 값이 토큰 체계에서 나온다 | **간격 눈금 격자**. 셀 크기를 실제 space 토큰으로 |
| 재질·표면이 주제다 | 광원 블롭. 섹션마다 자리를 옮긴다 |
| 데이터·계측이 주제다 | 눈금, 축, 등고선 |
| 문서·아카이브다 | 괘선, 여백선 |

격자를 그릴 때 두 가지를 지켜라.

1. **가장자리를 마스크로 지운다.** 화면 끝까지 격자가 가면 배경이 아니라 **표**가 된다
2. **배경이 가장 적게 움직인다.** 앞의 것보다 크게 움직이면 앞으로 튀어나와 보인다

```css
.bg-grid::before {
  content: ""; position: absolute; inset: -12% 0; z-index: -1;
  background-image:
    repeating-linear-gradient(to right,  var(--line-strong) 0 1px, transparent 1px var(--grid-cell)),
    repeating-linear-gradient(to bottom, var(--line-strong) 0 1px, transparent 1px var(--grid-cell));
  mask-image: radial-gradient(135% 95% at 50% 45%, black 22%, transparent 92%);
}
```

세 층이 되면 시차가 확실해진다 — 실측: 배경 격자 **+14.9px**, 산출물 **-14px**, 단계명 **0**.
**한 층은 반드시 고정이어야 한다.** 전부 움직이면 기준이 없어 그냥 흔들리는 페이지다.

### 구현은 scroll-driven animation 으로

```css
@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: view()) {
    .par-slow, .par-fast, .par-lag {
      animation: par linear both;
      animation-timeline: view();
      animation-range: cover 0% cover 100%;
    }
    @keyframes par {
      from { transform: translate3d(0, var(--par-from), 0); }
      to   { transform: translate3d(0, var(--par-to), 0); }
    }
  }
}
```

**두 가지를 지켜라.**

1. **`transform` 만 애니메이션한다.** `opacity` 를 걸면 범위 끝에 도달하지 못한 요소가
   영영 투명하게 남는다 — 지원 안 되는 브라우저에서는 제자리에 그대로 있을 뿐이지만,
   `opacity` 는 콘텐츠를 지운다. 실측에서 6단계 중 3개가 그렇게 사라졌다.
2. **페이지 최상단 요소는 `view()` 가 아니라 `scroll()` 을 써라.** 히어로는 처음부터 보이므로
   `entry` 구간이 이미 지나 있어 시작점이 확정되지 않는다.
   `animation-timeline: scroll(root block); animation-range: 0 92vh;`

새 클래스를 만들 때 **애니메이션 셀렉터에 추가하는 것을 잊지 마라.** 변수만 정의하면
아무 일도 일어나지 않고, 조용히 실패해서 검출도 안 된다.

---

### 읽기 간격과 스크롤 구간은 같은 손잡이가 아니다

sticky 캔버스의 진행도는 `섹션 높이 − 뷰포트` 다. 그래서 "스크롤 구간이 짧다" 는
문제를 만나면 **목록 항목의 간격을 벌려 섹션을 늘리고 싶어진다.** 실측에서 그렇게 했다.

- `--space-4`(24px): 섹션 1493px → sticky 이동 593px. 조금만 굴려도 여섯 단계를 다 지나침
- `--space-7`(96px): 게이트당 300px 확보. 대신 항목 사이가 **224px** 로 벌어져
  여섯 개가 한 목록으로 안 읽힘. 사용자 지적: "각 아이템별로 마진 공간이 너무 크다"

**하나의 값으로 두 가지를 맞추려 하면 둘 다 나빠진다.** 나눠라.

```css
.stop  { padding-block: var(--stop-pad); }   /* 읽기 리듬 — 눈으로 정한다 */
.after { padding-bottom: 62dvh; }            /* 스크롤 구간 — 계산으로 정한다 */
```

판정 기준: **게이트 하나당 200px 이상**. 휠 한 번이 100~150px 이므로
그 아래면 "조금만 스크롤해도 다 지나가는" 과민한 반응이 된다.

### 스크롤 구간이 모자라면 여백이 아니라 **진행도 공식**을 고쳐라

위 문제를 처음에는 목록 뒤에 여백을 넣어 풀었다. `padding-bottom: 62dvh`.
계산은 맞았고 게이트는 끝까지 돌았는데, 화면 절반이 넘는 빈 공간이 남았다.
사용자가 본 것은 그 빈칸이었다 — "어마어마하게 큰 이상한 게 있는데요?"

**그 여백은 콘텐츠가 아니라 스크롤 연료였다.** 원인은 진행도를 섹션 높이로
잰 것이다. 그러면 진행 구간이 섹션 길이에 묶이고, 구간을 늘리는 유일한 방법이
섹션을 늘리는 것뿐이다.

진행도를 **읽는 대상이 화면을 지나는 동안**으로 바꾸면 여백이 필요 없어진다.

```js
// 섹션이 아니라 목록에서 읽는다. 게이트가 도는 구간과 읽는 구간이 같아진다.
const rect = list.getBoundingClientRect();
const start = innerHeight * 0.7;   // 목록 위쪽이 여기 오면 진행 0
const end   = innerHeight * 0.3;   // 목록 아래쪽이 여기를 빠져나가면 진행 1
const travel = rect.height + (start - end);
const progress = clamp01((start - rect.top) / travel);
```

`(start - end)` 만큼이 공짜로 얻는 구간이다. 뷰포트의 40% 를 쓰면
목록 963px 짜리가 1323px 구간이 되고, 게이트 6 개면 하나당 220px 이다.
**여백 558px 을 지우고도 기준을 넘겼다.**

> 확인할 것: sticky 요소가 `progress === 1` 에 닿기 전에 사라지지 않아야 한다.
> 목록 아래쪽이 화면 30% 에 있을 때 섹션 하단이 아직 뷰포트 안이면 안전하다.

---

## 4. JS 스택 판단 — 언제 끌어오고 언제 안 끌어오나

```
[1] §3의 CSS로 되는가?                                        YES → 끝. 라이브러리 없음
[2] React이고 레이아웃 변화(FLIP)·제스처·퇴장 관리가 핵심인가?   YES → Motion
[3] 스크롤 핀 고정+다단계 시퀀스, 텍스트 자동 분해, SVG 모핑?    YES → GSAP
[4] 명령형 제어만 필요하고 번들을 늘리기 싫은가?                 YES → WAAPI (0KB)
```

| 스택 | 번들 (min+gzip) | 쓰는 이유 | 쓰지 않는 이유 |
|---|---|---|---|
| **CSS + WAAPI** | **0KB** | 기본값 | 타임라인 시퀀싱·FLIP이 안 된다 |
| **Motion** `animate` mini | 2.3KB | 바닐라에서 명령형 애니메이션 | 스크롤 핀이 없다 |
| **Motion** (React, `m`+`LazyMotion`+`domMax`) | 4.6KB + 25KB 지연 | `layout` prop(FLIP 최고), 스프링, `AnimatePresence` | 스크롤 핀·스크럽 등가물 없음 |
| **Motion** 풀 | ~34KB | 전부 | 예산을 먹는다 |
| **GSAP** core | ~24KB + 플러그인 | ScrollTrigger(핀·스크럽·스냅), SplitText, Flip, MorphSVG | 메인 스레드에서 돈다 |
| **Lenis** | ~3KB | 스크롤 감각이 브랜드 자산일 때, WebGL↔DOM 동기화 | 아래 |

**GSAP과 Motion을 한 프로젝트에 둘 다 넣는 것은 대체로 실수다.** 번들이 두 배가 되고 RAF 루프가 둘 돈다. 역할이 명확히 갈릴 때만 허용하고, 그때도 티커는 `gsap.ticker` 하나로 통일한다.

### GSAP 라이선스 — 2025년에 바뀌었다 (gsap.com 원문 확인)

**발효 2025-04-30.** 상업 프로젝트에서 **전부 무료**다. 과거 Club GreenSock 유료 플러그인(SplitText, MorphSVG, DrawSVG, ScrollTrigger, ScrollSmoother, Inertia, MotionPath, Flip, Observer)이 **모두 포함**된다. 배경은 2024년 10월 Webflow의 GreenSock 인수.

남은 제약은 하나다. **GSAP을 "코드 없이 시각적으로 애니메이션을 만드는 도구"에 넣어 Webflow의 애니메이션 빌더와 경쟁하는 제품을 만들 수 없다.** FAQ는 AI가 생성한 코드를 명시적으로 허용한다("AI-generated code is not a 'Prohibited Use'"). **일반적인 웹사이트·앱 제작에는 아무 제약이 없다.**

> 판단: GSAP은 더 이상 "돈 때문에 못 쓰는 라이브러리"가 아니다. 스크롤 시퀀스나 텍스트 분해가 필요하면 주저 없이 쓴다. 다만 **필요 없으면 여전히 깔지 않는다.**

### Lenis (스무스 스크롤) — 논쟁이 있다. 양쪽을 알고 결정해라

**반대**: 스크롤은 사용자가 기대하는 기기 고유의 물리다. 바꾸는 건 시스템 관습 침해다. 관성이 붙으면 **정확한 위치에 멈추기 어렵고** 운동 장애가 있는 사용자에게 치명적이다. 지연은 모든 사용자에게 인지 비용이다.

**찬성**: Lenis는 구식 스크롤 재킹이 아니다. **네이티브 `scrollTop`을 이징할 뿐**이라 스크롤바·앵커·`position: sticky`·스크린리더 탐색이 그대로 작동한다. WebGL↔DOM 동기화는 스크롤을 메인 스레드에서 통제해야만 드리프트가 사라진다.

**판단**: 대시보드·관리도구·문서·커머스·폼 → **쓰지 않는다**(`scroll-behavior: smooth`로 충분). 콘텐츠 사이트·블로그·뉴스 → **쓰지 않는다**(읽기를 방해한다). 브랜드 랜딩·포트폴리오·캠페인 → 조건 충족 시 쓸 수 있다. WebGL↔DOM 동기화 → 사실상 필수.

쓴다면 전부 지켜라: `respectReducedMotion: true` 명시 · `duration` 1.2 이하 · `syncTouch: false` · 모달 열릴 때 `lenis.stop()` · 내부 스크롤 요소(코드 블록·지도)에 `data-lenis-prevent` · **키보드 스크롤(Space·PageDown·Home·End)을 실제로 눌러보고 확인**.

---

## 5. 체크리스트 — 5단계 프리플라이트에서 그대로 돌린다

**하나라도 실패하면 4-4 복귀다.**

**게이트 직결**
- [ ] `transition`·`@keyframes`를 전부 grep했다. 애니메이션되는 속성이 `opacity`·`transform`·`translate`·`rotate`·`scale`뿐이다 (**#8**)
- [ ] `background-color`·`box-shadow`·`filter`·`height`·`width`·`top`·`left`·`color`를 전환하는 곳이 없다. 있으면 §2 우회표로
- [ ] `addEventListener('scroll'`이 소스에 없다 (**#10**)
- [ ] duration·이징이 전부 `var(--dur-*)`·`var(--ease-*)`다. 인라인 ms 값이 없다
- [ ] 이펙트를 전부 끈 상태에서 페이지가 완성돼 있다

**동작**
- [ ] scroll-driven을 쓴 곳에 `@supports` 가드가 있고 **초기 상태가 그 블록 안에** 있다. Firefox에서 백지가 되지 않는다
- [ ] 스크롤 리빌이 위아래로 오갈 때 재생되지 않는다 (`both` / `unobserve()`)
- [ ] 첫 화면 콘텐츠가 애니메이션 없이 즉시 읽힌다
- [ ] 퇴장 duration이 진입의 0.65배다. `(항목수 − 1) × --stagger + duration ≤ 800ms`
- [ ] 한 시점에 움직이는 독립 요소가 3개 미만이고, 애니메이션 중에도 클릭·키 입력이 먹는다

**접근성 — 타협 없음**
- [ ] `prefers-reduced-motion: reduce`를 켜고 실제로 확인했다. **전부 꺼지지 않고** 위치 이동·시차·루프만 죽고 페이드·상태 변화·진행 표시는 남는다
- [ ] 5초 넘게 자동 재생되는 애니메이션에 정지 수단이 있다 (WCAG 2.2.2)
- [ ] 큰 면적이 무한 회전·확대·시차 이동하지 않는다 (전정기관 장애)
- [ ] 텍스트가 글자 단위로 쪼개져 있지 않다. 쪼갰다면 원문이 DOM에 온전히 있고 시각 사본에 `aria-hidden`이 있다
- [ ] 카운트업의 최종값이 스크린리더에 노출된다
- [ ] 포커스 링이 애니메이션되지 않고 즉시 보인다. Tab 순서가 시각 순서와 일치하고, 전환 후 포커스가 새 콘텐츠를 따라간다
- [ ] 호버에만 있는 정보가 없다 (터치·키보드에서 접근 불가)

**성능**
- [ ] 모션 라이브러리 추가분이 `tokens.md` §5 예산 안이다. 넘겼으면 올린 이유를 명시했다
- [ ] `will-change`가 상시 걸린 요소가 10개 미만이다
- [ ] Lenis를 썼다면 §4의 조건 6개를 전부 충족했고 키보드 스크롤을 실제로 테스트했다
- [ ] 6단계 `design.md`에 **채택한 모션 · 안 쓰기로 한 모션과 그 이유**를 적었다

> 근거: research/motion/01~03 (조사일 2026-08-20)
