designpaca/packages/skill/references/motion.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

504 lines
30 KiB
Markdown
Raw 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.

# 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);
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);
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())`.
---
## 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)