designpaca/research/motion/02-css-native.md
Yun Chan 8808c672dc designpaca 초기 구현 — 스킬 · 설치 CLI · 배포 파이프라인
웹 디자인 파이프라인 스킬과 이를 5개 에이전트에 설치하는 CLI 를 담은 모노레포.

스킬 (packages/skill)
- SKILL.md 261줄 + 참조 문서 16개 3,349줄. progressive disclosure 로
  본문은 절차와 인덱스만, 지식은 references/ 로 분리
- 0~6단계 파이프라인. 규모에 따라 전체·연장·국소 세 경로로 분기
- 하드 게이트 12개는 grep·카운트로 검증 가능한 것만. 취향 판단은 제외
- 미학 프리셋 5종, AI 슬롭 지문 목록, 한글 조판 규칙,
  SVG 필터·three.js·인터랙티브 모션·HTML-in-Canvas 실전 지침

설치 CLI (packages/cli, packages/core)
- npx designpaca 온보딩 TUI. Claude Code · Codex · Cursor · Windsurf · AGENTS.md
- 매니페스트에 설치 시점 해시를 기록해 사용자가 고친 파일은 update 가 건너뛴다
- 타깃별로 본문의 references/ 경로를 실제 설치 위치로 재작성
- AGENTS.md 는 항상 로드되므로 본문 대신 303자 포인터만 주입
- Windsurf 는 12,000자 상한 초과 시 설치를 차단

배포 (build/ci, .forgejo/workflows)
- 태그 v* → 검사·테스트·빌드 → npmjs 배포 + Forgejo 레지스트리 미러
  → draft 릴리스 → Cloudflare Pages. 재실행 멱등

근거 (research/)
- 약 250개 웹 소스 조사 결과와 도그푸딩 검증 2건. 스킬의 모든 수치는 여기서 나온다

테스트 22개 통과 (core 16 · cli 6)
2026-08-20 10:48:00 +09:00

46 KiB
Raw Permalink Blame History

02. CSS 네이티브 모션 — 라이브러리 없이 되는 것 전부

원칙: CSS로 되면 CSS로 한다. 라이브러리는 CSS가 못 하는 일을 할 때만 꺼낸다. CSS 애니메이션은 상당 부분 컴포지터 스레드에서 돌아가므로 메인 스레드가 막혀도 프레임을 떨어뜨리지 않는다. 이 문서의 모든 코드는 복사해서 그대로 동작하는 완성 코드다.


0. 지원 현황 — 2026년 8월 기준 (webstatus.dev API 직접 조회)

기능 Baseline Chrome Firefox Safari 프로덕션 판단
prefers-reduced-motion widely (2020-01) 74 63 10.1 무조건 쓴다
linear() 이징 widely (2023-12) 113 112 17.2 무조건 쓴다
@property (registered custom properties) newly (2024-07) 85 128 16.4 폴백 있으면 쓴다
@starting-style newly (2024-08) 117 129 17.5 폴백 있으면 쓴다
transition-behavior: allow-discrete newly (2024-08) 117 129 17.4 폴백 있으면 쓴다
Popover API newly (2025-01) 116 125 17 / iOS 18.3 쓴다
View Transitions (same-document) newly (2025-10) 111 144 18 점진 향상으로 쓴다
Cross-document View Transitions limited 126 18.2 점진 향상 전용 (Firefox 미지원)
Scroll-driven animations limited 115 ✗(플래그) 26 @supports 가드 필수
interpolate-size / calc-size() limited 129 Chromium 전용 향상
content-visibility newly (2025-09) 108 130 26 쓴다
Long Animation Frames API limited 123 계측 전용, Chromium만

핵심 판단: scroll-driven animations와 cross-document view transitions는 아직 Baseline이 아니다. 반드시 @supports 가드를 씌우고, 미지원 브라우저에서도 콘텐츠가 정상적으로 보이게 만든다. 특히 스크롤 리빌은 미지원 브라우저에서 opacity: 0으로 남아 콘텐츠가 안 보이는 사고가 잦다.


1. transition 기본기 — 그러나 제대로

1.1 비대칭 타이밍 (진입 ≠ 퇴장)

가장 저렴하면서 가장 효과가 큰 기법. 상태 규칙에 transition을 다시 선언하면 그 상태로 갈 때의 타이밍만 바뀐다.

.card {
  --lift: 0px;
  translate: 0 var(--lift);
  box-shadow: 0 1px 2px rgb(0 0 0 / 0.08);

  /* 마우스가 떠날 때: 여유롭게 */
  transition:
    translate 340ms cubic-bezier(0.16, 1, 0.3, 1),
    box-shadow 340ms cubic-bezier(0.16, 1, 0.3, 1);
}

.card:hover,
.card:focus-visible {
  --lift: -6px;
  box-shadow: 0 12px 32px rgb(0 0 0 / 0.16);

  /* 마우스가 들어올 때: 즉각적으로 */
  transition:
    translate 140ms cubic-bezier(0.16, 1, 0.3, 1),
    box-shadow 140ms cubic-bezier(0.16, 1, 0.3, 1);
}

1.2 개별 transform 속성 (transform 충돌 해결)

translate, rotate, scale은 독립 CSS 속성이다. 이걸 쓰면 서로 다른 타이밍으로 애니메이션할 수 있고, 여러 규칙이 transform을 덮어쓰는 문제가 사라진다. (Baseline widely, 2022년~)

.tile {
  translate: 0 0;
  rotate: 0deg;
  scale: 1;

  transition:
    translate 200ms cubic-bezier(0.16, 1, 0.3, 1),
    rotate    500ms cubic-bezier(0.34, 1.56, 0.64, 1),  /* 회전만 늦게, 오버슛 */
    scale     200ms cubic-bezier(0.16, 1, 0.3, 1);
}

.tile:hover {
  translate: 0 -8px;
  rotate: -2deg;
  scale: 1.03;
}

주의: transform 단축 속성과 개별 속성을 같은 요소에 섞으면 예측이 어려워진다. 하나만 쓴다. 적용 순서는 항상 translate → rotate → scale → transform 이며, 이 순서는 CSS 선언 순서와 무관하다.

1.3 "doom flicker" 방지

호버 시 요소 자체가 움직이면 커서가 요소를 벗어나 → hover 해제 → 되돌아옴 → 다시 hover의 무한 깜빡임이 생긴다. 트리거 요소와 움직이는 요소를 분리한다.

<button class="lift-btn"><span class="lift-btn__inner">호버해 보세요</span></button>
.lift-btn {
  border: 0;
  padding: 0;
  background: none;
  cursor: pointer;
  /* 버튼 자체는 절대 움직이지 않는다 → 히트 영역 고정 */
}

.lift-btn__inner {
  display: block;
  padding: 12px 24px;
  border-radius: 10px;
  background: #111;
  color: #fff;
  translate: 0 0;
  transition: translate 300ms cubic-bezier(0.16, 1, 0.3, 1);
}

.lift-btn:hover .lift-btn__inner,
.lift-btn:focus-visible .lift-btn__inner {
  translate: 0 -4px;
  transition-duration: 120ms;
}

1.4 드롭다운 hover 그레이스 기간

메뉴 경계에서 커서가 살짝 벗어나도 바로 닫히지 않게 한다.

.dropdown {
  opacity: 0;
  translate: 0 -6px;
  pointer-events: none;
  /* 닫힐 때: 300ms 유예 후 400ms에 걸쳐 사라짐 */
  transition:
    opacity 400ms ease,
    translate 400ms ease;
  transition-delay: 300ms;
}

.dropdown-wrapper:hover .dropdown,
.dropdown-wrapper:focus-within .dropdown {
  opacity: 1;
  translate: 0 0;
  pointer-events: auto;
  /* 열릴 때: 즉시 */
  transition-duration: 140ms;
  transition-delay: 0ms;
}

2. @starting-style + transition-behavior: allow-discrete

2.1 문제와 해결

display: nonedisplay: block이산(discrete) 속성이라 전환되지 않는다. 또 DOM에 새로 삽입된 요소는 "이전 스타일"이 없어서 transition이 시작되지 않는다.

  • @starting-style = 요소가 처음 렌더링될 때의 출발 스타일을 정의 (transition에만 적용, animation에는 무의미)
  • transition-behavior: allow-discrete = display, overlay 같은 이산 속성도 전환 목록에 포함시켜 전환이 끝날 때까지 값을 유지하게 함
  • overlay는 top layer(popover/dialog) 이탈을 애니메이션 종료까지 지연시키는 속성

규칙: @starting-style 블록은 원본 규칙 뒤에 놓는다. 명시도가 같아 순서로 승부가 난다.

2.2 완성 코드 — <dialog> 모달

<button id="open-modal">모달 열기</button>

<dialog id="demo-modal" class="modal" aria-labelledby="modal-title">
  <h2 id="modal-title">정말 삭제할까요?</h2>
  <p>이 작업은 되돌릴 수 없습니다.</p>
  <form method="dialog" class="modal__actions">
    <button value="cancel">취소</button>
    <button value="confirm" class="is-primary">삭제</button>
  </form>
</dialog>
.modal {
  /* 닫힌 상태 = 퇴장 애니메이션의 종착점 */
  opacity: 0;
  translate: 0 16px;
  scale: 0.97;

  border: 0;
  border-radius: 16px;
  padding: 28px 32px;
  max-width: min(90vw, 420px);
  background: Canvas;
  color: CanvasText;
  box-shadow: 0 24px 64px rgb(0 0 0 / 0.24);

  transition:
    opacity   195ms cubic-bezier(0.5, 0, 0.75, 0),
    translate 195ms cubic-bezier(0.5, 0, 0.75, 0),
    scale     195ms cubic-bezier(0.5, 0, 0.75, 0),
    display   195ms allow-discrete,
    overlay   195ms allow-discrete;
}

/* 열린 상태 */
.modal[open] {
  opacity: 1;
  translate: 0 0;
  scale: 1;

  transition:
    opacity   300ms cubic-bezier(0.16, 1, 0.3, 1),
    translate 300ms cubic-bezier(0.16, 1, 0.3, 1),
    scale     300ms cubic-bezier(0.16, 1, 0.3, 1),
    display   300ms allow-discrete,
    overlay   300ms allow-discrete;
}

/* 진입 애니메이션의 출발점 — 반드시 .modal[open] 규칙 뒤에 */
@starting-style {
  .modal[open] {
    opacity: 0;
    translate: 0 16px;
    scale: 0.97;
  }
}

/* 백드롭 */
.modal::backdrop {
  background: rgb(0 0 0 / 0);
  backdrop-filter: blur(0px);
  transition:
    background-color 195ms ease,
    backdrop-filter  195ms ease,
    display          195ms allow-discrete,
    overlay          195ms allow-discrete;
}

.modal[open]::backdrop {
  background: rgb(0 0 0 / 0.5);
  backdrop-filter: blur(4px);
  transition-duration: 300ms;
}

@starting-style {
  .modal[open]::backdrop {
    background: rgb(0 0 0 / 0);
    backdrop-filter: blur(0px);
  }
}

.modal__actions {
  display: flex;
  gap: 8px;
  justify-content: flex-end;
  margin-top: 24px;
}

@media (prefers-reduced-motion: reduce) {
  .modal,
  .modal[open] {
    translate: 0 0;
    scale: 1;
    transition:
      opacity 120ms linear,
      display 120ms allow-discrete,
      overlay 120ms allow-discrete;
  }
  @starting-style {
    .modal[open] { opacity: 0; translate: 0 0; scale: 1; }
  }
  .modal::backdrop,
  .modal[open]::backdrop {
    backdrop-filter: none;
    transition:
      background-color 120ms linear,
      display 120ms allow-discrete,
      overlay 120ms allow-discrete;
  }
}
document.getElementById('open-modal').addEventListener('click', () => {
  document.getElementById('demo-modal').showModal();
});

2.3 완성 코드 — Popover API 툴팁

<button popovertarget="tip-1" popovertargetaction="toggle">도움말</button>
<div id="tip-1" popover="auto" class="tip" role="tooltip">
  이 필드는 계정 복구에만 사용됩니다.
</div>
.tip {
  margin: 0;
  inset: auto;
  position: fixed;
  bottom: 24px;
  right: 24px;

  max-width: 280px;
  padding: 12px 16px;
  border: 0;
  border-radius: 10px;
  background: #1a1a1a;
  color: #fff;
  font-size: 14px;
  line-height: 1.5;

  /* 닫힌 상태 */
  opacity: 0;
  translate: 0 6px;
  scale: 0.96;

  transition:
    opacity   145ms cubic-bezier(0.5, 0, 0.75, 0),
    translate 145ms cubic-bezier(0.5, 0, 0.75, 0),
    scale     145ms cubic-bezier(0.5, 0, 0.75, 0),
    display   145ms allow-discrete,
    overlay   145ms allow-discrete;
}

.tip:popover-open {
  opacity: 1;
  translate: 0 0;
  scale: 1;
  transition-duration: 220ms;
  transition-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}

@starting-style {
  .tip:popover-open {
    opacity: 0;
    translate: 0 6px;
    scale: 0.96;
  }
}

2.4 완성 코드 — DOM에 새로 삽입되는 리스트 항목

<ul id="todo-list" class="todo"></ul>
<button id="add-todo">항목 추가</button>
.todo { list-style: none; margin: 0; padding: 0; }

.todo li {
  display: grid;
  grid-template-rows: 1fr;
  opacity: 1;
  translate: 0 0;
  overflow: hidden;

  transition:
    opacity   180ms ease,
    translate 180ms ease,
    grid-template-rows 240ms cubic-bezier(0.16, 1, 0.3, 1);
}

.todo li > .todo__row {
  min-height: 0;
  padding: 12px 16px;
  border-bottom: 1px solid rgb(0 0 0 / 0.08);
}

/* 삽입 시작점 — li 규칙 뒤에 배치 */
@starting-style {
  .todo li {
    opacity: 0;
    translate: -12px 0;
    grid-template-rows: 0fr;
  }
}

/* 제거 직전에 붙이는 클래스 */
.todo li.is-leaving {
  opacity: 0;
  translate: 12px 0;
  grid-template-rows: 0fr;
  transition-duration: 160ms;
}
const list = document.getElementById('todo-list');
let counter = 0;

document.getElementById('add-todo').addEventListener('click', () => {
  const li = document.createElement('li');
  li.innerHTML = `
    <div class="todo__row">
      항목 ${++counter}
      <button type="button" class="todo__remove" aria-label="삭제">&times;</button>
    </div>`;
  list.append(li);
});

list.addEventListener('click', (e) => {
  const btn = e.target.closest('.todo__remove');
  if (!btn) return;
  const li = btn.closest('li');
  li.classList.add('is-leaving');
  // transitionend 대신 이벤트 이름을 확인해 조기 종료 방지
  li.addEventListener('transitionend', function onEnd(ev) {
    if (ev.propertyName !== 'grid-template-rows') return;
    li.removeEventListener('transitionend', onEnd);
    li.remove();
  });
});

grid-template-rows: 0fr → 1fr 트릭: height: auto를 애니메이션하는 가장 호환성 좋은 방법. 부모를 display: grid로 두고 행을 0fr ↔ 1fr로 전환, 자식에 min-height: 0. Baseline widely. Chromium 전용인 interpolate-size보다 훨씬 안전하다 (7장 참조).


3. @property — 애니메이션 불가능하던 것을 가능하게

일반 CSS 변수는 타입이 없어서 브라우저가 "0deg에서 360deg 사이"를 보간하지 못한다. @property로 타입을 등록하면 그라디언트 각도, 색상 정지점, 임의의 숫자가 애니메이션된다.

3.1 회전하는 그라디언트 테두리

<div class="glow-card">
  <div class="glow-card__content">
    <h3>Pro 플랜</h3>
    <p>테두리만 회전합니다. 내용은 정지 상태입니다.</p>
  </div>
</div>
@property --glow-angle {
  syntax: '<angle>';
  inherits: false;
  initial-value: 0deg;
}

.glow-card {
  --glow-angle: 0deg;
  position: relative;
  padding: 2px;                 /* 테두리 두께 */
  border-radius: 18px;
  background: conic-gradient(
    from var(--glow-angle),
    #7c3aed, #ec4899, #f59e0b, #7c3aed
  );
  animation: glow-spin 6s linear infinite;
}

@keyframes glow-spin {
  to { --glow-angle: 360deg; }
}

.glow-card__content {
  border-radius: 16px;
  padding: 28px;
  background: #0b0b0f;
  color: #f4f4f5;
}

/* @property 미지원 시: 회전 대신 정적 그라디언트 */
@supports not (background: conic-gradient(from 0deg, red, blue)) {
  .glow-card { background: linear-gradient(135deg, #7c3aed, #ec4899); }
}

@media (prefers-reduced-motion: reduce) {
  .glow-card { animation: none; --glow-angle: 135deg; }
}

3.2 마우스를 따라오는 스포트라이트 (JS는 값만 넘긴다)

<div class="spotlight" id="spotlight">
  <h3>커서를 올려보세요</h3>
  <p>JS는 좌표만 CSS 변수로 넘기고, 보간은 CSS가 한다.</p>
</div>
@property --mx { syntax: '<length>'; inherits: false; initial-value: 50%; }
@property --my { syntax: '<length>'; inherits: false; initial-value: 50%; }
@property --spot-opacity { syntax: '<number>'; inherits: false; initial-value: 0; }

.spotlight {
  position: relative;
  padding: 48px;
  border-radius: 16px;
  background: #0f0f14;
  color: #e4e4e7;
  overflow: hidden;
  isolation: isolate;
  transition: --spot-opacity 260ms ease;
}

.spotlight::before {
  content: '';
  position: absolute;
  inset: 0;
  z-index: -1;
  background: radial-gradient(
    240px circle at var(--mx) var(--my),
    rgb(124 58 237 / 0.35),
    transparent 70%
  );
  opacity: var(--spot-opacity);
  /* --mx/--my 자체를 transition하면 커서를 부드럽게 따라온다 */
  transition: --mx 120ms linear, --my 120ms linear;
}

.spotlight:hover { --spot-opacity: 1; }

@media (prefers-reduced-motion: reduce) {
  .spotlight::before { transition: none; }
}
const spot = document.getElementById('spotlight');
spot.addEventListener('pointermove', (e) => {
  const r = spot.getBoundingClientRect();
  spot.style.setProperty('--mx', `${e.clientX - r.left}px`);
  spot.style.setProperty('--my', `${e.clientY - r.top}px`);
});

3.3 순수 CSS 숫자 카운트업

<span class="counter" style="--target: 1284" role="img" aria-label="1284명">
  <span class="counter__num"></span>
</span>
@property --count {
  syntax: '<integer>';
  inherits: false;
  initial-value: 0;
}

.counter {
  font-variant-numeric: tabular-nums;
  font-weight: 700;
  font-size: 2.5rem;
}

.counter__num {
  counter-reset: num var(--count);
  animation: count-up 1.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}

.counter__num::after {
  content: counter(num);
}

@keyframes count-up {
  from { --count: 0; }
  to   { --count: var(--target); }
}

@media (prefers-reduced-motion: reduce) {
  .counter__num { animation: none; --count: var(--target); }
}

접근성 필수: 카운트업은 시각 효과일 뿐이므로 부모에 aria-label로 최종 값을 명시하고 애니메이션되는 요소 자체는 스크린리더에 노출되지 않게 한다(위 코드는 ::after content라 대부분의 스크린리더가 읽지 않지만, 확실히 하려면 .counter__numaria-hidden="true").

3.4 @property 성능 주의사항

CSS 변수 변경은 paint(그리고 종종 style recalc)를 유발한다. 특히 :root나 상위 요소에 선언된 변수를 매 프레임 바꾸면 하위 트리 전체의 스타일이 재계산된다. 실측 사례로 1300개 요소에서 프레임당 8ms가 소요된 보고가 있다(120fps 예산 전체).

규칙

  1. @property는 항상 inherits: false로 선언한다 (상속을 끊어 재계산 범위를 좁힌다).
  2. 변수는 사용하는 요소에 최대한 가깝게 선언한다. :root에 애니메이션용 변수를 두지 않는다.
  3. 매 프레임 바뀌는 변수는 5개 이하 요소에만 적용한다.

4. CSS 스크롤 구동 애니메이션 (Scroll-driven Animations)

가장 중요한 사실: 이건 컴포지터 스레드에서 돈다. 메인 스레드가 막혀도 스크롤 애니메이션은 끊기지 않는다. IntersectionObserver + JS보다 근본적으로 우월하다. 단, 아직 Baseline이 아니다(Firefox 미지원). @supports 가드가 필수다.

4.1 두 종류의 타임라인

종류 함수 무엇을 추적하나 대표 용도
Scroll Progress Timeline scroll() 스크롤 컨테이너의 스크롤 진행률 (0~100%) 읽기 진행 바, 배경 시프트
View Progress Timeline view() 요소 자신이 스크롤포트를 지나가는 진행률 등장 리빌, 시차, 카드 스택

4.2 scroll() — 읽기 진행 바

<div class="progress" role="progressbar" aria-label="읽기 진행률"></div>
<article>…긴 글…</article>
.progress {
  position: fixed;
  inset-block-start: 0;
  inset-inline: 0;
  height: 3px;
  background: #7c3aed;
  transform-origin: 0 50%;
  scale: 0 1;
  z-index: 100;
}

@supports (animation-timeline: scroll()) {
  .progress {
    animation: progress-grow linear both;
    animation-timeline: scroll(root block);
  }
  @keyframes progress-grow {
    from { scale: 0 1; }
    to   { scale: 1 1; }
  }
}

/* 미지원 브라우저: 바를 아예 숨긴다 (0 스케일로 남지 않게) */
@supports not (animation-timeline: scroll()) {
  .progress { display: none; }
}

@media (prefers-reduced-motion: reduce) {
  .progress { display: none; }
}

scroll() 인자: scroll(<scroller> <axis>)

  • <scroller>: nearest(기본) | root | self
  • <axis>: block(기본) | inline | y | x

4.3 view() — 스크롤 리빌 (가장 자주 쓰는 패턴)

<section class="reveal-group">
  <div class="reveal">항목 1</div>
  <div class="reveal">항목 2</div>
  <div class="reveal">항목 3</div>
</section>
.reveal {
  padding: 48px;
  border-radius: 14px;
  background: #f4f4f5;
}

@supports (animation-timeline: view()) {
  .reveal {
    animation: reveal-in linear both;
    animation-timeline: view();
    /* 요소가 뷰포트에 20% 들어왔을 때 시작, 40% 들어왔을 때 끝 */
    animation-range: entry 20% cover 40%;
  }

  @keyframes reveal-in {
    from { opacity: 0; translate: 0 32px; }
    to   { opacity: 1; translate: 0 0; }
  }
}

@media (prefers-reduced-motion: reduce) {
  .reveal { animation: none !important; opacity: 1; translate: 0 0; }
}

both 필수: animation-fill-mode: both가 없으면 범위 밖에서 원래 스타일로 돌아가 깜빡인다. animation: reveal-in linear both에서 both가 그것이다. animation-durationauto가 기본이라 명시하지 않는다. 초를 넣으면 무시되거나 오작동한다.

4.4 animation-range — 범위 이름 완전 정리

view() 타임라인에는 5개의 명명된 범위가 있다. 요소가 스크롤포트를 통과하는 과정을 구간으로 나눈 것이다.

범위 이름 0% 시점 100% 시점
cover 요소가 스크롤포트에 닿기 시작 요소가 스크롤포트를 완전히 벗어남
entry 요소가 닿기 시작 요소가 완전히 들어옴
exit 요소가 나가기 시작 요소가 완전히 벗어남
contain 요소가 스크롤포트에 완전히 담김 (요소가 뷰포트보다 작을 때) 담긴 상태가 끝남
entry-crossing / exit-crossing 요소가 시작/끝 경계를 가로지르는 구간
/* 들어올 때 페이드인, 나갈 때 페이드아웃 — 키프레임에 범위 이름을 직접 쓴다 */
@keyframes in-and-out {
  entry 0%   { opacity: 0; translate: 0 40px; }
  entry 100% { opacity: 1; translate: 0 0; }
  exit  0%   { opacity: 1; translate: 0 0; }
  exit  100% { opacity: 0; translate: 0 -40px; }
}

@supports (animation-timeline: view()) {
  .fly-item {
    animation: in-and-out linear both;
    animation-timeline: view();
  }
}

4.5 명명 타임라인 — 다른 요소의 스크롤에 반응시키기

<div class="gallery">
  <div class="gallery__track">
    <img src="1.jpg" alt=""><img src="2.jpg" alt=""><img src="3.jpg" alt="">
  </div>
  <div class="gallery__bar"><span></span></div>
</div>
.gallery__track {
  display: flex;
  gap: 16px;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-timeline: --gallery inline;   /* 이름 있는 스크롤 타임라인 생성 */
}

.gallery__track img {
  flex: 0 0 80%;
  scroll-snap-align: center;
  border-radius: 12px;
}

.gallery {
  /* 형제 요소에게 타임라인을 보이게 하려면 공통 조상에 timeline-scope */
  timeline-scope: --gallery;
}

.gallery__bar {
  height: 4px;
  background: rgb(0 0 0 / 0.1);
  border-radius: 2px;
  margin-top: 12px;
  overflow: hidden;
}

.gallery__bar span {
  display: block;
  height: 100%;
  background: #111;
  transform-origin: 0 50%;
  scale: 0 1;
}

@supports (animation-timeline: --gallery) {
  .gallery__bar span {
    animation: bar-grow linear both;
    animation-timeline: --gallery;
  }
  @keyframes bar-grow {
    from { scale: 0 1; }
    to   { scale: 1 1; }
  }
}

timeline-scope가 핵심이다. 기본적으로 명명 타임라인은 자손에게만 보인다. 형제/사촌에게 보이게 하려면 공통 조상에 timeline-scope: --name을 선언한다.

4.6 시차(parallax) 배경 — 완성 코드

<section class="hero">
  <div class="hero__bg"></div>
  <div class="hero__content"><h1>Parallax</h1></div>
</section>
.hero {
  position: relative;
  min-height: 100svh;
  display: grid;
  place-items: center;
  overflow: hidden;
  view-timeline: --hero block;   /* 자기 자신을 뷰 타임라인으로 */
}

.hero__bg {
  position: absolute;
  inset: -20% 0;                 /* 이동 여유분 확보 */
  background: url('/hero.jpg') center / cover no-repeat;
  will-change: translate;
}

@supports (animation-timeline: view()) {
  .hero__bg {
    animation: parallax linear both;
    animation-timeline: --hero;
    animation-range: cover 0% cover 100%;
  }
  @keyframes parallax {
    from { translate: 0 -10%; }
    to   { translate: 0 10%; }
  }
}

.hero__content { position: relative; z-index: 1; }

@media (prefers-reduced-motion: reduce) {
  .hero__bg { animation: none; translate: 0 0; inset: 0; }
}

시차는 전정기관 장애의 1순위 유발 요인이다. prefers-reduced-motion에서 반드시 완전히 끈다. 이동 폭은 뷰포트 높이의 10~15% 이내로 제한한다.

4.7 스티키 헤더 축소

.site-header {
  position: sticky;
  top: 0;
  z-index: 50;
  background: rgb(255 255 255 / 0.9);
  backdrop-filter: blur(8px);
  --header-pad: 24px;
  padding-block: var(--header-pad);
  box-shadow: 0 0 0 rgb(0 0 0 / 0);
}

@property --header-pad {
  syntax: '<length>';
  inherits: false;
  initial-value: 24px;
}

@supports (animation-timeline: scroll()) {
  .site-header {
    animation: header-shrink linear both;
    animation-timeline: scroll(root block);
    /* 문서 처음 200px 스크롤 동안만 진행 */
    animation-range: 0 200px;
  }
  @keyframes header-shrink {
    to {
      --header-pad: 10px;
      box-shadow: 0 4px 16px rgb(0 0 0 / 0.1);
    }
  }
}

4.8 스택되는 카드 (stacking cards)

<ul class="stack">
  <li class="stack__card"><h3>01</h3></li>
  <li class="stack__card"><h3>02</h3></li>
  <li class="stack__card"><h3>03</h3></li>
  <li class="stack__card"><h3>04</h3></li>
</ul>
.stack {
  list-style: none;
  margin: 0;
  padding: 0;
  /* 카드 개수를 CSS에 알려준다 */
  --cards: 4;
  --card-h: 60vh;
  --gap: 24px;
}

.stack__card {
  position: sticky;
  top: 12vh;
  height: var(--card-h);
  margin-bottom: var(--gap);
  border-radius: 20px;
  padding: 40px;
  background: #18181b;
  color: #fafafa;
  transform-origin: 50% 0%;
}

@supports (animation-timeline: view()) {
  .stack__card {
    animation: card-shrink linear both;
    animation-timeline: view();
    /* 카드가 뷰포트를 빠져나가는 동안 축소 */
    animation-range: exit-crossing 0% exit-crossing 100%;
  }
  @keyframes card-shrink {
    to { scale: 0.9; filter: brightness(0.6); }
  }
}

@media (prefers-reduced-motion: reduce) {
  .stack__card { animation: none; position: static; }
}

4.9 언제 쓰고 언제 쓰지 말아야 하나

쓴다 쓰지 않는다
진행 바, 스크롤 인디케이터 Firefox 지원이 요구사항인 핵심 기능
요소 등장 리빌 (1회성) 요소 핀 고정 + 복잡한 시퀀스 → ScrollTrigger가 낫다
시차, 배경 시프트 스크롤 위치에 따라 DOM을 바꿔야 할 때 (CSS는 스타일만 바꾼다)
헤더 축소/색 변화 스크롤 방향에 따라 다르게 동작해야 할 때 (CSS는 방향 인식 불가)
수평 캐러셀 진행 표시 스크롤 스냅 후 콜백이 필요할 때

5. View Transitions API

5.1 같은 문서 (SPA / 상태 변화)

/**
 * DOM을 바꾸는 함수를 view transition으로 감싼다.
 * 미지원 브라우저에서는 그냥 즉시 바뀐다 → 점진 향상.
 */
function updateWithTransition(updateDOM) {
  if (!document.startViewTransition) {
    updateDOM();
    return { finished: Promise.resolve() };
  }
  if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
    updateDOM();
    return { finished: Promise.resolve() };
  }
  return document.startViewTransition(updateDOM);
}

// 사용 예: 리스트 필터링
document.querySelectorAll('[data-filter]').forEach((btn) => {
  btn.addEventListener('click', () => {
    updateWithTransition(() => {
      const key = btn.dataset.filter;
      document.querySelectorAll('.card').forEach((card) => {
        card.hidden = key !== 'all' && card.dataset.category !== key;
      });
    });
  });
});
/* 각 카드에 고유 이름을 주면 위치 이동이 자동 애니메이션된다 */
.card { view-transition-name: attr(data-id type(<custom-ident>)); }

/* attr() 타입 지원이 불확실하면 JS로 부여 */
::view-transition-group(*) {
  animation-duration: 320ms;
  animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}

::view-transition-old(root) {
  animation: 180ms cubic-bezier(0.5, 0, 0.75, 0) both vt-fade-out;
}
::view-transition-new(root) {
  animation: 260ms cubic-bezier(0.16, 1, 0.3, 1) both vt-fade-in;
}

@keyframes vt-fade-out { to   { opacity: 0; } }
@keyframes vt-fade-in  { from { opacity: 0; } }
// view-transition-name을 JS로 안전하게 부여 (이름 중복은 즉시 에러가 된다)
document.querySelectorAll('.card').forEach((card) => {
  card.style.viewTransitionName = `card-${card.dataset.id}`;
});

5.2 의사 요소 트리 구조

::view-transition                       (오버레이 루트, 뷰포트 전체 덮음)
└── ::view-transition-group(name)       (위치·크기를 애니메이션하는 컨테이너)
    └── ::view-transition-image-pair(name)  (isolation: isolate)
        ├── ::view-transition-old(name)     (이전 상태 스냅샷)
        └── ::view-transition-new(name)     (새 상태의 라이브 표현)

기본 애니메이션:

  • group: 위치/크기를 old → new로 보간
  • image-pair: old는 페이드아웃, new는 페이드인 (크로스페이드)

5.3 shared element 전환 (썸네일 → 상세)

/* 목록의 썸네일과 상세의 큰 이미지에 같은 이름을 준다 */
.thumb[data-id="42"] img,
.detail[data-id="42"] img {
  view-transition-name: hero-42;
}

/* 스냅샷 왜곡 방지 — 기본 object-fit이 fill이라 가로세로비가 깨진다 */
::view-transition-old(hero-42),
::view-transition-new(hero-42) {
  object-fit: cover;
  height: 100%;
  overflow: clip;
}

::view-transition-group(hero-42) {
  animation-duration: 420ms;
  animation-timing-function: cubic-bezier(0.76, 0, 0.24, 1);
}

가장 흔한 사고 3가지

  1. 같은 시점에 같은 view-transition-name이 2개 존재 → 전환 전체가 즉시 실패한다. 필터링/정렬 UI에서 특히 자주 발생. hidden 처리한 요소도 이름을 갖고 있으면 카운트된다.
  2. object-fit: fill 기본값 → 이미지가 늘어난다. 위 코드처럼 명시적으로 덮어쓴다.
  3. position: fixed 요소 → 스냅샷 좌표계가 달라 이상하게 움직인다. view-transition-name: none으로 제외.

5.4 view-transition-classtypes

/* 여러 요소에 공통 전환 스타일 적용 */
.card { view-transition-class: card-item; }

::view-transition-group(.card-item) {
  animation-duration: 300ms;
  animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}
// 전환의 "종류"를 지정해 CSS에서 분기
const dir = newIndex > oldIndex ? 'forwards' : 'backwards';
document.startViewTransition({
  update: () => renderSlide(newIndex),
  types: [`slide-${dir}`],
});
html:active-view-transition-type(slide-forwards) {
  &::view-transition-old(root) {
    animation: 300ms cubic-bezier(0.5, 0, 0.75, 0) both slide-out-left;
  }
  &::view-transition-new(root) {
    animation: 300ms cubic-bezier(0.16, 1, 0.3, 1) both slide-in-right;
  }
}

html:active-view-transition-type(slide-backwards) {
  &::view-transition-old(root) {
    animation: 300ms cubic-bezier(0.5, 0, 0.75, 0) both slide-out-right;
  }
  &::view-transition-new(root) {
    animation: 300ms cubic-bezier(0.16, 1, 0.3, 1) both slide-in-left;
  }
}

@keyframes slide-out-left  { to   { translate: -30% 0; opacity: 0; } }
@keyframes slide-in-right  { from { translate:  30% 0; opacity: 0; } }
@keyframes slide-out-right { to   { translate:  30% 0; opacity: 0; } }
@keyframes slide-in-left   { from { translate: -30% 0; opacity: 0; } }

5.5 문서 간 전환 (MPA)

두 페이지 모두에 아래 CSS가 있어야 한다. 같은 오리진이어야 한다.

@view-transition {
  navigation: auto;
}

폐기된 문법 주의: <meta name="view-transition" content="same-origin">은 더 이상 동작하지 않는다. 콘솔 경고 없이 조용히 실패하므로 오래된 튜토리얼을 복사하지 않는다.

// 나가는 페이지: 스냅샷 직전에 이름을 부여
window.addEventListener('pageswap', (event) => {
  if (!event.viewTransition) return;

  const targetUrl = new URL(event.activation.entry.url);
  const id = targetUrl.searchParams.get('id');
  if (id) {
    const thumb = document.querySelector(`.thumb[data-id="${id}"] img`);
    if (thumb) thumb.style.viewTransitionName = 'shared-hero';
  }

  // 스냅샷 후 즉시 정리 (다음 방문 시 중복 방지)
  event.viewTransition.finished.finally(() => {
    document.querySelectorAll('[style*="view-transition-name"]').forEach((el) => {
      el.style.viewTransitionName = '';
    });
  });
});

// 들어오는 페이지: 렌더 직전에 이름을 부여
window.addEventListener('pagereveal', (event) => {
  if (!event.viewTransition) return;

  const from = navigation.activation?.from?.url;
  if (from && new URL(from).pathname === '/list') {
    const hero = document.querySelector('.detail img');
    if (hero) hero.style.viewTransitionName = 'shared-hero';
  }

  // 4초 타임아웃 등 실패를 감지
  event.viewTransition.finished.catch((err) => {
    console.warn('view transition aborted:', err.name, err.message);
  });
});

MPA 전환의 함정

  1. 4초 타임아웃: 새 페이지가 4초 안에 렌더되지 않으면 전환이 조용히 취소된다. 네트워크 지연이 이 시간에 포함된다.
  2. 렌더 블로킹으로 깜빡임 방지: 새 페이지의 핵심 요소가 준비되기 전에 렌더되면 전환이 어색해진다.
    <link rel="expect" href="#hero" blocking="render">
    
  3. 스크롤 위치: 브라우저가 스크롤 복원을 처리하지만, 전환 애니메이션과 겹치면 어색하다. 상세 페이지로 갈 때는 history.scrollRestoration = 'manual'로 두고 명시적으로 최상단으로 보낸다.

5.6 전환 중 스크롤 위치 처리 (SPA)

/**
 * SPA 라우터에서 view transition과 스크롤 복원을 함께 처리한다.
 * 핵심: DOM 업데이트 콜백 안에서 스크롤을 옮겨야 스냅샷이 맞는다.
 */
const scrollPositions = new Map();

async function navigate(url, { isBack = false } = {}) {
  scrollPositions.set(location.href, window.scrollY);

  const html = await fetch(url).then((r) => r.text());
  const doc = new DOMParser().parseFromString(html, 'text/html');

  const apply = () => {
    document.querySelector('#app').replaceChildren(
      ...doc.querySelector('#app').childNodes
    );
    document.title = doc.title;
    history[isBack ? 'replaceState' : 'pushState']({}, '', url);

    // 스냅샷이 찍히기 전에 스크롤을 확정한다
    const y = isBack ? (scrollPositions.get(url) ?? 0) : 0;
    window.scrollTo({ top: y, behavior: 'instant' });
  };

  if (!document.startViewTransition ||
      matchMedia('(prefers-reduced-motion: reduce)').matches) {
    apply();
    return;
  }
  await document.startViewTransition(apply).finished;
}

5.7 prefers-reduced-motion 대응

@media (prefers-reduced-motion: reduce) {
  /* 이동은 죽이고 크로스페이드만 남긴다 */
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation-duration: 120ms !important;
    animation-timing-function: linear !important;
  }
  ::view-transition-group(*) {
    animation-name: none !important;   /* 위치/크기 보간 제거 */
  }
}

6. 스크롤 관련 CSS 유틸리티

6.1 scroll-behavior와 앵커 이동

/* 사용자 선호를 존중하는 부드러운 스크롤 — 이게 정답이다 */
@media (prefers-reduced-motion: no-preference) {
  :root { scroll-behavior: smooth; }
}

/* 고정 헤더 아래로 앵커가 숨는 문제 해결 */
:target,
[id] { scroll-margin-block-start: 96px; }

scroll-behavior: smooth는 브라우저 네이티브다. Lenis 같은 라이브러리를 도입하기 전에 이걸로 충분한지 먼저 확인한다. 대부분의 경우 충분하다.

6.2 스크롤 스냅

.snap-scroller {
  scroll-snap-type: y mandatory;
  overflow-y: auto;
  height: 100svh;
  overscroll-behavior-y: contain;   /* 부모로 스크롤 전파 차단 */
}

.snap-scroller > section {
  scroll-snap-align: start;
  scroll-snap-stop: always;         /* 빠르게 스와이프해도 한 칸씩 */
  min-height: 100svh;
}

mandatory vs proximity: mandatory는 반드시 스냅되어 스크롤을 강제로 통제한다. 콘텐츠가 뷰포트보다 긴 섹션에서 mandatory를 쓰면 내용을 읽을 수 없는 상태에 갇힌다. 확신이 없으면 proximity를 쓴다.


7. height: auto 애니메이션 — 3가지 방법 비교

방법 지원 장점 단점
grid-template-rows: 0fr → 1fr Baseline widely 순수 CSS, 모든 브라우저 마크업 한 겹 추가, min-height: 0 필요
interpolate-size: allow-keywords Chromium 129+ 가장 간결 Firefox/Safari 미지원
JS로 scrollHeight 측정 후 px 지정 전부 완전한 제어 레이아웃 강제 계산(리플로우), 코드 증가

권장: grid-template-rows 방식을 기본으로, interpolate-size를 점진 향상으로 겹친다.

<div class="accordion">
  <button class="accordion__trigger" aria-expanded="false" aria-controls="panel-1">
    섹션 제목
  </button>
  <div class="accordion__panel" id="panel-1" hidden>
    <div class="accordion__inner">
      <p>패널 내용이 여기 들어갑니다.</p>
    </div>
  </div>
</div>
.accordion__panel {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 300ms cubic-bezier(0.16, 1, 0.3, 1);
}

.accordion__panel[data-open] {
  grid-template-rows: 1fr;
}

.accordion__inner {
  min-height: 0;      /* 필수: grid item의 기본 min-height는 auto */
  overflow: hidden;
}

.accordion__inner > * { padding-block: 12px; }

/* Chromium 향상: 더 정확한 intrinsic 보간 */
@supports (interpolate-size: allow-keywords) {
  :root { interpolate-size: allow-keywords; }
}

@media (prefers-reduced-motion: reduce) {
  .accordion__panel { transition-duration: 1ms; }
}
document.querySelectorAll('.accordion__trigger').forEach((trigger) => {
  const panel = document.getElementById(trigger.getAttribute('aria-controls'));

  trigger.addEventListener('click', () => {
    const willOpen = trigger.getAttribute('aria-expanded') === 'false';
    trigger.setAttribute('aria-expanded', String(willOpen));

    if (willOpen) {
      panel.hidden = false;
      // hidden 해제 후 리플로우를 강제해야 transition이 발동한다
      void panel.offsetHeight;
      panel.dataset.open = '';
    } else {
      delete panel.dataset.open;
      panel.addEventListener('transitionend', function onEnd(e) {
        if (e.propertyName !== 'grid-template-rows') return;
        panel.removeEventListener('transitionend', onEnd);
        if (trigger.getAttribute('aria-expanded') === 'false') panel.hidden = true;
      });
    }
  });
});

8. 가변 폰트 축 애니메이션

@font-face {
  font-family: 'Inter Var';
  src: url('/fonts/InterVariable.woff2') format('woff2-variations');
  font-weight: 100 900;
  font-display: swap;
}

/* 1) 표준 속성으로 애니메이션 — 브라우저 최적화가 붙어 이 쪽이 낫다 */
.weight-hover {
  font-family: 'Inter Var', system-ui, sans-serif;
  font-weight: 400;
  transition: font-weight 260ms cubic-bezier(0.16, 1, 0.3, 1);
}
.weight-hover:hover { font-weight: 750; }

/* 2) 커스텀 축은 font-variation-settings로 — 단, 전부 한 줄에 나열해야 한다 */
@property --wght { syntax: '<number>'; inherits: false; initial-value: 400; }
@property --slnt { syntax: '<number>'; inherits: false; initial-value: 0; }

.vf-axis {
  font-family: 'Inter Var', system-ui, sans-serif;
  font-variation-settings: 'wght' var(--wght), 'slnt' var(--slnt);
  transition: --wght 300ms cubic-bezier(0.16, 1, 0.3, 1),
              --slnt 300ms cubic-bezier(0.16, 1, 0.3, 1);
}
.vf-axis:hover { --wght: 800; --slnt: -8; }

@media (prefers-reduced-motion: reduce) {
  .weight-hover, .vf-axis { transition: none; }
}

가변 폰트 애니메이션의 비용과 판단 기준

항목 사실
합성 가능? 아니오. 글리프를 다시 래스터화하므로 매 프레임 paint 발생
레이아웃 영향? 있음. wght, wdth 변화는 글자 폭을 바꿔 리플로우를 유발한다
안전한 사용 짧은 텍스트(제목, 버튼 라벨, 로고), 1회성 전환, 소수 요소
위험한 사용 본문 단락, 리스트 전체, 스크롤에 물린 지속 애니메이션
완화 컨테이너에 contain: layout; 또는 font-variation-settings 대신 font-synthesis 없이 고정폭 축(wdth 고정)만 사용

권장: font-variation-settings를 매 프레임 바꾸는 스크롤 연동 효과는 만들지 않는다. 호버·포커스 같은 이산적 상태 변화에만 쓴다. 리플로우를 막으려면 텍스트 컨테이너에 고정 폭을 주거나 text-wrap: balance와 함께 쓰지 않는다.


9. SVG 필터 파라미터 애니메이션 — CSS / SMIL / JS 비교

9.1 세 방법

방법 코드 장점 단점 판정
CSS + @property @property --scale + feDisplacementMap scale="…"불가 (SVG 속성은 CSS 변수로 직접 못 받음) SVG 프레젠테이션 속성이 아닌 필터 primitive 속성은 CSS로 제어 불가 필터 primitive 속성에는 사용 불가
SMIL <animate> 선언적, 마크업 안에 완결 코드가 짧고 JS 불필요 Chrome이 한때 폐기 예고했다가 철회. IE 미지원(무관). 제어(일시정지/역재생)가 어렵고 prefers-reduced-motion 대응이 번거로움 단순 루프에는 OK
JS로 속성 직접 갱신 filter.setAttribute('scale', v) 완전한 제어, reduced-motion 대응 용이, GSAP attr 플러그인과 궁합 메인 스레드 부하 프로덕션 권장

9.2 완성 코드 — 호버 시 물결 왜곡 (JS 제어)

<div class="distort">
  <img src="/photo.jpg" alt="" class="distort__img">
  <svg width="0" height="0" aria-hidden="true" focusable="false">
    <filter id="wobble" x="-20%" y="-20%" width="140%" height="140%">
      <feTurbulence
        id="wobble-noise"
        type="fractalNoise"
        baseFrequency="0.008 0.014"
        numOctaves="2"
        seed="7"
        result="noise" />
      <feDisplacementMap
        id="wobble-disp"
        in="SourceGraphic"
        in2="noise"
        scale="0"
        xChannelSelector="R"
        yChannelSelector="G" />
    </filter>
  </svg>
</div>
.distort { display: inline-block; overflow: hidden; border-radius: 12px; }
.distort__img {
  display: block;
  max-width: 100%;
  filter: url(#wobble);
  /* 필터가 걸린 요소는 자체 레이어를 갖는다 — 크기를 작게 유지할 것 */
}
const disp = document.getElementById('wobble-disp');
const noise = document.getElementById('wobble-noise');
const wrap = document.querySelector('.distort');

const reduce = matchMedia('(prefers-reduced-motion: reduce)');

let raf = null;
let current = 0;
let target = 0;
let t = 0;

function tick() {
  // 지수 감쇠 보간 (lerp) — 프레임레이트에 덜 민감한 형태
  current += (target - current) * 0.12;
  t += 0.004;

  disp.setAttribute('scale', current.toFixed(2));
  // 노이즈 주파수를 아주 미세하게 흔들어 "살아있는" 느낌을 준다
  const fx = (0.008 + Math.sin(t) * 0.0015).toFixed(5);
  const fy = (0.014 + Math.cos(t * 0.8) * 0.0015).toFixed(5);
  noise.setAttribute('baseFrequency', `${fx} ${fy}`);

  if (Math.abs(target - current) > 0.05 || target > 0) {
    raf = requestAnimationFrame(tick);
  } else {
    disp.setAttribute('scale', '0');
    raf = null;
  }
}

function start() {
  if (reduce.matches) return;
  target = 26;
  if (!raf) raf = requestAnimationFrame(tick);
}
function stop() {
  target = 0;
  if (!raf) raf = requestAnimationFrame(tick);
}

wrap.addEventListener('pointerenter', start);
wrap.addEventListener('pointerleave', stop);
wrap.addEventListener('focusin', start);
wrap.addEventListener('focusout', stop);

9.3 SMIL 버전 (참고 — 단순 루프에만)

<svg width="0" height="0" aria-hidden="true">
  <filter id="drift">
    <feTurbulence type="fractalNoise" baseFrequency="0.01" numOctaves="2" result="n">
      <animate
        attributeName="baseFrequency"
        dur="14s"
        values="0.010;0.016;0.010"
        repeatCount="indefinite"
        calcMode="spline"
        keySplines="0.42 0 0.58 1; 0.42 0 0.58 1" />
    </feTurbulence>
    <feDisplacementMap in="SourceGraphic" in2="n" scale="12"
      xChannelSelector="R" yChannelSelector="G" />
  </filter>
</svg>
// SMIL도 reduced-motion을 존중하게 만든다
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
  document.querySelectorAll('animate, animateTransform, animateMotion')
    .forEach((el) => el.parentElement.removeChild(el));
}

9.4 SVG 필터 성능 규칙 (타협 불가)

  1. feTurbulence는 가장 비싼 primitive다. 필터 영역의 픽셀마다 노이즈를 계산한다. 필터가 걸린 요소 크기를 300×300px 이하로 유지하거나, 큰 영역에는 쓰지 않는다.
  2. filter 영역(x/y/width/height)을 반드시 명시해 확장 범위를 제한한다. 기본값은 -10%~120%다.
  3. 필터 파라미터와 transform을 동시에 애니메이션하지 않는다. 이동은 transform으로, 왜곡은 필터로 하되 왜곡은 짧은 순간만.
  4. 정적인 텍스처(그레인, 종이 질감)는 필터를 실시간 계산하지 말고 PNG/WebP로 미리 렌더해서 background-image로 깐다. 비용이 0이 된다.
  5. 모바일에서는 필터 애니메이션을 아예 끄는 것을 기본값으로 고려한다.
/* 저사양/모바일에서 필터 비활성화 */
@media (max-width: 768px), (prefers-reduced-motion: reduce) {
  .distort__img { filter: none; }
}

10. CSS만으로 되는 것 / 안 되는 것 요약

CSS로 충분한 것 (라이브러리 금지)

  • 호버·포커스·액티브 상태 전환
  • 모달·팝오버·툴팁 진입/퇴장 (@starting-style)
  • 아코디언 펼침 (grid-template-rows)
  • 스크롤 리빌, 진행 바, 시차, 스티키 헤더 (scroll-driven, @supports 가드)
  • 페이지 전환 (View Transitions)
  • 무한 루프 (마퀴, 스피너, 앰비언트 그라디언트)
  • 스프링 느낌 (linear() 프리셋)
  • 그라디언트 각도·색상 정지점 애니메이션 (@property)
  • 스크롤 스냅 캐러셀

CSS로 안 되는 것 (03번 문서로)

  • 속도(velocity)를 이어받는 인터럽트 (드래그 던지기, 목표 재설정)
  • 복잡한 타임라인 시퀀싱 (A 끝나고 B, B의 절반에서 C 시작)
  • 레이아웃 변화를 transform으로 변환하는 FLIP (요소가 그리드에서 리스트로 이동)
  • 스크롤 핀 고정 + 시퀀스 (섹션을 고정하고 여러 단계를 진행)
  • 텍스트 문자/단어 단위 분해
  • SVG 패스 모핑, 패스 위 이동
  • 커서/포인터 위치에 물린 연속 값 (스프링 스무딩 포함)
  • 물리 기반 관성 스크롤
  • WebGL/Canvas와의 동기화