designpaca/packages/skill/references/motion.md
Yun Chan 6805fb2be7 feat(skill): absorb external design skills, restore interview gate, add review route
- Restore the step-0 interview as a mechanical gate the skill explicitly
  depends on; add harness.md (per-harness question tools, limits,
  fallbacks) and brief-interview.md (slots, question cards, rounds).
- Add 10 reference docs absorbed from external design skills
  (accessibility, interaction-feel, elevation, color, icons, product-copy,
  component-systems, critique, change-review, print-email) and extend
  existing references.
- Add a review-only route and two hard-gate clauses (truncated content
  reachability, three-flashes limit).
- design-gate: split tap targets into WCAG 2.5.8 and 44px contract layers,
  run axe-core when available, and fix false positives found on a real
  site (decorative alt="", stacked wordmark line count, url-only pages).
- lint-skill: fail if the interview gate section or its links disappear.
- Ship agents/openai.yaml and THIRD_PARTY_NOTICES.md.
2026-09-24 13:26:03 +09:00

63 KiB
Raw Blame History

motion — 움직임과 인터랙션

4-4에서 읽는다. 레이아웃·재질·입체가 끝난 뒤에 온다. 마지막인 이유는 앞의 셋이 완성돼야 무엇이 움직여야 하는지 알 수 있기 때문이다.

이 문서를 읽는 법

상황 읽을 곳
모션을 넣을지 아직 안 정했다 §0만. 절반은 여기서 "안 넣는다"로 끝나고 그게 정답이다
하루에 몇 번 쓰는 동작인지로 판단하고 싶다 §0 사용 빈도 표
duration·이징만 고르면 된다 §1 결정 표. 여기서 끝내라
하고 싶은 게 저비용 속성(transform/opacity) 밖이다 §2 우회표
스크롤에 뭔가 물려야 한다 §3 코드 B·C — '애니메이션·스크롤 로직이 입력을 방해하지 않는다' 하드 게이트의 정답이 여기 있다
모달·페이지 전환 / 텍스트·숫자 연출 §3 코드 D·E / F·G
형태 자체가 잘려야 한다(clip-path) §3 코드 C-1
테마(라이트·다크) 전환이 번져 보인다 §3 코드 I
라이브러리를 깔지 말지 / 정리 규율 §4
감사 직전 §5 체크리스트
드래그·제스처·촉감 피드백을 만들어야 한다 이 문서가 아니라 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는 "리빌을 쓰기로 했을 때"의 구현 기본값이지, 모든 섹션에 리빌을 걸라는 뜻이 아니다. 뒤의 "패럴랙스는 섹션마다 다른 말을 해야 한다"의 마지막 줄(움직이지 않는 것도 결정이다)과 같은 원칙이다.

다섯 번째 "멋있어서"는 이유가 아니다. 예외는 브랜드 표현이 브리프의 명시적 요구일 때뿐이고, 그때도 (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의 넷이 전부다.

: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에 근거를 남긴다

도구 한계와 저비용 선택

  • 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)
스크롤 리빌·진행 바·시차·스티키 헤더 스크롤 핀 고정 + 다단계 시퀀스
페이지 전환 (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). cross-document View Transitions도 Baseline Limited(Firefox 미지원)다. 두 기능 모두 @supports 가드가 필수이고, 가드 안의 초기 상태가 실제로 콘텐츠를 보이게 하는지를 반드시 확인한다(아래 "가장 흔한 사고"). [WEB-BASELINE]

가장 흔한 사고: 미지원 브라우저에서 opacity: 0이 남아 콘텐츠가 영영 안 보이는 것. 초기 상태를 @supports 블록 안에 넣어라. 밖에 두면 Firefox에서 백지가 된다.

코드 A — 버튼·링크 마이크로 인터랙션

언제: 움직임이 입력 피드백이나 상태 변화를 더 분명하게 할 때. 모든 인터랙션에는 피드백이 필요하지만 모션 자체가 필수는 아니다. 색과 그림자를 opacity로 바꾼 것은 저비용 대안의 예다.

.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)

언제: 스크롤 리빌을 쓰기로 했을 때의 구현 기본값. 리빌 자체를 걸지는 §0("섹션마다 스크롤 리빌을 거는 것은 기본값이 아니다")에서 먼저 정한다. 쓰기로 했다면 진행 바·시차·헤더 축소를 포함해 이 방식이 기본이다 — 컴포지터 스레드에서 돌아 메인 스레드가 막혀도 끊기지 않는다. scroll-driven animations는 Baseline Limited다. Firefox 안정판이 지원하지 않으므로(§3 도입부) @supports 가드 없이 쓰면 Firefox에서 리빌이 영영 실행되지 않고, 초기 상태를 가드 밖에 두면 콘텐츠가 사라진 채로 남는다. '애니메이션·스크롤 로직이 입력을 방해하지 않는다' 하드 게이트의 정답이 이것이다. [WEB-BASELINE]

/* 초기 상태를 @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 (§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 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이 더 맞는지 비교한다.

/* .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으로 만들어 전역 처리한다 */
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으로 되돌린다.

/* 탭 배경 전환 — 선택된 탭 아래로 배경이 미끄러져 들어온다.
   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])

<button popovertarget="tip">도움말</button>
<div id="tip" popover="auto" role="tooltip" class="tip">계정 복구에만 사용됩니다.</div>
.tip {
  margin: 0; padding: 0.75rem 1rem; border: 0; border-radius: var(--radius-md);
  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이 되며 자동 처리 */

프로젝트 계약 — scale(0)에서 시작하지 않는다. 0에서 커지는 진입은 평평했다가 갑자기 부풀어 보인다. scale(0.9) 이상에서 시작하고 항상 opacity와 함께 쓴다 — 위 코드의 scale: 0.96이 그 예다. [SKILL-EMIL-DESIGN-ENG]

팝오버는 클릭한 곳에서 나와야 한다. 화면 정중앙에서 페이드인하는 팝오버는 "어디서 왔는지"를 버리는 것이다. transform-origin을 트리거 위치로 잡아라. 모달은 예외다 — 뷰포트 중앙에 고정되므로 transform-origin: center를 유지한다. [SKILL-EMIL-DESIGN-ENG]

관찰 후보 — 툴팁 그룹은 첫 번째만 지연한다. 같은 그룹의 인접 트리거 사이를 연속으로 오갈 때마다 매번 딜레이·트랜지션을 다시 타면 굼떠 보인다. 첫 툴팁이 열린 뒤 일정 시간 안에 그룹 내 다른 트리거로 옮기면 지연·애니메이션 없이 즉시 연다.

let groupTimer = null;
group.addEventListener('pointerenter', ({ target }) => {
  if (!target.closest('[data-tip-trigger]')) return;
  if (groupTimer) group.dataset.tipInstant = '';   // 그룹이 방금 열려 있었으면 두 번째부터 즉시 연다
  clearTimeout(groupTimer);
  groupTimer = setTimeout(() => {                  // 1500ms 는 실측 조정 시작값
    delete group.dataset.tipInstant;
    groupTimer = null;                             // 창이 닫히면 다음 첫 툴팁은 다시 지연한다
  }, 1500);
}, true);   // pointerenter 는 버블링되지 않으므로 캡처 단계에서 그룹이 받는다
[data-tip-instant] .tip { transition-delay: 0s; transition-duration: 0s; }

[SKILL-EMIL-DESIGN-ENG]

프로젝트 계약 — 모달 스크림·비차단 패널·스택 시트는 디밍이 다르다. 모달성 과업은 배경을 어둡게 하는 스크림(::backdrop 또는 별도 레이어)과 함께 배경을 살짝 뒤로 후퇴시킨다. 사이드바처럼 흐름을 막지 않고 나란히 떠 있는 패널은 스크림 없이 반투명·오프셋만으로 존재감을 준다. 시트가 겹겹이 쌓이면 열릴 때마다 이전 레이어를 한 단계씩 더 어둡고 더 뒤로 민다. [SKILL-APPLE-DESIGN]

호버·포커스로 나타나는 콘텐츠(위 .tip 같은 툴팁)가 해제 가능·호버 유지·지속이라는 WCAG 1.4.13 조건을 만족하는지는 accessibility.md에서 확인한다. [WCAG-HOVER]

코드 E — 페이지·뷰 전환 (View Transitions API)

언제: 라우트 이동, 리스트 필터링, 썸네일 → 상세. 미지원 브라우저에서는 즉시 바뀐다(점진 향상). same-document View Transitions는 2025-10-14 Baseline newly가 됐다(§3 도입부) — 아직 widely는 아니므로 아래 transition() 래퍼의 폴백이 실제 경로다. [WEB-BASELINE]

/** 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)));
/* 저자 키프레임은 저비용 기본 선택을 쓴다. 위치·크기 보간은 브라우저가 한다 */
::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이 금지돼 있다
<!-- 줄바꿈을 직접 마크업에 넣는다. 텍스트는 온전히 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>
.split__line { display: block; overflow: hidden; }  /* 마스크. 줄 리빌은 overflow 마스크 + translate가 기본이다.
                                                          형태 자체를 잘라야 하는 경우만 코드 C-1(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"를 건다.

관찰 후보 — 시작값. 단어 단위 스플릿도 옵션이다. 줄 단위 대신 단어 단위(스태거 약 80ms 시작값)로 쪼개는 스타일도 있다 — 제목처럼 짧고 리듬을 강조하고 싶을 때다. --stagger 토큰(60ms, tokens.md §4)은 그대로 유지하고, 단어 개수가 많으면 위 §1의 스태거 예산식으로 총 소요 상한을 확인한다. [SKILL-BETTER-UI]

코드 G — 숫자 카운트업 (자릿수 스트립, transform만)

언제: 실적 숫자 하나. 사용자가 준 진짜 숫자에만 쓴다 — 지어낸 숫자는 '사용자가 주지 않은 수치를 그럴듯한 값으로 넣지 않는다' 하드 게이트에 걸린다. @property --count 방식은 커스텀 속성을 매 프레임 바꿔 저비용 속성(transform/opacity) 밖의 속성을 애니메이션하게 된다. 자릿수를 굴려라.

<p class="stat">
  <span class="sr-only">1284</span>
  <span class="odo" data-value="1284" aria-hidden="true"></span>
  <span>개의 프로젝트</span>
</p>
.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; } }
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초 넘게 움직이면 정지 수단 필요)에 걸린다. 근거는 취향이 아니라 어지럼·구역질·두통이라는 실제 신체 반응이다.

@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()).

코드 I — 테마 전환 시 트랜지션 억제

언제: 라이트·다크를 런타임에 토글하는 프로젝트 전부. 넣지 않으면 테마가 바뀌는 순간 색·그림자·테두리 트랜지션이 동시에 발화해 화면 전체가 번져 보인다.

OS 설정이나 인앱 토글로 테마가 바뀔 때, 전역에 transition: none을 순간적으로 주입하고 강제 리플로우한 뒤 다음 프레임에 제거한다.

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]


패럴랙스는 섹션마다 다른 말을 해야 한다

같은 패럴랙스를 모든 섹션에 똑같이 걸면 그건 문법이 아니라 벽지다. 그 섹션이 주장하는 것이 무엇인지 먼저 말하고, 그 주장을 움직임으로 옮겨라.

실측 사례 — designpaca 소개 페이지의 섹션별 배정:

섹션 주장 움직임 왜
히어로 "결정하지 않으면 기본값이 나온다" 글자 ↑ / 배경 ↓ (반대 방향) 결정이 떠오르고 기본값이 가라앉는다. 같은 방향이면 그냥 느린 스크롤로 읽힌다
파이프라인 단계를 밟으면 결과가 나온다 단계명 고정, 산출물만 지연 옆의 것이 고정이어야 "저것 때문에 이것"이 보인다. 둘 다 움직이면 인과가 사라진다
사례 서로 다른 두 결과물 두 판이 다른 속도로 어긋남 같은 스킬에서 다른 것이 나온다
재료 표면은 한 겹이 아니다 타일이 서로 다른 깊이 주장 자체가 레이어다
프리플라이트 시간순 기록 뒤 항목일수록 더 늦게 따라옴 로그는 흐른다
수치 공개한 값은 검증 가능하다 없음 숫자가 흔들리면 값도 흔들려 보인다. 계기판의 바늘은 떨지 않는다

마지막 줄이 가장 중요하다. 움직이지 않는 것도 결정이다. 안 움직이는 섹션이 하나도 없으면 움직임에 의미가 없다는 뜻이고, design.md 에 "여기는 의도적으로 정지"라고 적어두지 않으면 다음 사람이 "빠뜨린 것"으로 보고 채운다.

패럴랙스의 원형은 "고정"이다

배경이 뷰포트에 멈춰 있고 콘텐츠만 그 위를 지나갈 때 원경이 된다. 느리게 같이 움직이는 것으로는 그 인상이 안 나온다 — 속도만 다를 뿐 방향이 같기 때문이다. "패럴랙스를 넣었는데 티가 안 난다"의 절반은 이것이다.

background-attachment: fixed 는 쓰지 마라. iOS Safari 가 GPU 메모리 때문에 그것을 throttle 해서 스크롤 중 배경이 튀거나 리셋된다.

대신 부모를 position: fixed 의 컨테이닝 블록으로 만든다:

.section { position: relative; clip-path: inset(0); }  /* 컨테이닝 블록 + 클립 */
.section > .bg { position: fixed; inset: 0; z-index: -1; }
.section > .bg img { width: 100%; height: 100%; object-fit: cover; }

clip-path 는 두 가지를 동시에 한다 — 배경을 섹션 밖으로 못 나가게 자르고, fixed 자식의 기준을 뷰포트가 아니라 자기 자신으로 잡아준다. 그래서 배경이 화면에 고정되면서도 그 섹션 안에서만 보인다.

두 가지 대가가 있다. 알고 써라.

  1. 조상에 transform·filter·mask 를 추가하면 즉시 깨진다. 그것들도 컨테이닝 블록을 만들기 때문에 fixed 가 섹션 기준이 되어 고정이 사라진다. 위아래 페이드 마스크를 얹고 싶어도 못 얹는다 — 경계는 clip 으로 딱 잘리는 것을 받아들여야 한다
  2. prefers-reduced-motion 에서는 position: absolute 로 되돌려라. 고정 배경은 스크롤 중 시각 부하가 크다

전부 고정하지는 마라. 가까이 있어야 할 재질(종이·질감)까지 원경으로 두면 거짓말이 된다. 원경은 fixed, 중경은 아래의 비율 이동, 근경은 콘텐츠와 함께.

이동량은 절대값이 아니라 컨테이너 대비 비율이다

패럴랙스가 "안 보인다"의 대부분은 이동량이 모자란 것이다.

공식은 하나다. 배경을 컨테이너보다 크게 만들고, 그 초과분 전체를 움직인다.

이미지 높이 = 컨테이너 × (1 + depth)
이동량      = 컨테이너 × depth
          = 이미지 높이의 depth / (1 + depth)

depth: 0.45 면 이미지는 145%, 이동은 이미지 자기 높이의 31%. 섹션이 2000px 이면 900px 을 움직인다.

.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). 다만 그러면 <img loading="lazy"> 의 지연 로딩과 반응형 srcset 을 잃는다. 무거운 사진이면 <img> 를 절대배치하고 transform 을 쓰는 쪽이 낫다.

overflow: hidden 이 view() 타임라인을 죽인다

이것 하나에 패럴랙스가 통째로 멈춘다. 그리고 조용히 멈춘다.

overflow: hidden 은 스크롤 컨테이너를 만든다. 그러면 그 안에 있는 자식의 animation-timeline: view() 는 문서가 아니라 그 상자를 기준으로 잡힌다. 자식은 상자 안에서 위치가 변하지 않으므로 진행도가 고정되고, 애니메이션은 어중간한 한 프레임에서 굳는다.

  • 콘솔 에러 없음. animation-name·animation-timeline computed 값도 전부 정상
  • getComputedStyle(el).transform 을 두 스크롤 위치에서 재봐야 알 수 있다. 실측에서 이동 0px

잘라내야 한다면 overflow: clip 을 쓰거나 조상 섹션에서 처리해라. clip 은 스크롤 컨테이너를 만들지 않는다.

어두운 배경 위 광원 이미지는 opacity 로 누르지 마라

opacity 는 이미지 전체를 균일하게 죽인다 — 검정도, 빛도 같이. 그래서 어두운 광원 사진을 opacity: 0.5 로 깔면 있는지조차 안 보인다.

.bg img { mix-blend-mode: screen; opacity: 0.9; }

screen 은 검정을 투명하게 만들고 밝은 곳만 더한다. 그래서 불투명도를 높게 유지해도 본문 대비를 해치지 않는다. 밝은 이미지(종이·질감)는 반대로 normal 이 맞다.

생성형 이미지를 배경으로 쓸 때 특히 조심해라. 프롬프트에 "본문 뒤에 깔리니 어둡게"를 너무 강하게 넣으면 안전한 이미지가 아니라 없는 이미지가 나온다. 대비는 합성 방식으로 풀고, 이미지 자체는 볼 수 있게 만들어라.

sticky 캔버스의 진행도는 섹션 높이가 아니다

캔버스를 position: sticky; top: 0; height: 100dvh 로 붙여두고 섹션이 지나가게 하는 패턴에서, 진행도를 섹션 높이로 나누면 안 된다.

캔버스가 실제로 화면에 머무는 거리는 (섹션 높이 − 뷰포트 높이) 다. 섹션 높이를 쓰면 캔버스가 위로 사라진 뒤에도 진행이 남아 끝에 영영 도달하지 못한다.

const travel = Math.max(section.getBoundingClientRect().height - innerHeight, 1);
const progress = clamp(-section.getBoundingClientRect().top / travel, 0, 1);

실측: 섹션 1493px · 뷰포트 900px 에서 섹션 높이로 나눴더니 6단계 중 3단계에서 멈췄다. height - innerHeight 로 바꾸자 6단계를 다 돌았다.

그리고 진행도의 기준 요소는 캔버스가 아니라 섹션이다. sticky/fixed 캔버스는 rect 가 변하지 않으므로 자기 rect 로 재면 진행도가 고정된다.

배경도 한 섹션에 하나다

CSS 격자와 사진을 같이 깔았다가 걷어냈다. 두 배경이 서로 경쟁했고, 격자는 배경이라기보다 표로 읽혔다. 앞에서 "한 층은 고정"이라고 한 것과 같은 이야기다 — 층을 늘리는 것과 층이 싸우게 두는 것은 다르다.

패럴랙스에는 기준면이 필요하다

배경이 단색이면 패럴랙스는 성립하지 않는다. 앞의 것이 몇 px 어긋나 봐야 비교할 대상이 없어서 아무도 못 느낀다. 시차는 두 층 사이에서만 보인다.

그렇다고 배경 이미지를 사 오지 마라. 그 페이지가 이미 주장하는 것을 배경으로 그려라.

페이지가 주장하는 것 배경으로 그릴 것
값이 토큰 체계에서 나온다 간격 눈금 격자. 셀 크기를 실제 space 토큰으로
재질·표면이 주제다 광원 블롭. 섹션마다 자리를 옮긴다
데이터·계측이 주제다 눈금, 축, 등고선
문서·아카이브다 괘선, 여백선

격자를 그릴 때 두 가지를 지켜라.

  1. 가장자리를 마스크로 지운다. 화면 끝까지 격자가 가면 배경이 아니라 표가 된다
  2. 배경이 가장 적게 움직인다. 앞의 것보다 크게 움직이면 앞으로 튀어나와 보인다
.bg-grid::before {
  content: ""; position: absolute; inset: -12% 0; z-index: -1;
  background-image:
    repeating-linear-gradient(to right,  var(--line-strong) 0 1px, transparent 1px var(--grid-cell)),
    repeating-linear-gradient(to bottom, var(--line-strong) 0 1px, transparent 1px var(--grid-cell));
  mask-image: radial-gradient(135% 95% at 50% 45%, black 22%, transparent 92%);
}

세 층이 되면 시차가 확실해진다 — 실측: 배경 격자 +14.9px, 산출물 -14px, 단계명 0. 한 층은 반드시 고정이어야 한다. 전부 움직이면 기준이 없어 그냥 흔들리는 페이지다.

구현은 scroll-driven animation 으로

@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: view()) {
    .par-slow, .par-fast, .par-lag {
      animation: par linear both;
      animation-timeline: view();
      animation-range: cover 0% cover 100%;
    }
    @keyframes par {
      from { transform: translate3d(0, var(--par-from), 0); }
      to   { transform: translate3d(0, var(--par-to), 0); }
    }
  }
}

두 가지를 지켜라.

  1. transform 만 애니메이션한다. opacity 를 걸면 범위 끝에 도달하지 못한 요소가 영영 투명하게 남는다 — 지원 안 되는 브라우저에서는 제자리에 그대로 있을 뿐이지만, opacity 는 콘텐츠를 지운다. 실측에서 6단계 중 3개가 그렇게 사라졌다.
  2. 페이지 최상단 요소는 view() 가 아니라 scroll() 을 써라. 히어로는 처음부터 보이므로 entry 구간이 이미 지나 있어 시작점이 확정되지 않는다. animation-timeline: scroll(root block); animation-range: 0 92vh;

새 클래스를 만들 때 애니메이션 셀렉터에 추가하는 것을 잊지 마라. 변수만 정의하면 아무 일도 일어나지 않고, 조용히 실패해서 검출도 안 된다.


읽기 간격과 스크롤 구간은 같은 손잡이가 아니다

sticky 캔버스의 진행도는 섹션 높이 − 뷰포트 다. 그래서 "스크롤 구간이 짧다" 는 문제를 만나면 목록 항목의 간격을 벌려 섹션을 늘리고 싶어진다. 실측에서 그렇게 했다.

  • --space-4(24px): 섹션 1493px → sticky 이동 593px. 조금만 굴려도 여섯 단계를 다 지나침
  • --space-7(96px): 게이트당 300px 확보. 대신 항목 사이가 224px 로 벌어져 여섯 개가 한 목록으로 안 읽힘. 사용자 지적: "각 아이템별로 마진 공간이 너무 크다"

하나의 값으로 두 가지를 맞추려 하면 둘 다 나빠진다. 나눠라.

.stop  { padding-block: var(--stop-pad); }   /* 읽기 리듬 — 눈으로 정한다 */
.after { padding-bottom: 62dvh; }            /* 스크롤 구간 — 계산으로 정한다 */

판정 기준: 게이트 하나당 200px 이상. 휠 한 번이 100~150px 이므로 그 아래면 "조금만 스크롤해도 다 지나가는" 과민한 반응이 된다.

스크롤 구간이 모자라면 여백이 아니라 진행도 공식을 고쳐라

위 문제를 처음에는 목록 뒤에 여백을 넣어 풀었다. padding-bottom: 62dvh. 계산은 맞았고 게이트는 끝까지 돌았는데, 화면 절반이 넘는 빈 공간이 남았다. 사용자가 본 것은 그 빈칸이었다 — "어마어마하게 큰 이상한 게 있는데요?"

그 여백은 콘텐츠가 아니라 스크롤 연료였다. 원인은 진행도를 섹션 높이로 잰 것이다. 그러면 진행 구간이 섹션 길이에 묶이고, 구간을 늘리는 유일한 방법이 섹션을 늘리는 것뿐이다.

진행도를 읽는 대상이 화면을 지나는 동안으로 바꾸면 여백이 필요 없어진다.

// 섹션이 아니라 목록에서 읽는다. 게이트가 도는 구간과 읽는 구간이 같아진다.
const rect = list.getBoundingClientRect();
const start = innerHeight * 0.7;   // 목록 위쪽이 여기 오면 진행 0
const end   = innerHeight * 0.3;   // 목록 아래쪽이 여기를 빠져나가면 진행 1
const travel = rect.height + (start - end);
const progress = clamp01((start - rect.top) / travel);

(start - end) 만큼이 공짜로 얻는 구간이다. 뷰포트의 40% 를 쓰면 목록 963px 짜리가 1323px 구간이 되고, 게이트 6 개면 하나당 220px 이다. 여백 558px 을 지우고도 기준을 넘겼다.

확인할 것: sticky 요소가 progress === 1 에 닿기 전에 사라지지 않아야 한다. 목록 아래쪽이 화면 30% 에 있을 때 섹션 하단이 아직 뷰포트 안이면 안전하다.


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 — 번들 없이 명령형 제어

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()로 스코프를 잡고 정리 함수를 반환한다.

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 채택 게이트를 넘지 않고도 만들 수 있다.

.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으로 안 풀릴 때)만 쓴다. 매 스크롤 이벤트가 아니라 다음 페인트 한 번으로 묶는다.

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. 체크리스트 — 5단계 프리플라이트에서 그대로 돌린다

하나라도 실패하면 4-4 복귀다.

게이트 직결

  • 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 값이 없다
  • 이펙트를 전부 끈 상태에서 페이지가 완성돼 있다
  • 등장 모션이 감사 도구의 대기시간보다 짧다 — 시각 회귀가 뷰 전환 뒤 N ms 에 스크린샷을 찍는다면 등장 애니메이션은 그보다 짧게(관례: ≤70ms). 아니면 회귀가 매번 다른 프레임을 찍어 흔들린다. 자세한 규칙은 audit-gate.md 하니스 10

동작

  • scroll-driven을 쓴 곳에 @supports 가드가 있고 초기 상태가 그 블록 안에 있다. Firefox에서 백지가 되지 않는다
  • 스크롤 리빌이 위아래로 오갈 때 재생되지 않는다 (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]

접근성 — 타협 없음

  • prefers-reduced-motion: reduce를 켜고 실제로 확인했다. 전부 꺼지지 않고 위치 이동·시차·루프만 죽고 페이드·상태 변화·진행 표시는 남는다
  • 5초 넘게 자동 재생되는 애니메이션에 정지 수단이 있다 (WCAG 2.2.2)
  • 큰 면적이 무한 회전·확대·시차 이동하지 않는다 (전정기관 장애)
  • 텍스트가 글자 단위로 쪼개져 있지 않다. 쪼갰다면 원문이 DOM에 온전히 있고 시각 사본에 aria-hidden이 있다
  • 카운트업의 최종값이 스크린리더에 노출된다
  • 포커스 링이 애니메이션되지 않고 즉시 보인다. Tab 순서가 시각 순서와 일치하고, 전환 후 포커스가 새 콘텐츠를 따라간다
  • 호버에만 있는 정보가 없다 (터치·키보드에서 접근 불가)
  • 자동 재생이든 인터랙션 트리거든 초당 3회 넘게 깜빡이지 않는다 (WCAG 2.3.1). 세부 판정은 accessibility.md [WCAG-FLASH]

성능

  • 모션 라이브러리 추가분이 tokens.md §5 예산 안이다. 넘겼으면 올린 이유를 명시했다
  • 상시 will-change 요소의 레이어 메모리와 실제 기기 성능이 프로젝트 예산 안임을 확인했다(§2 will-change 표)
  • 드래그·제스처가 있다면 입력 경로 지연을 감사했다 — 기준과 방법은 interaction-feel.md
  • GSAP·Motion·Lenis·SplitText·IntersectionObserver 등 이 문서에서 쓴 라이브러리 인스턴스가 이탈 시 정리된다(§4 정리 규율 표)
  • Lenis를 썼다면 §4의 입력·키보드·포커스·앵커·감소 모션·예산 검증을 실제로 마쳤다
  • 6단계 design.md에 채택한 모션 · 안 쓰기로 한 모션과 그 이유를 적었다

근거: research/motion/01~03 (조사일 2026-08-20)