# motion — 움직임과 인터랙션 4-4에서 읽는다. **레이아웃·재질·입체가 끝난 뒤에 온다.** 마지막인 이유는 앞의 셋이 완성돼야 무엇이 움직여야 하는지 알 수 있기 때문이다. ## 이 문서를 읽는 법 | 상황 | 읽을 곳 | |---|---| | **모션을 넣을지 아직 안 정했다** | **§0만.** 절반은 여기서 "안 넣는다"로 끝나고 그게 정답이다 | | 하루에 몇 번 쓰는 동작인지로 판단하고 싶다 | §0 사용 빈도 표 | | duration·이징만 고르면 된다 | §1 결정 표. 여기서 끝내라 | | 하고 싶은 게 저비용 속성(transform/opacity) 밖이다 | §2 우회표 | | 스크롤에 뭔가 물려야 한다 | §3 코드 B·C — '애니메이션·스크롤 로직이 입력을 방해하지 않는다' 하드 게이트의 정답이 여기 있다 | | 패럴랙스·고정 배경·sticky 캔버스 진행도 | §3-b → [motion-parallax.md](motion-parallax.md). 넣기로 정한 뒤에만 연다 | | 모달·팝오버(트리거 옆 위치 포함)·페이지 전환 / 텍스트·숫자 연출 | §3 코드 D·E / F·G | | 형태 자체가 잘려야 한다(clip-path) | §3 코드 C-1 | | 감소 모션에서 무엇을 끄고 남기나 | §3 코드 H(정본) | | 테마(라이트·다크) 전환이 번져 보인다 | §3 코드 I | | 라이브러리를 깔지 말지 / 정리 규율 | §4 | | 구현을 마무리할 때 | §5 자기 점검. 납품 판정은 [preflight.md](preflight.md) X1 | | 드래그·제스처·촉감 피드백을 만들어야 한다 | 이 문서가 아니라 [interaction-feel.md](interaction-feel.md) — 입력 축은 전부 거기서 다룬다 | --- ## 0. "넣지 않는다"를 먼저 통과시켜라 애니메이션을 쓰기 전에 **아래 넷 중 어디에 해당하는지 한 문장으로** 답해라. 못 답하면 넣지 않는다. | 역할 | 답해야 할 질문 | 예 | |---|---|---| | **인과** | "이게 왜 여기 나타났나?" | 누른 버튼 자리에서 시트가 자라남 | | **연속성** | "이게 어디서 와서 어디로 갔나?" | 썸네일 → 상세 이미지 | | **피드백** | "내 입력이 접수됐나?" | 프레스, 토글, 검증 실패 | | **주의** | "지금 어디를 봐야 하나?" | 오류 필드로의 이동 | 모든 인터랙션에는 입력 결과를 알 수 있는 피드백이 필요하다. 다만 모션은 상태 변화의 이해를 도울 때만 쓰며, 즉시 바뀌는 값·텍스트·포커스도 피드백이 될 수 있다. ### 사용 빈도가 답을 정할 때도 있다 빈도를 모르면 넣지 말고, 브리프에서 확인하거나 가정으로 남긴다. | 빈도 | 위계 | 예 | 처방 | |---|---|---|---| | 하루 100회 이상, 키보드 단축키·커맨드 팔레트로 실행 | 관찰 후보 — 시작값 | 커맨드 팔레트 열기, 단축키 토글 | 애니메이션을 넣지 않는다. 반복 마찰이 속도보다 크다 | | 하루 수십 회, 반복 목록·호버 내비 | 관찰 후보 — 시작값 | 리스트 항목 호버, 필터 전환 | 넣더라도 `--dur-instant` 이하로 줄이고 상태는 즉시 반영한다 | | 가끔, 세션당 몇 번 | 관찰 후보 | 모달·드로어·토스트 | §1 결정 표의 표준값을 쓴다 | | 드물게, 최초 1회 | 관찰 후보 | 온보딩, 첫 방문 히어로 | 딜라이트 여지가 가장 크다 | 첫 두 행의 임계값(100회, 수십 회)은 근거 출처가 없는 매직넘버다. 실측해 조정할 시작값으로만 써라. [SKILL-EMIL-DESIGN-ENG] **관찰 후보 — 체감 성능은 실제 처리 시간과 다른 축이다.** 같은 대기 시간이라도 회전이 빠르거나 duration이 짧으면 더 빠르게 느껴진다. 이징도 이 지각에 관여한다 — `--ease-out`은 도착이 이르게 느껴지고 `linear`는 기계적으로 느껴진다. 같은 그룹 안에서 반복되는 인터랙션(예: 툴팁 그룹, §3 코드 D 인근)은 두 번째부터 지연·연출을 줄이면 전체가 더 빠르게 느껴진다. [SKILL-EMIL-DESIGN-ENG] **프로젝트 계약 — 흩어진 효과보다 오케스트레이션된 한 순간.** 섹션마다 다른 등장 효과를 걸거나 모든 카드에 같은 호버 트랜지션을 반복해 붙이는 것은 AI가 만든 티가 가장 잘 나는 패턴 중 하나다. 페이지 로드 시퀀스 하나, 리빌 하나로 정한 순간이 흩어진 열 개의 작은 효과보다 낫다. [SKILL-FRONTEND-DESIGN][SKILL-IMPECCABLE] **프로젝트 계약 — 섹션마다 스크롤 리빌을 거는 것은 기본값이 아니다.** §3 코드 B는 "리빌을 쓰기로 했을 때"의 구현 기본값이지, 모든 섹션에 리빌을 걸라는 뜻이 아니다. [motion-parallax.md](motion-parallax.md) §1 "섹션마다 다른 말을 해야 한다"의 마지막 줄(움직이지 않는 것도 결정이다)과 같은 원칙이다. 다섯 번째 **"멋있어서"는 이유가 아니다.** 예외는 브랜드 표현이 브리프의 명시적 요구일 때뿐이고, 그때도 (a) 사용자가 스크롤·호버로 통제하거나 (b) 1회성이며 (c) `prefers-reduced-motion`에서 완전히 사라져야 한다. **넣지 말아야 할 곳**: 고빈도 반복 작업(폼·표·필터) · 오류 복구 경로 · 결과가 이미 예측되는 전환(탭) · **첫 화면**(콘텐츠는 즉시 읽혀야 한다) · 숫자가 계속 바뀌는 곳. ### 즉시 실격 — 하나라도 있으면 고친다 | 징후 | 처방 | |---|---| | 애니메이션 때문에 콘텐츠가 **읽히기까지 지연**됨 | 첫 화면은 모션 없이 즉시 표시. 리빌은 스크롤 이후 | | 스크롤 리빌이 위아래로 오갈 때 **매번 재생** | `both` / `once` / `unobserve()` | | 애니메이션 중 **레이아웃 시프트** | 레이아웃·입력·CLS를 실제로 측정하고 프로젝트 예산과 비교 | | 독립 애니메이션이 대표 과업의 시선·입력을 분산시킴 | 실제 과업에서 동시성·입력 지연을 확인하고 순차화·통합·정지 중 선택 | | 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`, 12개 이하를 초기 예산으로 둘 수 있다. 목적·입력 지연·전체 소요·감소 모션에 맞춰 계약을 정하고 실제 렌더에서 확인한다. 방향은 읽기 방향(좌→우, 상→하)과 일치시킨다. **각주 넷.** 1. **프로젝트 계약 — "UI에 ease-in을 절대 쓰지 않는다"는 통념은 진입(entrance)에만 해당한다.** 진입에 감속 없는 가속 곡선을 쓰면 도착이 급정거처럼 보이기 때문이다. 위 표의 퇴장 `--ease-in`(화면 밖으로 나가는 것)은 의도적으로 유지한다 — 나가는 것은 가속해도 자연스럽다. [SKILL-EMIL-DESIGN-ENG] 2. **프로젝트 계약 — 트랜지션과 키프레임은 재트리거 가능성으로 고른다.** CSS `transition`은 중간에 끊겨도 현재 값에서 다시 조준(retarget)되지만 `@keyframes`는 중단되면 처음부터 다시 재생된다. 토스트 스태킹, 호버 상태 깜빡임처럼 짧은 간격으로 다시 발생할 수 있는 요소는 `transition`을 기본으로 쓰고, `@keyframes`는 1회성·비재트리거 애니메이션(코드 B의 리빌, 코드 G의 카운트업)에만 쓴다. [SKILL-EMIL-DESIGN-ENG] 3. **관찰 후보 — 크로스페이드가 이징·duration 조정으로도 어색하면 아주 작은 블러를 겹친다.** `filter: blur(2px)` 정도를 크로스페이드 구간에만 더하면 전환이 매끄러워 보인다. 블러는 20px 미만으로 제한하고, Safari에서 `filter`가 비싼 속성이라는 점을 §2와 함께 확인한다. [SKILL-EMIL-DESIGN-ENG] 4. **관찰 후보 — 시작값. 드로어류에는 별도 이징 곡선을 조건부로 둘 수 있다.** iOS 스타일 드로어가 브리프의 요구일 때만, 드로어 컴포넌트 스코프에 로컬로 `--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1)`을 정의해 쓴다(전역 3단계 토큰에는 추가하지 않는다, 실측 조정). [SKILL-EMIL-DESIGN-ENG] --- ## 2. 저비용 기본 선택으로 만들기 **`transform`/`opacity`는 저비용 기본 선택이다.** 다른 속성의 애니메이션은 자동 실패가 아니다. 다만 비용·CLS·입력 간섭·감소 모션·지원 범위를 측정해 프로젝트 예산과 비교하고, 목적이 더 단순한 정적 상태로 충족되는지도 먼저 검토한다. | 비용을 확인할 선택 | 저비용 대안 | |---|---| | 배경색 전환 | 목표 색을 칠한 `::before`의 **`opacity`** 전환 | | 그림자 전환 | 짙은 그림자를 가진 `::after`의 **`opacity`** 전환 | | 테두리 전환 | `::after`의 `border`와 **`opacity`** 전환 | | 글자색 전환 | 즉시 변경 또는 목적·입력 간섭·프레임 비용을 기록한 전환 | | 진행 바 너비 전환 | `transform-origin: left` + **`scaleX()`** | | 이미지 `filter` 전환 | 선명본과 미리 블러된 사본의 **`opacity`** 크로스페이드 | | 그라디언트 각도 전환 | 원뿔 그라디언트 레이어의 **`rotate`** | | `clip-path` 전환 | `overflow: hidden` 래퍼 안 자식의 **`translate`** | | 숫자 속성 전환 | 자릿수 스트립의 **`translate`** (§3 코드 G) | | 패널 `height`·`max-height` 전환 | 아래 대안과 비교한 뒤 측정·계약 근거를 기록 | **높이.** `transition: height`는 매 프레임 레이아웃 비용을 낸다. 구조에 따라 형제의 시각 이동·입력 간섭이 생길 수 있으므로, 실제 상태와 프로젝트 예산에서 확인한다. 순서대로 검토해라. 1. **구조를 바꾼다.** 정말 인라인으로 자라야 하는가? 모달·팝오버로 만들면 높이 문제가 사라지고 스케일+페이드로 끝난다 2. **높이는 즉시 확정하고 내용만 전환한다.** 래퍼를 `display: grid; grid-template-rows: 0fr` ↔ `1fr`로 만들고 자식의 `opacity`·`translate`를 전환하는 방식을 먼저 비교한다. 실제 레이아웃 이동과 입력 간섭을 확인한다 3. `grid-template-rows` 전환은 레이아웃 비용이 생길 수 있다. 필요하면 다른 대안과 비교하고 비용·입력 간섭·감소 모션·예산을 실제로 측정해 `design.md`에 근거를 남긴다 4. `height: auto`까지의 전환이 꼭 필요하면 `:root { interpolate-size: allow-keywords; }`를 점진 향상으로 둔다. Chromium 전용이라(확인 2026-10-06) 다른 브라우저에서는 전환 없이 즉시 바뀐다 — 그 상태가 계약에 맞는지 확인한다 [WEB-BASELINE] **도구 한계와 저비용 선택** - `translate` / `rotate` / `scale`은 transform 계열인 저비용 기본 선택이다. 축약형보다 속성 충돌을 줄일 수 있다 - `display`·`overlay`의 `transition-behavior: allow-discrete`는 보간하지 않는 선택지다. 다른 속성도 자동 금지가 아니며, 정적 게이트 검출은 측정·계약 검토가 필요한 후보를 알릴 뿐이다 - View Transitions의 저자 키프레임은 `opacity`와 transform 계열을 먼저 검토한다. 다른 속성은 목적·비용·입력 간섭·감소 모션·예산을 실제로 확인한 근거가 있을 때만 채택한다 **프로젝트 계약 — `transition: all`은 쓰지 않는다.** 전환할 속성을 `transition-property`로 명시한다(`transition: scale var(--dur-instant) var(--ease-out), opacity var(--dur-instant) var(--ease-out)`처럼 속성별로 쓰거나 `transition-property: scale, opacity`로 모아 쓴다). `all`은 의도하지 않은 속성(`height`·`box-shadow` 등)까지 전환에 끌어들여 위 표의 저비용 원칙을 조용히 깬다. Tailwind의 `transition-transform`은 `transform`·`translate`·`scale`·`rotate` 넷을 한 번에 잡으므로 그 넷만 전환할 때 쓰고, 다른 속성이 섞이면 대괄호 문법(`transition-[scale,opacity]`)으로 좁힌다. [SKILL-BETTER-UI] **`will-change`가 실제로 GPU 합성을 만드는 속성은 셋뿐이다.** | 속성 | 합성 가능 | 비고 | |---|---|---| | `transform` | O | 저비용 기본 | | `opacity` | O | 저비용 기본 | | `filter` | O | Safari에서 특히 체감 효과가 크다 | | `clip-path` | 불안정 | 신형 Chromium 한정. 신뢰하지 마라 | | `top`·`left`·`width`·`height` | X | 레이아웃 속성. `will-change`를 걸어도 합성되지 않는다 | | `background`·`border`·`color` | X | 페인트 속성. 합성되지 않는다 | **관찰 후보 — 첫 프레임에서 실제로 끊김이 보일 때만 추가한다.** 상시 걸어두면 레이어가 늘어 오히려 느려진다(`preflight.md`·`svg-filters.md`의 경고와 같은 이유). [SKILL-BETTER-UI] --- ## 3. CSS 네이티브 우선 — 라이브러리를 끌어오기 전에 | CSS로 충분한 것 (라이브러리 금지) | CSS로 안 되는 것 (§4로) | |---|---| | 호버·포커스·프레스 상태 전환 | 속도를 이어받는 인터럽트(드래그 던지기) | | 모달·팝오버·툴팁 진입/퇴장 (`@starting-style`) | 복잡한 타임라인 시퀀싱 (A 끝나고 B, B 중간에 C) | | 팝오버·툴팁·메뉴를 트리거 옆에 붙이기 (anchor positioning, 코드 D) | 구형 브라우저까지 충돌 회피를 보장해야 하는 위치 계산 | | 스크롤 리빌·진행 바·시차·스티키 헤더 | 스크롤 **핀 고정 + 다단계 시퀀스** | | 페이지 전환 (View Transitions) | 레이아웃 변화 FLIP (그리드 → 리스트) | | 무한 루프, `linear()` 스프링 근사 | 텍스트 자동 분해, SVG 패스 모핑, 포인터 추종 | **지원 현황(확인 2026-09-24, 1차 출처).** `prefers-reduced-motion`·`linear()`는 Baseline widely — 무조건 쓴다. `@starting-style`은 2024-08-06 Baseline newly가 됐고(Firefox 129), same-document View Transitions는 2025-10-14 Baseline newly가 됐다(Firefox 144가 마지막으로 지원) — 둘 다 아직 widely(저 시점부터 30개월)는 아니므로 폴백을 두고 쓴다. **scroll-driven animations(`animation-timeline`)는 Baseline Limited다 — Firefox는 안정판에서 아직 지원하지 않는다**(지원: Chrome/Chrome Android/Edge 115, Safari 26). Firefox가 켜는 날을 대비해 예시는 모두 길이 `1ms`를 적는다(코드 B 주석). cross-document View Transitions도 Baseline Limited(Firefox 미지원)다. 두 기능 모두 `@supports` 가드가 필수이고, 가드 **안의 초기 상태가 실제로 콘텐츠를 보이게 하는지**를 반드시 확인한다(아래 "가장 흔한 사고"). [WEB-BASELINE] > **가장 흔한 사고**: 미지원 브라우저에서 `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-control); 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) > 언제: **스크롤 리빌을 쓰기로 했을 때의 구현 기본값.** 리빌 자체를 걸지는 §0("섹션마다 스크롤 리빌을 거는 것은 기본값이 아니다")에서 먼저 정한다. 쓰기로 했다면 진행 바·시차·헤더 축소를 포함해 이 방식이 기본이다 — 컴포지터 스레드에서 돌아 메인 스레드가 막혀도 끊기지 않는다. **scroll-driven animations는 Baseline Limited다. Firefox 안정판이 지원하지 않으므로**(§3 도입부) `@supports` 가드 없이 쓰면 Firefox에서 리빌이 영영 실행되지 않고, 초기 상태를 가드 밖에 두면 콘텐츠가 사라진 채로 남는다. '애니메이션·스크롤 로직이 입력을 방해하지 않는다' 하드 게이트의 정답이 이것이다. [WEB-BASELINE] ```css /* 초기 상태를 @supports 안에 둔다 — 미지원 브라우저에서는 그냥 보인다 */ @supports (animation-timeline: view()) { .reveal { /* both 필수. 없으면 범위 밖에서 깜빡인다. 길이는 진행에 쓰이지 않지만 1ms를 적는다 — Firefox는 0이 아닌 duration이 있어야 애니메이션을 적용한다(MDN) */ animation: reveal-in 1ms linear both; animation-timeline: view(); /* 축약형 뒤에 둔다. 앞에 두면 축약형이 auto로 되돌린다 */ /* 스크롤 타임라인에서 animation-delay는 무시된다. 스태거는 구간을 밀어서 만든다 */ animation-range: entry calc(15% + var(--i, 0) * 4%) cover calc(35% + var(--i, 0) * 4%); } @keyframes reveal-in { from { opacity: 0; translate: 0 var(--shift-lg); } to { opacity: 1; translate: 0 0; } } } /* 읽기 진행 바 — width가 아니라 scaleX (§2 저비용 속성 원칙) */ @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 1ms 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')`은 자동 금지가 아니다. 필요한 경우 passive 처리·throttle 또는 rAF 일정화·입력 지연·프레임 시간·감소 모션 경로를 측정하고, `IntersectionObserver` 또는 scroll-driven animation이 더 맞는지 비교한다. ```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)); } ``` ### 코드 C-1 — clip-path를 애니메이션할 때 > 언제: 형태 자체가 잘려야 하는 경우만. 단순 리빌(텍스트·카드 등장)은 여전히 `overflow: hidden` 래퍼 안 자식의 `translate`가 저비용 기본이다(§2 표, 코드 F의 각주). 이 소절은 그 대안이 아니라 **다른 문제**를 푼다 — 탭 배경이 선택된 탭의 모양으로 바뀌거나, 눌러서 채우는 확인, 두 이미지를 가르는 슬라이더처럼 **경계선 자체가 움직여야** 할 때다. [SKILL-EMIL-DESIGN-ENG] `clip-path`는 GPU 합성이 불안정하다(§2 will-change 표). 아래 레시피는 실제 렌더에서 프레임을 재보고, 끊기면 `mask-image`나 두 겹 이미지 + `overflow: hidden`으로 되돌린다. ```css /* 탭 배경 전환 — 선택된 탭 아래로 배경이 미끄러져 들어온다. JS가 선택된 탭의 실제 rect를 읽어 --tab-x/--tab-w를 갱신한다 */ .tabs { position: relative; } .tabs__bg { position: absolute; inset: 0; background: var(--accent); clip-path: inset(0 calc(100% - var(--tab-x) - var(--tab-w)) 0 var(--tab-x) round var(--radius-pill)); transition: clip-path var(--dur-normal) var(--ease-soft); } /* hold-to-confirm — 누르는 동안(결정)은 느리게 채우고, 놓으면(해제)은 항상 빠르게 되감는다. "누르는 동작은 느리게, 해제는 항상 빠르게"라는 비대칭 원칙의 구체 사례다(preflight.md의 되돌림 원칙과 연결) */ .confirm { position: relative; } .confirm::after { content: ''; position: absolute; inset: 0; background: var(--ink); clip-path: inset(0 100% 0 0); transition: clip-path calc(var(--dur-quick) * 0.65) var(--ease-in); /* 해제 스냅백 */ } .confirm:active::after { clip-path: inset(0 0 0 0); transition: clip-path 2s linear; } /* 실측 조정 */ /* 비교 슬라이더 — 두 이미지를 가르는 경계 자체가 움직인다. --split은 드래그로 갱신 */ .compare__after { clip-path: inset(0 0 0 var(--split, 50%)); } /* 이미지 리빌 — 사각형이 아니라 비정형 경계로 드러나야 할 때 */ .reveal-shape { clip-path: polygon(0 0, 0 0, 0 100%, 0 100%); transition: clip-path var(--dur-slow) var(--ease-out); } .reveal-shape[data-shown] { clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%); } ``` **관찰 후보.** 네 레시피 모두 §5 모션 QA로 실제 기기에서 프레임을 확인한다. [SKILL-EMIL-DESIGN-ENG] ### 코드 D — 모달·팝오버 진입/퇴장 (`@starting-style`) > 언제: `display: none`에서 나타나거나 top layer에 올라가는 모든 것. 라이브러리가 필요 없다. (`@starting-style`은 2024-08-06 Baseline newly — §3 도입부. [WEB-BASELINE]) ```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 // 값은 CMS·사용자 입력에서 올 수 있다. 문자열 보간으로 innerHTML을 만들지 않고 // 숫자만 통과시킨 뒤 DOM API로 조립한다(속성 탈출 XSS 차단) function span(cls) { const el = document.createElement('span'); if (cls) el.className = cls; return el; } document.querySelectorAll('.odo').forEach((odo) => { const value = odo.dataset.value ?? ''; if (!/^\d{1,12}$/.test(value)) return; // 숫자가 아니면 연출하지 않는다 — .sr-only 값이 그대로 남는다 const cols = [...value].map((d, i) => { const col = span('odo__col'); const strip = span('odo__strip'); strip.style.setProperty('--d', d); strip.style.setProperty('--i', String(i)); for (let n = 0; n <= 9; n++) { const digit = span(); digit.textContent = String(n); strip.append(digit); } col.append(strip); return col; }); odo.replaceChildren(...cols); 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 Animation from Interactions(**AAA**)는 **인터랙션으로 촉발된 모션 애니메이션을 끌 수 있어야** 한다고 요구한다. 색·투명도·블러만 바뀌는 것은 해당하지 않는다 — **위치·크기·형태가 바뀌는 것**이 대상이다. AAA라서 AA 준수 판정의 근거는 아니다. designpaca는 전정기관 장애(어지럼·구역질·두통이라는 실제 신체 반응) 근거로 감소 모션 대응을 **정책상 하드 게이트**로 올린다 — 보고서에는 "WCAG AA 위반"이 아니라 "designpaca 정책(WCAG 2.3.3 AAA 근거)"으로 표기한다. 자동 재생되는 것은 별도로 2.2.2 Pause, Stop, Hide(**A**, 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 쪽 판정도 한 곳에 모은다. 설정이 도중에 바뀌면 **새로고침하지 않고** 상태만 갱신한다 — `location.reload()`는 입력 중인 폼·스크롤 위치·열린 패널을 잃게 한다. ```js const mq = matchMedia('(prefers-reduced-motion: reduce)'); const applyMotionPref = () => { document.documentElement.toggleAttribute('data-reduced-motion', mq.matches); // 진행 중인 JS 애니메이션(WAAPI·GSAP 등)은 여기서 끝값으로 보내거나 취소한다 }; applyMotionPref(); mq.addEventListener('change', applyMotionPref); ``` ### 코드 I — 테마 전환 시 트랜지션 억제 > 언제: 라이트·다크를 런타임에 토글하는 프로젝트 전부. 넣지 않으면 테마가 바뀌는 순간 색·그림자·테두리 트랜지션이 동시에 발화해 화면 전체가 번져 보인다. OS 설정이나 인앱 토글로 테마가 바뀔 때, 전역에 `transition: none`을 순간적으로 주입하고 강제 리플로우한 뒤 다음 프레임에 제거한다. ```js function setTheme(next) { const css = document.createElement('style'); css.textContent = '*,*::before,*::after{transition:none!important}'; document.head.appendChild(css); document.documentElement.dataset.theme = next; // 실제 토큰 전환 document.body.offsetHeight; // 강제 리플로우 — 주입한 스타일을 확정시킨다 requestAnimationFrame(() => requestAnimationFrame(() => css.remove())); } ``` `next-themes` 같은 라이브러리를 쓴다면 `disableTransitionOnChange` 옵션이 같은 일을 기본 제공한다. **프로젝트 계약.** [SKILL-BETTER-UI] --- ## 3-b. 패럴랙스·스크롤 연동 깊이 → [motion-parallax.md](motion-parallax.md) 패럴랙스, 고정 배경, sticky 캔버스의 진행도 계산은 분량이 커서 [motion-parallax.md](motion-parallax.md)로 분리했다. **넣기로 정한 뒤에만** 연다. 섹션별 주장과 움직임 대응, 고정 원형, 이동량 공식, `overflow: hidden` 함정, 진행도 공식이 거기 있다. --- ## 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은 더 이상 "돈 때문에 못 쓰는 라이브러리"가 아니다. 스크롤 시퀀스나 텍스트 분해가 필요하면 주저 없이 쓴다. 다만 **필요 없으면 여전히 깔지 않는다.** ### 정리 규율 — 라이브러리를 쓰면 반드시 해제한다 **프로젝트 계약.** 라우트를 떠나거나 컴포넌트가 언마운트될 때 인스턴스를 정리하지 않으면 리스너와 타임라인이 누적돼 메모리와 프레임이 새어나간다. | 라이브러리 | 정리 호출 | |---|---| | GSAP 타임라인·ScrollTrigger | `gsap.context(fn, scope)`로 스코프를 잡고 언마운트 시 `ctx.revert()` | | GSAP SplitText | `split.revert()` — 원본 DOM 구조로 되돌린다 | | WAAPI | `animation.cancel()` | | IntersectionObserver | `observer.disconnect()`(§3 코드 C·G에 이미 적용) | | Lenis | `lenis.destroy()` | [SKILL-INTERACTION-DESIGN][SKILL-EMIL-DESIGN-ENG] ### WAAPI — 번들 없이 명령형 제어 ```js const anim = el.animate( [ { opacity: 0, translate: '0 var(--shift-lg)' }, { opacity: 1, translate: '0 0' }, ], { duration: 350, easing: 'cubic-bezier(0.22, 1, 0.36, 1)', fill: 'both' } ); anim.finished.then(() => { /* 완료 후 처리 */ }).catch(() => {}); // 언마운트하거나 다시 요청받으면 반드시 취소한다 anim.cancel(); ``` ### GSAP 타임라인 + ScrollTrigger 최소 골격 (바닐라 JS) React를 전제하지 않아 어떤 프레임워크에도 옮길 수 있는 최소 형태다. `gsap.context()`로 스코프를 잡고 정리 함수를 반환한다. ```js import gsap from 'gsap'; import { ScrollTrigger } from 'gsap/ScrollTrigger'; gsap.registerPlugin(ScrollTrigger); function mountHero(root) { const ctx = gsap.context(() => { const tl = gsap.timeline({ scrollTrigger: { trigger: root, start: 'top 80%', once: true }, }); // GSAP은 CSS 변수를 직접 읽지 않으므로 토큰 값과 같은 숫자를 쓴다 tl.from('.hero__title', { opacity: 0, y: 24, duration: 0.6, ease: 'power2.out' }) // 0.6 = --dur-slow, 'power2.out' = --ease-out에 대응하는 GSAP 이징 .from('.hero__sub', { opacity: 0, y: 16, duration: 0.35, ease: 'power2.out' }, '-=0.3'); // 0.35 = --dur-normal }, root); return () => ctx.revert(); // 언마운트 시 반드시 호출 } ``` [SKILL-INTERACTION-DESIGN] ### Motion(Framer) 쓸 때 주의 둘 - **관찰 후보 — `x`·`y`·`scale` 축약 속성은 그 자체로 하드웨어 가속이 아니다.** Motion은 메인 스레드의 `requestAnimationFrame`으로 이 값들을 계산해 매 프레임 `transform` 문자열로 합성한다. 실제 GPU 합성 여부는 최종적으로 만들어지는 `transform` 값과 `will-change`(§2 표) 조합에 달려 있지, 축약 속성 문법 자체가 보장하지 않는다. [SKILL-EMIL-DESIGN-ENG] - **관찰 후보 — `AnimatePresence`는 `initial={false}`로 첫 렌더 진입 애니메이션을 끈다.** 마운트마다 진입 연출이 발화하면 페이지를 새로고침할 때마다 불필요하게 움직인다. 단, 스태거드 히어로처럼 `initial` 자체가 최초 1회 연출을 담당하는 곳에는 적용하지 않는다 — 새로고침에서도 올바르게 보이는지 확인한다. [SKILL-BETTER-UI] ### 경량 CSS 3D — WebGL 게이트를 넘지 않는 작은 회전 로고 궤도, 카드 뒤집기처럼 작은 3D 연출은 `three.md`의 WebGL 채택 게이트를 넘지 않고도 만들 수 있다. ```css .flip-card { perspective: 800px; } .flip-card__inner { transform-style: preserve-3d; transition: transform var(--dur-normal) var(--ease-soft); } .flip-card[data-flipped] .flip-card__inner { transform: rotateY(180deg); } .flip-card__face--back { transform: rotateY(180deg); backface-visibility: hidden; } ``` [SKILL-EMIL-DESIGN-ENG] ### 스크롤 핸들러는 passive + 일정화한다 `addEventListener('scroll', ...)`이 §3 코드 C처럼 정말 필요할 때(IntersectionObserver나 scroll-driven animation으로 안 풀릴 때)만 쓴다. 매 스크롤 이벤트가 아니라 다음 페인트 한 번으로 묶는다. ```js let ticking = false; window.addEventListener('scroll', () => { if (ticking) return; ticking = true; requestAnimationFrame(() => { handleScroll(); ticking = false; }); }, { passive: true }); ``` **프로젝트 계약.** [SKILL-INTERACTION-DESIGN] ### Lenis (스무스 스크롤) — 논쟁이 있다. 양쪽을 알고 결정해라 **반대**: 스크롤은 사용자가 기대하는 기기 고유의 물리다. 바꾸는 건 시스템 관습 침해다. 관성이 붙으면 **정확한 위치에 멈추기 어렵고** 운동 장애가 있는 사용자에게 치명적이다. 지연은 모든 사용자에게 인지 비용이다. **찬성**: Lenis는 구식 스크롤 재킹과 다른 구현을 목표로 한다. 버전·통합 방식에 따라 스크롤바·앵커·`position: sticky`·스크린리더 탐색에 미치는 결과를 실제로 검증해야 한다. WebGL↔DOM 동기화에 도움이 될 수 있으나, 그 효과는 프로젝트에서 측정한다. **판단**: 기본은 네이티브 스크롤이다. 브랜드 표현이나 WebGL↔DOM 동기화처럼 특별한 요구가 있을 때만 도입 후보로 삼고, 입력 지연·키보드·포커스·앵커·감소 모션·예산을 실제로 검증한다. 쓴다면 터치 동작·모달과 내부 스크롤의 상호작용을 프로젝트 계약으로 정하고, Space·PageDown·Home·End·포커스 이동·앵커를 실제로 확인한다. `prefers-reduced-motion`에서는 네이티브 경로 또는 불필요한 보간 제거가 실제로 적용되는지 확인한다. 측정된 입력 지연과 모션 예산을 `design.md`에 남긴다. --- ## 5. 체크리스트 (구현 중 자기 점검 — 납품 판정은 preflight.md X1) 모션을 짜는 동안 확인한다. 납품 판정은 [preflight.md](preflight.md) X1이 하고, 이펙트를 끈 상태의 완성은 M-A, 감소 모션은 A7, 5초 넘는 자동 재생 정지는 A8, 호버 전용 정보는 W13, 깜빡임은 A19, 포커스 링·Tab 순서는 A1·A2, 모션 라이브러리 추가분 예산은 P2가 판정한다. 시각 회귀 캡처는 모션 완료를 관측한 뒤 찍는다([audit-gate.md](audit-gate.md) 렌더 측정 계약, [audit-gate-rules.md](audit-gate-rules.md) G10). **게이트 직결** - `transition`·`@keyframes`를 전부 grep하고, 각 속성의 목적·비용 측정·입력 간섭·감소 모션 경로를 기록했다. `opacity`·`transform`·`translate`·`rotate`·`scale` 이외 속성은 프로젝트 예산과 실제 렌더 결과로 정당화했다 - `background-color`·`box-shadow`·`filter`·`height`·`width`·`top`·`left`·`color`를 전환한다면 §2의 저비용 대안과 비교하고, 유지 이유와 측정 결과를 기록했다 - `addEventListener('scroll'`을 썼다면 passive 처리와 일정화, 프레임 시간·입력 지연 측정, 감소 모션 경로를 기록했다 - duration·이징이 전부 `var(--dur-*)`·`var(--ease-*)`다. 인라인 ms 값이 없다 **동작** - scroll-driven을 쓴 곳에 `@supports` 가드가 있고 **초기 상태가 그 블록 안에** 있다. Firefox에서 백지가 되지 않는다 - 스크롤 타임라인 애니메이션에 `animation-delay`로 스태거를 걸지 않았다(무시된다). 첫 화면 요소에 `view()`의 `entry` 구간을 쓰지 않았다 - 스크롤 리빌이 위아래로 오갈 때 재생되지 않는다 (`both` / `unobserve()`) - 첫 화면 콘텐츠가 애니메이션 없이 즉시 읽힌다 - 퇴장 duration과 stagger 총 소요가 프로젝트 계약에 맞고, 입력 지연·감소 모션을 실제로 확인했다 - 대표 과업에서 동시 모션이 시선·클릭·키 입력을 방해하지 않는지 실제로 확인했다 - 모션 QA를 실제로 돌렸다 — duration을 2~5배로 늘리거나 DevTools Animations 패널에서 프레임 단위로 재생해 색 전환·이징·transform-origin·속성 동기화 4항목을 확인했다 [SKILL-APPLE-DESIGN][SKILL-EMIL-DESIGN-ENG][SKILL-IMPECCABLE] - 실기기 또는 CPU 스로틀링(DevTools Performance)에서 한 번은 확인했다. 증거 출처(에뮬레이션/실기기, 엔진명 — Chromium≠Safari)를 `design.md`에 남겼다 [SKILL-IMPECCABLE] **접근성 — 타협 없음** - 큰 면적이 무한 회전·확대·시차 이동하지 않는다 (전정기관 장애) - 텍스트가 글자 단위로 쪼개져 있지 않다. 쪼갰다면 원문이 DOM에 온전히 있고 시각 사본에 `aria-hidden`이 있다 - 카운트업의 최종값이 스크린리더에 노출된다 - 포커스 링 자체를 애니메이션하지 않는다. 뷰 전환 뒤 포커스가 새 콘텐츠를 따라간다 **성능** - 상시 `will-change` 요소의 레이어 메모리와 실제 기기 성능이 프로젝트 예산 안임을 확인했다(§2 will-change 표) - 드래그·제스처가 있다면 입력 경로 지연을 감사했다 — 기준과 방법은 [interaction-feel.md](interaction-feel.md) - GSAP·Motion·Lenis·SplitText·IntersectionObserver 등 이 문서에서 쓴 라이브러리 인스턴스가 이탈 시 정리된다(§4 정리 규율 표) - Lenis를 썼다면 §4의 입력·키보드·포커스·앵커·감소 모션·예산 검증을 실제로 마쳤다 - 6단계 `design.md`에 **채택한 모션 · 안 쓰기로 한 모션과 그 이유**를 적었다 > 근거: designpaca 저장소의 모션 조사 노트(조사일 2026-08-20). 설치본에는 포함되지 않으며, 이 문서의 결론만으로 작업할 수 있게 썼다.