# 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
1284 개의 프로젝트
``` ```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) => `${n}`).join(''); document.querySelectorAll('.odo').forEach((odo) => { odo.innerHTML = odo.dataset.value.split('').map((d, i) => `${DIGITS}` ).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` 에 "여기는 의도적으로 정지"라고 적어두지 않으면 다음 사람이 "빠뜨린 것"으로 보고 채운다. ### 이동량은 절대값이 아니라 컨테이너 대비 비율이다 **패럴랙스가 "안 보인다"의 대부분은 이동량이 모자란 것이다.** 공식은 하나다. **배경을 컨테이너보다 크게 만들고, 그 초과분 전체를 움직인다.** ``` 이미지 높이 = 컨테이너 × (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). 다만 그러면 > `