designpaca/packages/skill/references/motion.md
Yun Chan af3a08397e 배경을 뷰포트에 고정하고, 게이트 씬을 파이프라인으로 옮긴다
"스크롤할 때 이미지가 고정되어 원경처럼 보이는 게 패럴랙스의 꽃"이라는
지적이 맞았다. 느리게 같이 움직이는 것으로는 그 인상이 안 나온다 —
속도만 다를 뿐 방향이 같기 때문이다.

고정 배경
  background-attachment: fixed 는 iOS Safari 가 GPU 메모리 때문에
  throttle 해서 스크롤 중 배경이 튄다. 쓰지 않았다.
  섹션에 clip-path: inset(0) 을 걸어 position: fixed 의 컨테이닝 블록으로
  만들었다. 배경이 화면에 멈춘 채 그 섹션 안에서만 보인다.
  실측: 스크롤 1000px 구간에서 viewportTop 이 계속 0.

  대가 둘을 받아들였다
    조상에 transform/filter/mask 를 못 쓴다 — 페이드 마스크를 포기했다
    prefers-reduced-motion 에서는 absolute 로 되돌린다

  전부 고정하지는 않았다. 종이는 손에 닿는 거리라 원경으로 두면 거짓말이다
    재료·설치·프리플라이트  fixed  원경
    사례                    drift  두 결과물이 흐르는 자리
    조판                    drift  종이는 가깝다

게이트 씬을 파이프라인으로
  히어로에서는 의미를 판독기가 자막으로 설명해야 했다. 그림이 스스로
  말하지 못한다는 뜻이다. 옆에 여섯 단계가 적힌 자리로 옮기니 자막이
  필요 없어졌다 — 판을 통과하면 그 단계가 켜진다.

  캔버스는 sticky, 진행도는 섹션에서 읽는다.
  travel 을 섹션 높이로 잡았더니 캔버스가 사라진 뒤에도 진행이 남아
  6단계 중 3단계에서 멈췄다. (섹션 높이 - 뷰포트 높이)로 고쳤다.
  캔버스 위 텍스트 대비 14.2:1.

스킬
  motion.md  패럴랙스의 원형은 고정 · clip-path 기법과 그 대가
             sticky 캔버스의 진행도 계산
  three.md   씬의 의미는 옆의 콘텐츠가 반응할 때 성립한다.
             자막이 필요하면 배치를 의심해라
2026-08-20 16:08:11 +09:00

40 KiB
Raw Blame History

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의 넷이 전부다.

: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 ::afterborder를 두고 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: 0fr1fr로 만들되 grid-template-rows에 transition을 걸지 않는다. 자식의 opacity·translate만 전환하면 게이트를 완전히 통과한다
  3. grid-template-rows에 transition을 거는 관용구가 널리 쓰이지만 그건 여전히 레이아웃 애니메이션이고 게이트 #8 grep에 걸린다. max-height보다 정확할 뿐 합성 가능하지는 않다. 굳이 쓰겠다면 design.md에 예외로 적어라

오해 방지 — 게이트 #8이 허용하는 것

  • translate / rotate / scale 개별 속성은 transform 계열이다. 통과다. 축약형보다 낫다(속성끼리 안 덮어쓴다)
  • display·overlaytransition-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로 바꾼 것이 요점이다.

.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의 정답이 이것이다.

/* 초기 상태를 @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이다. 절대 쓰지 마라.

/* .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));
}

코드 D — 모달·팝오버 진입/퇴장 (@starting-style)

언제: display: none에서 나타나거나 top layer에 올라가는 모든 것. 라이브러리가 필요 없다.

<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이 되며 자동 처리 */

팝오버는 클릭한 곳에서 나와야 한다. 화면 정중앙에서 페이드인하는 팝오버는 "어디서 왔는지"를 버리는 것이다. transform-origin을 트리거 위치로 잡아라.

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

언제: 라우트 이동, 리스트 필터링, 썸네일 → 상세. 미지원 브라우저에서는 즉시 바뀐다(점진 향상).

/** 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)));
/* 저자 키프레임은 opacity와 transform 계열만 쓴다. 위치·크기 보간은 브라우저가 한다 */
::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; }  /* 마스크. 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"를 건다.

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

언제: 실적 숫자 하나. 사용자가 준 진짜 숫자에만 쓴다 — 지어낸 숫자는 게이트 #11이다. @property --count 방식은 커스텀 속성을 매 프레임 바꿔 게이트 #8에 걸린다. 자릿수를 굴려라.

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


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

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

실측 사례 — 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;

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


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은 더 이상 "돈 때문에 못 쓰는 라이브러리"가 아니다. 스크롤 시퀀스나 텍스트 분해가 필요하면 주저 없이 쓴다. 다만 필요 없으면 여전히 깔지 않는다.

Lenis (스무스 스크롤) — 논쟁이 있다. 양쪽을 알고 결정해라

반대: 스크롤은 사용자가 기대하는 기기 고유의 물리다. 바꾸는 건 시스템 관습 침해다. 관성이 붙으면 정확한 위치에 멈추기 어렵고 운동 장애가 있는 사용자에게 치명적이다. 지연은 모든 사용자에게 인지 비용이다.

찬성: Lenis는 구식 스크롤 재킹이 아니다. 네이티브 scrollTop을 이징할 뿐이라 스크롤바·앵커·position: sticky·스크린리더 탐색이 그대로 작동한다. WebGL↔DOM 동기화는 스크롤을 메인 스레드에서 통제해야만 드리프트가 사라진다.

판단: 대시보드·관리도구·문서·커머스·폼 → 쓰지 않는다(scroll-behavior: smooth로 충분). 콘텐츠 사이트·블로그·뉴스 → 쓰지 않는다(읽기를 방해한다). 브랜드 랜딩·포트폴리오·캠페인 → 조건 충족 시 쓸 수 있다. WebGL↔DOM 동기화 → 사실상 필수.

쓴다면 전부 지켜라: respectReducedMotion: true 명시 · duration 1.2 이하 · syncTouch: false · 모달 열릴 때 lenis.stop() · 내부 스크롤 요소(코드 블록·지도)에 data-lenis-prevent · 키보드 스크롤(Space·PageDown·Home·End)을 실제로 눌러보고 확인.


5. 체크리스트 — 5단계 프리플라이트에서 그대로 돌린다

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

게이트 직결

  • transition·@keyframes를 전부 grep했다. 애니메이션되는 속성이 opacity·transform·translate·rotate·scale뿐이다 (#8)
  • background-color·box-shadow·filter·height·width·top·left·color를 전환하는 곳이 없다. 있으면 §2 우회표로
  • addEventListener('scroll'이 소스에 없다 (#10)
  • duration·이징이 전부 var(--dur-*)·var(--ease-*)다. 인라인 ms 값이 없다
  • 이펙트를 전부 끈 상태에서 페이지가 완성돼 있다

동작

  • scroll-driven을 쓴 곳에 @supports 가드가 있고 초기 상태가 그 블록 안에 있다. Firefox에서 백지가 되지 않는다
  • 스크롤 리빌이 위아래로 오갈 때 재생되지 않는다 (both / unobserve())
  • 첫 화면 콘텐츠가 애니메이션 없이 즉시 읽힌다
  • 퇴장 duration이 진입의 0.65배다. (항목수 1) × --stagger + duration ≤ 800ms
  • 한 시점에 움직이는 독립 요소가 3개 미만이고, 애니메이션 중에도 클릭·키 입력이 먹는다

접근성 — 타협 없음

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

성능

  • 모션 라이브러리 추가분이 tokens.md §5 예산 안이다. 넘겼으면 올린 이유를 명시했다
  • will-change가 상시 걸린 요소가 10개 미만이다
  • Lenis를 썼다면 §4의 조건 6개를 전부 충족했고 키보드 스크롤을 실제로 테스트했다
  • 6단계 design.md채택한 모션 · 안 쓰기로 한 모션과 그 이유를 적었다

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