기초
모션
움직임은 무엇이 어디서 와서 어디로 갔는지 설명할 때만. 토큰, 거리 표, 패턴별 실시간 데모, 동작 줄이기 미리보기.
한 문장과 원칙
움직임은 “무엇이 어디서 왔고 어디로 갔는가”를 설명할 때만 쓴다. 설명할 것이 없으면 움직이지 않는다.
- 인과만 움직인다. 탭·키·스크롤이나 데이터 도착이 원인일 때만. 배경 흐름·호흡·반복·자동재생은 없다(Loader 회전만 예외).
- 멈춰도 안다. 모든 모션은 끝 상태만으로 의미가 성립한다. 그래서 동작 줄이기는 다른 디자인이 아니라 같은 끝 상태를 바로 보여주는 것이다.
- 출발점은 실제 관계에서. 시트는 아래에서, 팝오버는 트리거 쪽에서, push 상세는 오른쪽에서(RTL은 왼쪽).
- 짧게, 한 번에 하나. 한 화면에서 동시에 움직이는 그룹은 하나, 그룹 안 스태거는 최대 3개.
- 물처럼 조용하게. 최대 12px(시트·push 제외)를 감속 이징 하나로. 오버슈트·바운스·스프링 없음. scale도 쓰지 않는다.
- 브랜드는 한 번. 첫 “아하” 카드 도착만이 브랜드 모먼트다.
토큰
원천은 src/design/tokens/motion.css 하나다. 컴포넌트는 ms 값을 직접 쓰지 않는다(lint R8). 아래 표는 이 페이지의 CSS에서 읽으며, 토글을 켜면 data-motion="reduce" 영역 안에서 다시 읽는다.
| 토큰 | 값 | 쓰는 곳 |
|---|---|---|
--cb-dur-fast | … | 상태 피드백: hover·pressed·체크·스위치·선택 표시 |
--cb-dur-base | … | 작은 오버레이·흐름 안 요소의 등장: Popover·Menu·Toast·칩·행 도착 |
--cb-dur-move | … | 공간 이동: Sheet·Dialog·push·사이드바, 브랜드 모먼트 |
--cb-dur-fade | … | reduce에서도 남는 유일한 지속시간(불투명도 전용) |
--cb-dur-spin | … | Loader 회전(유일한 linear·반복 예외) |
--cb-ease | … | 모든 전환에 같은 곡선. 빠르게 출발해 길게 감속 |
--cb-exit-factor | … | 퇴장 = 등장 × 0.7 |
--cb-stagger | … | 지연 = min(i, 2) × stagger. 1·2·3번째 0·40·80ms, 이후는 80ms |
--cb-move-scale | … | 모든 이동 거리에 곱한다. reduce면 0 |
--cb-ease-ink | … | 잉크 획: 빨리 닿고 길게 감속(끝 접선 = --cb-ease) |
--cb-ease-gather | … | 연결 장면 전용 모으기: 느린 출발 |
--cb-stagger-ink | … | 활자 간격(정렬·등장 총 ≤ 400ms). reduce면 0 |
--cb-dur-sweep | … | 유리 위 빛 한 줄기가 한 번 지나가는 시간. reduce면 0(생략) |
값은 지금 페이지에 적용된 CSS에서 읽습니다.
불투명도는 항상 max()로 쓴다. reduce에서 base가 0이 되어도 120ms 페이드가 남게 하기 위해서다. 이동 거리는 calc(Npx * var(--cb-move-scale))로 쓴다.
.x {
--_fade-in: max(var(--cb-dur-base), var(--cb-dur-fade)); /* 200 → reduce 120 */
--_fade-out: max(calc(var(--cb-dur-base) * var(--cb-exit-factor)),
calc(var(--cb-dur-fade) * var(--cb-exit-factor))); /* 140 → reduce 84 */
transition: opacity var(--_fade-in) var(--cb-ease),
transform var(--cb-dur-base) var(--cb-ease); /* reduce 0 */
transform: translateY(calc(8px * var(--cb-move-scale)));
}거리와 불투명도
| 이름 | 거리 (× move-scale) | 쓰는 곳 |
|---|---|---|
| 미세 | 4px | Popover·Menu·Tooltip(트리거 쪽에서), 버튼 안 아이콘 교체 |
| 작음 | 8px | 목록 행 도착, Toast(아래에서), NewItemsChip(위에서), 랜딩 위젯 놓임 |
| 브랜드 | 12px | AhaCard(아래에서). 시스템의 최대 미세 거리 |
| 대화상자 | 16px | 가운데 Sheet·Dialog(≥768, 아래에서) |
| 시트 | 100% (자기 높이) | 바닥 Sheet(phone). 시트 자체 불투명도는 1 고정 |
| push | 100% (화면 폭) | 1024 미만 상세 push. 나가는 화면은 정지(패럴랙스 금지) |
- 이렇게 하지 않기불투명도를 중간값(0.5 등)에서 시작하지 않는다. 반투명한 중간 상태는 대비 계산을 벗어난다.
- 이렇게 하지 않기색 전환은 컨트롤 크기 이하 요소에만. 화면 크기 면의 색 보간(테마 전환 포함)은 하지 않는다.
패턴 데모
각 데모에는 “동작 줄이기 미리보기”가 있다. 켜면 그 영역에 data-motion="reduce"가 붙어 토큰이 바뀌고, 같은 끝 상태가 바로 보인다.
실제 컴포넌트: 상태 피드백과 선택 표시
실제 컴포넌트: 흐름 안 등장 + 스태거 (행 도착)
- 주간 계획 회의
- 가이드 초안 검토
- 출시 일정 조율
- 고객 인터뷰 정리
실제 컴포넌트: 오버레이 (Popover·Menu)
실제 컴포넌트: 바닥 시트 (phone)
실제 컴포넌트: 토스트·칩
실제 컴포넌트: 로딩: Skeleton은 항상 정지
모션 함수
위젯은 흰 종이 위의 잉크로 움직인다. 정확하고 가볍다. src/design/motion/gen.ts의 아홉 함수(enter · reflow · roll · day · expand · collapse · lift · ripple · underline)는 --cb-ease-ink와 --cb-stagger-ink(24ms)를 쓴다. 위젯 배선은 useWidgetMotion(ref, "general"): 처음 보일 때 한 번 enter, 가리킴·초점에 lift, 44px “다시 보기” 버튼은 replay()를 부른다. 훅은 데이터 속성(data-gen-roll · data-gen-row · data-gen-source · data-gen-title · data-reveal)이다.
등장 + 들어 올림
gen.enter(w) · gen.lift(el, on)다음 일정까지
14:00
분기 계획 리뷰 준비
예산 초안 공유
Google Calendar
처음 보일 때 한 번: 머리·문장 4px, 큰 값은 활자처럼 갈리고, 행이 24ms 간격으로 6px. 가리키거나 초점이 가면 2px 들림 + 그림자 + 출처 줄 잉크 밑줄. 줄이기: fade 120, 그림자만.
판면 재배치
gen.reflow(getEls, mutate)정확한 FLIP(base, 잉크 이징). 새 순서 위에서부터 24ms 간격, 총 ≤ 400ms. 줄이기: 즉시.
활자 갈림 + 물결
gen.roll(el, text) · gen.ripple(line)14:00
Google Calendar
바뀐 글자만 칸 안에서 갈린다(방향은 늘 같아 증감을 말하지 않음). 갱신 때 밝은 짧은 획이 hairline을 한 번 지나간다(move × 2). 줄이기: 글자 + fade, 물결 없음.
날짜 넘김 + 잔상
gen.day(box, next, dir)옛 하루는 24px 비켜나며 .3으로 잠시 남았다가 사라지고, 새 하루는 반대편에서 12px로 들어온다. 줄이기: 교차 fade.
카드 → 상세
gen.expand(card, panel) · gen.collapse(card, panel, done)분기 계획 리뷰
오늘 14:00, 회의실 3. 지난 분기 같은 회의 기록 3개.
오늘 14:00 · 기록 3
판이 카드 자리에서 미끄러져 오고, 제목은 카드 제목 자리에서 따로 날아온다(크기 그대로). 내용은 판이 자리 잡은 뒤. 닫기는 ×0.7 거꾸로, Esc, 초점은 카드로. 줄이기: fade.
잉크 선 긋기
gen.underline(el, delay)분기 계획 리뷰 준비
선 자체가 클립 안에서 밀려 들어온다(move, 잉크 이징). scaleX·덮개 없음. 줄이기: 끝 상태.
연결 장면은 ConnectScene의 cards-to-record(맨 위 카드가 기록, B→A 2안)와 ink-line(잉크 한 줄)이다. 이동 280ms 토큰을 넘는 유일한 문서화된 예외(계정당 한 번, 건너뛰기·Esc).
브랜드 모먼트
첫 가져오기에서 AhaCard가 처음 화면에 들어오는 순간, 계정당 한 번. 카드: opacity 0→1 + translateY 12→0, 280ms. 카드가 자리 잡은 뒤(280ms)에만 Dusk 빛 하나가 피어올라 1.6초 머문 뒤 196ms로 사라진다. 그 2.4초 동안 다른 것은 움직이지 않는다.
실제 컴포넌트: 첫 “아하” 카드 (실제 AhaCard, phase="arriving")
2주 전 캘린더에 있던 일
수요일
Q4 roadmap sync
| 환경 | 카드 | 빛 |
|---|---|---|
| 기본 | 위 타임라인 | 위 타임라인 |
| 동작 줄이기 | fade 120ms, 이동 없음 | 정지 위치, fade 120 → 유지 → fade 84 |
| 투명도 줄이기 | 그대로 | 없음 |
| 강제 색상 | 그대로 | 없음 |
동작 줄이기
prefers-reduced-motion은 컴포넌트마다 분기하지 않고 토큰 단계에서 처리한다: fast·base·move·spin·stagger → 0, move-scale → 0, fade만 120ms. e2e는 reduce일 때 계산된 transform이 none이고 모든 duration이 120ms 이하인지 확인한다.
| 항목 | 동작 줄이기 | 투명도 줄이기 |
|---|---|---|
| 상태 피드백(색) | 즉시 | 같음 |
| Switch·Segmented 손잡이 | 즉시 위치 | 유리 손잡이 → 불투명 |
| 행 도착 | fade 120, 스태거 0 | 같음 |
| Popover·Menu·Tooltip | fade 120 / 84 | 같음 |
| 시트·대화상자 | fade 120 / 84, 이동 없음 | 같음 |
| push 탐색 | 즉시 교체 + fade 120 | 같음 |
| 사이드바 접기 | 즉시 | 유리 → 불투명 |
| Loader | 정지 아이콘 + 텍스트 | 같음 |
| 차트 | 정지 상태로 바로 | 같음 |
| 랜딩 위젯 놓임·빛 | 없음(정지) | 빛 없음 |
성능과 금지
- transform과 opacity만 애니메이션한다. height·width·top·margin·filter·backdrop-filter·box-shadow·clip-path·stroke-dashoffset은 하지 않는다.
- 되돌릴 수 있는 상태는 transition, 일회성 등장만 @keyframes. 중간에 취소해도 점프가 없다.
- will-change는 전환 직전에 붙이고 끝나면 뗀다. JS 애니메이션 라이브러리 없음(FLIP 계산만 JS).
- 숨은 탭에서 도착한 등장 모션은 건너뛴다. 아하 카드만 보일 때까지 미룬다.
- 이렇게 하지 않기스프링·오버슈트·바운스, scale 등장, 패럴랙스, 스크롤 연동, 끌어서 닫기, 숫자 세어 올리기, 쉬머, 테마 전환 색 보간, 탭 전환 슬라이드, 초점 링 애니메이션, 차트 그리기·성장, 로고 애니메이션, 두 번째 브랜드 모먼트.