designpaca/research/motion/01-principles.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

552 lines
29 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.

# 01. 모션 원칙 — 왜 이 움직임인가
> 이 문서의 목적: 에이전트가 "어떤 이징을, 몇 ms로, 어떤 순서로" 쓸지를 **취향이 아니라 규칙으로** 결정하게 만든다.
> 모션은 장식이 아니라 **인터페이스의 문법**이다. 문법에는 옳고 그름이 있다.
---
## 0. 대전제: 모션은 4가지 일만 한다
애니메이션을 넣기 전에 반드시 아래 4개 중 **어느 것에 해당하는지 한 문장으로** 답할 수 있어야 한다.
답할 수 없으면 그 애니메이션은 넣지 않는다.
| # | 역할 | 질문 | 예시 |
|---|---|---|---|
| 1 | **인과(causality)** | "이게 왜 여기 나타났나?"에 답하는가 | 버튼을 누른 지점에서 시트가 자라남 |
| 2 | **공간 연속성(continuity)** | "이 요소가 어디서 와서 어디로 갔나?"에 답하는가 | 썸네일 → 상세 이미지로 이어지는 shared element |
| 3 | **상태 변화 고지(feedback)** | "내 입력이 접수됐나?"에 답하는가 | 프레스 시 눌림, 토글 스위치, 검증 실패 표시 |
| 4 | **주의 유도(attention)** | "지금 어디를 봐야 하나?"에 답하는가 | 새 항목 하이라이트, 에러 필드로의 스크롤 |
**여기에 없는 다섯 번째, "멋있어서"는 정당한 이유가 아니다.** 단, 브랜드 표현이 명시적 요구사항인
히어로/랜딩/포트폴리오에서는 5번 **연출(expression)** 이 추가로 허용된다. 대신 연출 모션은
반드시 (a) 스크롤/호버로 사용자가 통제 가능하거나 (b) 1회성이며 (c) `prefers-reduced-motion`에서
완전히 제거 가능해야 한다.
---
## 1. 이징의 의미론 — 곡선은 "물리적 인상"이다
이징 곡선은 속도의 시간 변화율이다. 사람의 뇌는 이것을 **무게, 힘, 의도**로 읽는다.
### 1.1 기본 곡선 표
| 이징 | cubic-bezier | 속도 변화 | 심리적 인상 | 언제 쓰나 | 쓰면 안 되는 곳 |
|---|---|---|---|---|---|
| `linear` | `(0,0,1,1)` | 일정 | 기계적, 무생물 | 무한 회전(스피너), 마퀴, **스크롤 스크럽** | 위치 이동, 등장/퇴장 |
| `ease-out` | `(0,0,0.58,1)` | 빠르게 시작 → 감속 | "도착했다", 안정감 | **화면에 들어오는 모든 것**. 진입, 열림, 확장 | 화면 밖으로 나가는 것 |
| `ease-in` | `(0.42,0,1,1)` | 느리게 시작 → 가속 | "떠난다", 이탈 | **화면 밖으로 나가는 것**. 퇴장, 닫힘 | 화면에 남는 것 (끝이 뚝 끊겨 어색함) |
| `ease-in-out` | `(0.42,0,0.58,1)` | 감속-가속 대칭 | 신중함, 왕복 | 화면 안에서의 **이동**(A→B), 루프 애니메이션 | 짧은 마이크로 인터랙션(굼떠 보임) |
| `ease`(기본값) | `(0.25,0.1,0.25,1)` | 짧은 가속 → 긴 감속 | 무난함 | 딱히 고민할 여유가 없을 때의 기본값 | 프로덕션 최종 튜닝 |
| **spring** | (곡선 아님) | 물리 시뮬레이션 | 생동감, 실체감, 손맛 | 직접 조작(드래그·제스처), 토글, 레이아웃 변화 | 색/투명도 변화, 텍스트 |
> **핵심 규칙 (Josh Comeau, Material, Carbon이 모두 동의하는 지점)**
> **들어오는 것은 감속(ease-out), 나가는 것은 가속(ease-in), 안에서 움직이는 것은 감속-가속(ease-in-out).**
> 이유: 화면에 남을 요소가 급정거하면 충돌처럼 보이고, 화면을 떠날 요소가 느리게 사라지면
> "왜 아직 안 갔지"라는 대기 비용이 생긴다.
### 1.2 왜 `linear`는 거의 항상 틀린가
현실의 모든 물체는 질량이 있어 가속·감속한다. 등속 운동은 뇌가 "이건 물체가 아니라 그려진 것"으로
읽는다. 예외는 **애초에 물체가 아닌 것**뿐이다: 진행률 표시, 회전 로더, 무한 마퀴,
그리고 **스크롤에 직접 물린 애니메이션**(사용자의 스크롤 자체가 이미 이징이므로 곡선을 덧씌우면 이중 이징이 된다).
### 1.3 커스텀 cubic-bezier를 만들 때의 안전 규칙
- `x1`, `x2`는 반드시 `0~1` 범위. 벗어나면 무효.
- `y1`, `y2``0~1`을 벗어나도 되며 이때 **오버슛/언더슛**이 생긴다 (예: `cubic-bezier(0.34, 1.56, 0.64, 1)` = back-out).
- 오버슛은 **작고 가벼운 요소**(칩, 아이콘, 토스트)에만. 큰 면적(모달, 시트)이 오버슛하면 멀미를 유발한다.
- 오버슛 폭은 이동 거리의 **5~10%**를 넘기지 않는다.
```css
/* 프로덕션에서 실제로 쓸 만한 커스텀 곡선 세트 */
--ease-out-quad: cubic-bezier(0.25, 0.46, 0.45, 0.94);
--ease-out-cubic: cubic-bezier(0.22, 0.61, 0.36, 1);
--ease-out-quart: cubic-bezier(0.16, 1, 0.3, 1); /* 가장 범용적인 "고급스러운" 진입 */
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in-quart: cubic-bezier(0.5, 0, 0.75, 0); /* 퇴장 */
--ease-in-out-quart:cubic-bezier(0.76, 0, 0.24, 1); /* 화면 내 이동 */
--ease-back-out: cubic-bezier(0.34, 1.56, 0.64, 1); /* 작은 요소 전용 오버슛 */
--ease-anticipate: cubic-bezier(0.68, -0.6, 0.32, 1.6); /* 뒤로 당겼다 튀어나감 — 아주 아껴 쓸 것 */
```
---
## 2. 스프링 — 언제 곡선 대신 물리를 쓰나
### 2.1 스프링이 이기는 상황
스프링은 **목표 지점이 도중에 바뀔 수 있는 애니메이션**에서 압도적으로 유리하다.
duration 기반 이징은 목표가 바뀌면 처음부터 다시 시작하거나 속도가 뚝 끊기지만,
스프링은 **현재 속도(velocity)를 이어받아** 자연스럽게 궤도를 수정한다.
| 상황 | 선택 |
|---|---|
| 드래그를 놓았을 때의 관성/스냅 | **스프링** (필수) |
| 커서를 따라가는 요소 | **스프링** 또는 lerp |
| 토글 스위치, 체크박스 | **스프링** (손맛) |
| 레이아웃 변화(FLIP) | **스프링** |
| 모달 진입/퇴장 | 곡선 (예측 가능한 지속시간이 더 중요) |
| 색상·투명도 변화 | **곡선** (스프링의 바운스가 색에는 무의미하고 깜빡임처럼 보임) |
| 스크롤 스크럽 | **둘 다 아님** (linear) |
### 2.2 파라미터의 의미
| 파라미터 | 별칭 | 올리면 | 실무 기본값 |
|---|---|---|---|
| `stiffness` | tension | 더 빠르고 날카롭게 | 170~400 |
| `damping` | friction | 바운스가 줄고 차분해짐 | 20~40 |
| `mass` | — | 무겁고 굼뜨게, 관성 증가 | 1 (거의 안 건드림) |
**감쇠비(ζ) = damping / (2 × √(stiffness × mass))**
- ζ < 1 언더댐프(바운스 있음)
- ζ = 1 임계 감쇠(바운스 없이 최단 시간에 정착) **UI 기본값으로 가장 안전**
- ζ > 1 → 오버댐프(느리게 미끄러짐)
> Motion 라이브러리는 `stiffness/damping` 대신 **`bounce`(0~1) + `visualDuration`** 조합을 권장한다.
> 이게 디자이너 친화적이다: `{ type: "spring", visualDuration: 0.35, bounce: 0.2 }`
> `bounce: 0`이면 임계 감쇠와 동일, `bounce: 0.25`가 Motion 기본값.
### 2.3 CSS에서 스프링 흉내내기 — `linear()` 프리셋 (직접 계산한 값)
CSS에는 스프링이 없지만 `linear()`(Baseline Widely available, 2023-12)로 근사할 수 있다.
아래는 실제 스프링 미분방정식 `x(t) = 1 e^(ζω₀t)(cos(ω_d t) + (ζω₀/ω_d)sin(ω_d t))`
샘플링해 생성한 값이다. 주석의 `duration`**반드시 함께** 써야 의도한 물리가 나온다.
```css
:root {
/* soft — m=1 k=170 d=26, ζ=0.997, 바운스 없음 / duration: 700ms */
--spring-soft: linear(
0, 0.0039 1%, 0.0148 2%, 0.0314 3%, 0.0526 4%, 0.1053 6%, 0.4302 16%,
0.5189 19%, 0.598 22%, 0.6669 25%, 0.7259 28%, 0.7907 32%, 0.8415 36%,
0.8971 42%, 0.9474 51%, 1
);
/* snappy — m=1 k=300 d=30, ζ=0.866, 거의 바운스 없음 / duration: 505ms */
--spring-snappy: linear(
0, 0.0036 1%, 0.0138 2%, 0.0296 3%, 0.05 4%, 0.0743 5%, 0.1317 7%,
0.4735 17%, 0.5669 20%, 0.65 23%, 0.7221 26%, 0.7831 29%, 0.8337 32%,
0.8869 36%, 0.9342 41%, 0.9739 48%, 1
);
/* bouncy — m=1 k=400 d=20, ζ=0.5, 최대 16% 오버슛 / duration: 635ms */
--spring-bouncy: linear(
0, 0.0077 1%, 0.0295 2%, 0.0634 3%, 0.1074 4%, 0.1597 5%, 0.2185 6%,
0.282 7%, 0.6231 12%, 0.6881 13%, 0.75 14%, 0.8084 15%, 0.8627 16%,
0.9126 17%, 0.9579 18%, 0.9984 19%, 1.0341 20%, 1.065 21%, 1.0913 22%,
1.113 23%, 1.1305 24%, 1.1438 25%, 1.1535 26%, 1.1596 27%, 1.1628 29%,
1.156 31%, 1.1329 34%, 1.0273 44%, 0.9976 48%, 0.9777 53%, 1
);
/* gentle — m=1 k=120 d=14, ζ=0.639, 7% 오버슛 / duration: 940ms */
--spring-gentle: linear(
0, 0.0051 1%, 0.0195 2%, 0.0419 3%, 0.071 4%, 0.1058 5%, 0.1453 6%,
0.2342 8%, 0.528 14%, 0.6206 16%, 0.7058 18%, 0.7823 20%, 0.8492 22%,
0.9062 24%, 0.9534 26%, 0.9915 28%, 1.021 30%, 1.0429 32%, 1.0636 35%,
1.0725 38%, 1.0673 44%, 1.0014 67%, 1
);
/* nobounce — m=1 k=200 d=28.284, ζ=1.0 (임계 감쇠) / duration: 650ms */
--spring-nobounce: linear(
0, 0.004 1%, 0.0151 2%, 0.0319 3%, 0.0534 4%, 0.1068 6%, 0.4339 16%,
0.5227 19%, 0.6017 22%, 0.6703 25%, 0.7291 28%, 0.7934 32%, 0.8438 36%,
0.8986 42%, 0.9483 51%, 1
);
}
/* 사용 예 */
.sheet {
transition: translate 700ms var(--spring-soft);
}
.chip {
transition: scale 635ms var(--spring-bouncy);
}
```
> **주의**: `linear()`로 오버슛(값 > 1)을 만들려면 애니메이션되는 값 자체가 오버슛을 허용해야 한다.
> `opacity`에 `--spring-bouncy`를 쓰면 1을 넘는 구간이 클램프되어 정체 구간이 생긴다. **transform 계열에만.**
---
## 3. 지속시간 — 숫자로 못 박기
### 3.1 인지 기준선
| 임계값 | 의미 | 근거 |
|---|---|---|
| **~100ms** | 사용자가 "즉각적"이라고 느끼는 한계. 직접 조작의 착각이 유지됨 | NN/g |
| **~1s** | 사고 흐름이 끊기지 않는 한계 | NN/g |
| **>400ms** | UI 애니메이션은 여기서부터 "느리다"고 인식되기 시작 | NN/g |
| **>500ms** | "짜증난다(a real drag)" 구간 진입 | NN/g |
| **200ms** | INP(Interaction to Next Paint) "good" 임계값 | Core Web Vitals |
> NN/g 요약: **대부분의 UI 애니메이션은 100~500ms.** 애니메이션은 "너무 짧아서" 문제가 되는 경우보다
> **"너무 길어서" 문제가 되는 경우가 압도적으로 많다.**
### 3.2 designpaca 표준 지속시간 표 ← **이 표를 기본값으로 쓴다**
| 카테고리 | 지속시간 | 이징 | 예시 |
|---|---|---|---|
| **즉각 피드백** | 60~100ms | `ease-out` | 버튼 프레스, 체크박스, 리플 시작, 아이콘 상태 |
| **호버/포커스** | 120~180ms | `ease-out` (진입) / 240ms `ease` (이탈) | 링크 밑줄, 카드 리프트, 컬러 변화 |
| **작은 컴포넌트 진입** | 180~250ms | `ease-out-quart` | 툴팁, 드롭다운, 토스트, 칩 |
| **작은 컴포넌트 퇴장** | 120~180ms | `ease-in` | 위와 동일 (진입의 **0.6~0.75배**) |
| **중간 표면 진입** | 250~350ms | `ease-out-quart` 또는 spring(nobounce) | 모달, 사이드 시트, 아코디언 |
| **중간 표면 퇴장** | 180~250ms | `ease-in-quart` | 위와 동일 |
| **전체 화면 전환** | 300~450ms | `ease-in-out-quart` | 페이지 전환, 풀스크린 확장 |
| **shared element 전환** | 350~500ms | `ease-in-out-quart` | 썸네일 → 상세 |
| **스크롤 리빌** | 400~700ms | `ease-out-quart` | 섹션 등장 (1회성) |
| **앰비언트 / 루프** | 3~20s | `ease-in-out` 또는 `linear` | 배경 그라디언트 드리프트, 마퀴 |
| **스크롤 스크럽** | (해당 없음) | `linear` / `none` | 스크롤 진행률에 직접 물린 것 |
### 3.3 지속시간 조정 규칙 (표에서 벗어날 때)
1. **거리 비례**: 이동 거리가 2배면 지속시간은 **√2배** 정도만 늘린다 (선형 비례 아님).
IBM Carbon이 명시: "요소의 이동 거리·크기가 클수록 애니메이션은 길어져야 한다."
실무 근사: `duration = base × (distance / baseDistance)^0.5`, 상한은 항상 위 표의 최대값.
2. **진입 > 퇴장**: 퇴장은 진입의 **0.6~0.75배**. 나가는 것은 볼 이유가 없다.
3. **면적 비례**: 화면 면적을 많이 차지할수록 길게. 단 500ms 상한.
4. **반복 빈도 반비례**: 사용자가 하루에 100번 보는 애니메이션은 **가장 짧게**. 1번 보는 것은 길어도 된다.
→ 검색 결과 필터링(빈번) 150ms vs 온보딩 일러스트(1회) 800ms.
5. **모바일은 짧게**: 터치는 마우스보다 직접적이라 지연에 더 민감하다. 데스크톱 값의 0.8배.
### 3.4 정립된 시스템의 실제 토큰값 (1차 출처 확인)
#### Material Design 3 (`material-web` 리포지토리 `_md-sys-motion.scss`, v0.192)
```css
/* Easing */
--md-sys-motion-easing-linear: cubic-bezier(0, 0, 1, 1);
--md-sys-motion-easing-standard: cubic-bezier(0.2, 0, 0, 1);
--md-sys-motion-easing-standard-accelerate: cubic-bezier(0.3, 0, 1, 1);
--md-sys-motion-easing-standard-decelerate: cubic-bezier(0, 0, 0, 1);
--md-sys-motion-easing-emphasized: cubic-bezier(0.2, 0, 0, 1);
--md-sys-motion-easing-emphasized-accelerate: cubic-bezier(0.3, 0, 0.8, 0.15);
--md-sys-motion-easing-emphasized-decelerate: cubic-bezier(0.05, 0.7, 0.1, 1);
--md-sys-motion-easing-legacy: cubic-bezier(0.4, 0, 0.2, 1);
--md-sys-motion-easing-legacy-accelerate: cubic-bezier(0.4, 0, 1, 1);
--md-sys-motion-easing-legacy-decelerate: cubic-bezier(0, 0, 0.2, 1);
/* Duration */
--md-sys-motion-duration-short1: 50ms;
--md-sys-motion-duration-short2: 100ms;
--md-sys-motion-duration-short3: 150ms;
--md-sys-motion-duration-short4: 200ms;
--md-sys-motion-duration-medium1: 250ms;
--md-sys-motion-duration-medium2: 300ms;
--md-sys-motion-duration-medium3: 350ms;
--md-sys-motion-duration-medium4: 400ms;
--md-sys-motion-duration-long1: 450ms;
--md-sys-motion-duration-long2: 500ms;
--md-sys-motion-duration-long3: 550ms;
--md-sys-motion-duration-long4: 600ms;
--md-sys-motion-duration-extra-long1: 700ms;
--md-sys-motion-duration-extra-long2: 800ms;
--md-sys-motion-duration-extra-long3: 900ms;
--md-sys-motion-duration-extra-long4:1000ms;
```
M3의 의미론:
- **decelerate** = 화면 밖에서 들어와 화면에 멈추는 것(**enter**)
- **accelerate** = 화면에 있던 것이 멈추지 않고 화면 밖으로 나가는 것(**exit**)
- **emphasized** = 사용자의 주의를 끌어야 하는 큰 전환. `standard`보다 초반이 더 느리고 후반이 더 빠른 비대칭 곡선
- **standard** = 화면 안에서 시작하고 끝나는 대부분의 전환
- **legacy** = M2 시절 곡선. 신규 프로젝트에서는 쓰지 않는다
> **주의**: M3 사양의 "emphasized"는 원래 **2개 곡선을 이어붙인 3점 곡선**이라 단일 cubic-bezier로
> 정확히 표현되지 않는다. 위 토큰값은 웹용 근사치다. 정확히 재현하려면 `linear()`를 쓰거나
> JS 애니메이션에서 키프레임 2개로 나눈다.
#### IBM Carbon (`@carbon/motion` v11.50.0 패키지 소스에서 직접 확인)
```css
/* productive — 실무 작업용. 빠르고 방해되지 않음 */
--carbon-ease-standard-productive: cubic-bezier(0.2, 0, 0.38, 0.9);
--carbon-ease-entrance-productive: cubic-bezier(0, 0, 0.38, 0.9);
--carbon-ease-exit-productive: cubic-bezier(0.2, 0, 1, 0.9);
/* expressive — 브랜드/감성 표현용. 크고 눈에 띄는 전환 */
--carbon-ease-standard-expressive: cubic-bezier(0.4, 0.14, 0.3, 1);
--carbon-ease-entrance-expressive: cubic-bezier(0, 0, 0.3, 1);
--carbon-ease-exit-expressive: cubic-bezier(0.4, 0.14, 1, 1);
/* duration — 용도가 토큰 이름에 직접 문서화되어 있다 */
--carbon-duration-fast-01: 70ms; /* 버튼·토글 등 마이크로 인터랙션. 즉각 반응 */
--carbon-duration-fast-02: 110ms; /* 작은 UI 요소의 미묘한 진입/퇴장(페이드) */
--carbon-duration-moderate-01: 150ms; /* 작은 확장, 짧은 거리 이동. 기본 전환 속도 */
--carbon-duration-moderate-02: 240ms; /* 확장, 시스템 알림, 토스트 */
--carbon-duration-slow-01: 400ms; /* 큰 확장, 중요 시스템 알림 */
--carbon-duration-slow-02: 700ms; /* 배경 디밍, 대형 히어로 전환 */
```
Carbon의 핵심 통찰: **하나의 제품 안에 두 개의 모션 인격이 공존한다.**
- 데이터 테이블, 폼, 툴바 = **productive** (짧고 절제됨, 70~240ms)
- 온보딩, 마케팅 섹션, 빈 상태 = **expressive** (400~700ms)
같은 제품에서 이 둘을 섞지 말고, **컴포넌트 성격에 따라 결정**한다.
#### Apple HIG
Apple은 수치 토큰을 공개하지 않고 원칙만 제시한다. 요약:
- 모션은 **사용자의 작업을 돕는 목적**일 때만 쓴다
- **일관성**: 같은 종류의 전환은 항상 같은 모션
- **Reduce Motion** 설정을 반드시 존중한다. 시스템 제공 애니메이션은 자동 대체되지만 커스텀은 직접 처리
- iOS/macOS의 기본 감성은 **스프링**이다 (SwiftUI의 기본 애니메이션이 스프링)
- 화면 전체가 회전·확대·시차 이동하는 효과는 멀미 유발원 1순위
---
## 4. Stagger와 오케스트레이션
### 4.1 stagger 규칙
| 항목 수 | 항목당 지연 | 총 지속시간 상한 |
|---|---|---|
| 3~5개 (네비게이션) | 40~60ms | 300ms |
| 6~12개 (카드 그리드) | 30~50ms | 500ms |
| 12~30개 (리스트) | 15~25ms | 600ms |
| 30개 초과 | **stagger 쓰지 않음** — 첫 화면 항목만 stagger, 나머지는 동시 | — |
**절대 규칙**
1. `총 지속시간 = (항목수 1) × stagger + 개별 duration`**800ms를 넘으면 안 된다.**
100개 리스트에 20ms stagger를 걸면 2초가 걸린다. 그건 기능이 아니라 버그다.
2. stagger는 **가상 스크롤/무한 스크롤에서는 첫 페이지에만** 적용한다. 스크롤할 때마다 stagger가
재생되면 읽기를 방해한다.
3. stagger 방향은 **읽기 방향**과 일치시킨다 (좌→우, 상→하). 중앙에서 퍼지는 stagger는
그리드/방사형 레이아웃에만.
### 4.2 오케스트레이션 — 순서의 문법
Disney 12원칙 중 UI에 실제로 유효한 4가지:
| 원칙 | UI 적용 |
|---|---|
| **Slow in / Slow out** (이징) | 위 1장 전체 |
| **Timing** | 위 3장 전체 |
| **Follow through / Overlapping action** | 연관 요소들이 **동시에 끝나지 않게** 한다. 카드가 먼저, 그 안의 텍스트가 60ms 뒤에. 이게 "고급스러움"의 정체다 |
| **Anticipation** | 큰 동작 전 반대 방향으로 살짝 당긴다. **아껴 쓸 것** — 지연으로 느껴지면 실패 |
나머지 8개(squash&stretch, straight ahead, arcs, secondary action, exaggeration, solid drawing,
appeal, staging)는 캐릭터 애니메이션용이며 UI에 억지로 적용하면 유치해진다.
**squash & stretch**만 버튼 프레스에 한해 미세하게(scale 0.96~0.98) 유효하다.
### 4.3 진입 순서 규칙
```
컨테이너 → 구조(헤더/구분선) → 콘텐츠(텍스트/이미지) → 인터랙션 요소(버튼)
```
각 단계 사이 40~80ms 오버랩. **동시에 시작해서 동시에 끝나면 평면적으로 보인다.**
퇴장은 **정확히 역순**이고 더 빨라야 한다. 실무에서는 퇴장 시 stagger를 없애고 한꺼번에
페이드아웃하는 것이 더 깔끔한 경우가 많다.
---
## 5. 공간 연속성 — "어디서 와서 어디로 가는가"
### 5.1 원칙
사용자의 머릿속에는 **인터페이스의 공간 모델**이 있다. 모션은 그 모델을 유지시키거나 파괴한다.
- 동일한 객체는 **같은 정체성을 유지**한 채 이동해야 한다 (shared element transition).
- 계층 구조에서 **아래로 들어가면** 확대·전진, **위로 나오면** 축소·후퇴.
- 형제 관계 이동은 **수평 슬라이드** (탭, 캐러셀, 스텝).
- 모달/시트는 **z축 위**에서 온다 (아래에서 올라오거나, 트리거 지점에서 자라남).
### 5.2 트리거 지점 원칙 (인과의 시각화)
**요소는 사용자가 클릭한 곳에서 나와야 한다.** 화면 정중앙에서 페이드인하는 팝오버는
"어디서 왔는지" 정보를 버리는 것이다.
```css
/* 클릭 지점을 transform-origin으로 삼는 최소 구현 */
.popover {
transform-origin: var(--origin-x, 50%) var(--origin-y, 0%);
scale: 0.95;
opacity: 0;
transition: scale 180ms var(--ease-out-quart), opacity 180ms linear;
}
.popover[data-open] {
scale: 1;
opacity: 1;
}
```
```js
// 트리거 버튼의 위치를 팝오버의 origin으로 전달
function openPopover(trigger, popover) {
const t = trigger.getBoundingClientRect();
const p = popover.getBoundingClientRect();
popover.style.setProperty('--origin-x', `${t.left + t.width / 2 - p.left}px`);
popover.style.setProperty('--origin-y', `${t.top + t.height / 2 - p.top}px`);
popover.dataset.open = '';
}
```
### 5.3 시선 유도
- 사람의 시선은 **움직이는 것**을 먼저 본다. 따라서 **한 시점에 움직이는 것은 하나**여야 한다.
- 동시에 여러 곳이 움직이면 시선이 분산되고 아무것도 강조되지 않는다.
- 순차 진입(stagger)은 시선을 **경로를 따라 끌고 간다**. 그 경로가 정보 우선순위와 일치해야 한다.
- 에러/알림은 **스크롤 이동 + 짧은 강조**로 유도한다. 화면 밖에 있는 것을 흔들어봐야 아무도 못 본다.
---
## 6. 나쁜 모션의 징후 — 실패 진단 체크리스트
에이전트는 자기가 만든 모션에 대해 아래를 **전부** 확인해야 한다. 하나라도 걸리면 고친다.
### 6.1 즉시 실격 (반드시 수정)
| 징후 | 왜 나쁜가 | 처방 |
|---|---|---|
| 페이지 로드 시 콘텐츠가 애니메이션 때문에 **읽히기까지 지연** | 콘텐츠는 즉시 읽혀야 한다. 애니메이션이 게이트가 되면 안 된다 | 첫 화면(above the fold)은 애니메이션 없이 즉시 표시. 리빌은 스크롤 이후에만 |
| 스크롤 리빌이 **매번 재생**됨 | 위아래로 스크롤할 때마다 깜빡여 읽기를 방해 | `once: true` / `animation-fill-mode: forwards` / IntersectionObserver 후 unobserve |
| 애니메이션 중 **레이아웃 시프트**(CLS) 발생 | 클릭 대상이 움직여 오조작 유발 | transform/opacity만 애니메이션. 공간은 미리 확보 |
| **동시에 3개 이상**의 독립적 애니메이션 | 시선 분산, 인지 부하 | 하나로 통합하거나 순차화 |
| 500ms 초과 **차단성** 애니메이션 | 사용자가 기다림 | 400ms 이하로 줄이거나 인터럽트 가능하게 |
| **인터럽트 불가** — 애니메이션 중 클릭 무시 | 통제권 상실 | 애니메이션 중에도 입력 수용, 새 목표로 재타겟 |
| `prefers-reduced-motion` 미대응 | 접근성 위반, 실제 신체 반응 유발 | 05번 문서 참조 |
| 무한 반복되는 **큰 면적** 움직임 | 전정기관 자극 + 배터리 소모 | 정지 가능하게 하거나 제거 |
| 스크롤 재킹으로 **네이티브 스크롤 파괴** | 키보드/스크린리더/스크롤바 붕괴 | 03번 문서 Lenis 섹션 참조 |
### 6.2 냄새 (재검토 필요)
| 징후 | 진단 질문 |
|---|---|
| 이징이 전부 `ease` 또는 `linear` | 진입/퇴장/이동을 구분했는가? |
| 모든 애니메이션이 같은 duration | 크기·거리·빈도가 다른데 왜 같은가? |
| 진입과 퇴장의 duration이 같음 | 퇴장을 0.6~0.75배로 줄였는가? |
| `will-change`가 10개 이상 요소에 상시 적용 | GPU 메모리 낭비. 필요할 때만 켜고 끄는가? |
| 로딩 스켈레톤이 2초 이상 유지 | 스켈레톤은 지연을 가리는 반창고. 실제 지연을 줄였는가? |
| 애니메이션 후에도 포커스가 원래 자리 | 포커스가 시각적 흐름을 따라가는가? |
| 텍스트가 글자 단위로 쪼개짐 | 스크린리더가 "ㅇ-ㅏ-ㄴ-ㄴ-ㅕ-ㅇ"으로 읽는다. aria 처리했는가? |
| hover에만 존재하는 정보 | 터치 기기와 키보드에서는? |
### 6.3 "애니메이션을 넣지 말아야 할" 상황
- **고빈도 반복 작업 흐름**: 데이터 입력, 표 편집, 코드 에디터. 여기서 애니메이션은 순수 비용이다.
- **에러 복구 경로**: 사용자가 이미 짜증난 상태. 지연을 추가하지 않는다.
- **결과가 이미 예측되는 전환**: 탭 전환처럼 "당연히 이렇게 될 것"인 곳에 화려한 모션은 낭비.
- **오프라인/저사양 환경**: 프레임 드랍된 애니메이션은 없느니만 못하다.
- **정보 밀도가 높은 대시보드**: 숫자가 계속 바뀌는 곳에서 카운트업 애니메이션은 읽기를 방해한다.
---
## 7. 결정 트리 — 에이전트용 실행 규칙
```
[1] 이 모션의 역할이 1~4(인과/연속성/피드백/주의) 중 하나인가?
NO → 만들지 않는다. (단 브랜드 표현이 명시 요구사항이면 5번으로)
YES ↓
[2] 사용자의 직접 조작(드래그/제스처)에 물려 있거나, 목표가 도중에 바뀔 수 있는가?
YES → spring. bounce는 0~0.25. visualDuration은 아래 [4]의 값.
NO ↓
[3] 스크롤 진행률에 직접 물려 있는가?
YES → linear / ease "none". duration 개념 없음. CSS scroll-driven 우선(02번), 불가하면 ScrollTrigger scrub(03번).
NO ↓
[4] 방향은?
화면 안으로 들어옴 → ease-out 계열 (--ease-out-quart)
화면 밖으로 나감 → ease-in 계열 (--ease-in-quart), duration은 진입의 0.65배
화면 안에서 이동 → ease-in-out 계열 (--ease-in-out-quart)
[5] 지속시간은 3.2 표에서 카테고리를 골라 가져온다.
거리가 표준의 2배 이상이면 ×1.4, 표의 상한을 넘지 않는다.
[6] 여러 요소인가?
YES → 4.1 stagger 표. 총 800ms 상한 검증.
NO ↓
[7] 성능 검증: transform/opacity/filter/clip-path 만 애니메이션하는가?
NO → 05번 문서의 대체 기법 적용.
[8] prefers-reduced-motion 대응이 있는가?
NO → 05번 문서 4장의 3단계 대응 적용. (전부 끄기는 오답)
[9] 구현 스택 선택: CSS로 되나?
YES → 02번 문서. (기본값. 라이브러리는 필요할 때만)
NO → 03번 문서의 선택 기준표.
```
---
## 8. 복사해서 바로 쓰는 모션 토큰 세트
```css
:root {
/* ── Duration ─────────────────────────────────────────── */
--dur-instant: 80ms; /* 프레스, 체크, 리플 시작 */
--dur-fast: 150ms; /* 호버, 컬러, 아이콘 상태 */
--dur-quick: 220ms; /* 툴팁, 드롭다운, 토스트 진입 */
--dur-base: 300ms; /* 모달, 시트, 아코디언 진입 */
--dur-slow: 420ms; /* 페이지 전환, shared element */
--dur-reveal: 600ms; /* 스크롤 리빌 (1회성) */
--dur-ambient: 12s; /* 배경 루프 */
/* 퇴장은 진입의 0.65배 */
--dur-quick-out: 145ms;
--dur-base-out: 195ms;
--dur-slow-out: 275ms;
/* ── Easing ───────────────────────────────────────────── */
--ease-in: cubic-bezier(0.5, 0, 0.75, 0);
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in-out: cubic-bezier(0.76, 0, 0.24, 1);
--ease-back-out: cubic-bezier(0.34, 1.56, 0.64, 1);
--ease-linear: linear;
/* Material 3 호환 별칭 */
--ease-emphasized: cubic-bezier(0.2, 0, 0, 1);
--ease-emphasized-decelerate: cubic-bezier(0.05, 0.7, 0.1, 1);
--ease-emphasized-accelerate: cubic-bezier(0.3, 0, 0.8, 0.15);
/* ── Stagger ──────────────────────────────────────────── */
--stagger-tight: 25ms;
--stagger-base: 40ms;
--stagger-loose: 60ms;
/* ── Distance ─────────────────────────────────────────── */
--shift-xs: 4px; /* 미세 피드백 */
--shift-sm: 8px; /* 툴팁, 드롭다운 */
--shift-md: 16px; /* 카드 리빌 */
--shift-lg: 32px; /* 섹션 리빌 */
}
/* reduced-motion에서의 재정의 — "끄기"가 아니라 "줄이기" */
@media (prefers-reduced-motion: reduce) {
:root {
--dur-instant: 1ms;
--dur-fast: 1ms;
--dur-quick: 100ms; /* 페이드는 남긴다 */
--dur-base: 120ms;
--dur-slow: 120ms;
--dur-reveal: 120ms;
--dur-ambient: 0s;
--dur-quick-out: 80ms;
--dur-base-out: 80ms;
--dur-slow-out: 80ms;
--stagger-tight: 0ms;
--stagger-base: 0ms;
--stagger-loose: 0ms;
--shift-xs: 0px;
--shift-sm: 0px;
--shift-md: 0px;
--shift-lg: 0px; /* 이동 거리를 0으로 → 페이드만 남음 */
--ease-out: ease-out;
--ease-in: ease-out;
--ease-in-out: ease-out;
--ease-back-out: ease-out; /* 오버슛 제거 */
}
}
```
> 이 토큰 세트의 핵심 설계: **`prefers-reduced-motion`에서 거리 토큰을 0으로 만들면
> 모든 슬라이드 애니메이션이 자동으로 크로스페이드가 된다.** 컴포넌트 코드를 한 줄도
> 고치지 않고 전역에서 모션 강도를 낮출 수 있고, `animationend` 이벤트 의존 코드도 깨지지 않는다.