# 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`을 다시 선언하면 그 상태로 갈 때의
타이밍만 바뀐다.

```css
.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년~)

```css
.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의
무한 깜빡임이 생긴다. **트리거 요소와 움직이는 요소를 분리한다.**

```html
<button class="lift-btn"><span class="lift-btn__inner">호버해 보세요</span></button>
```

```css
.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 그레이스 기간

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

```css
.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: none` ↔ `display: block`은 **이산(discrete) 속성**이라 전환되지 않는다.
또 DOM에 새로 삽입된 요소는 "이전 스타일"이 없어서 transition이 시작되지 않는다.

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

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

### 2.2 완성 코드 — `<dialog>` 모달

```html
<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>
```

```css
.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;
  }
}
```

```js
document.getElementById('open-modal').addEventListener('click', () => {
  document.getElementById('demo-modal').showModal();
});
```

### 2.3 완성 코드 — Popover API 툴팁

```html
<button popovertarget="tip-1" popovertargetaction="toggle">도움말</button>
<div id="tip-1" popover="auto" class="tip" role="tooltip">
  이 필드는 계정 복구에만 사용됩니다.
</div>
```

```css
.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에 새로 삽입되는 리스트 항목

```html
<ul id="todo-list" class="todo"></ul>
<button id="add-todo">항목 추가</button>
```

```css
.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;
}
```

```js
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 회전하는 그라디언트 테두리

```html
<div class="glow-card">
  <div class="glow-card__content">
    <h3>Pro 플랜</h3>
    <p>테두리만 회전합니다. 내용은 정지 상태입니다.</p>
  </div>
</div>
```

```css
@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는 값만 넘긴다)

```html
<div class="spotlight" id="spotlight">
  <h3>커서를 올려보세요</h3>
  <p>JS는 좌표만 CSS 변수로 넘기고, 보간은 CSS가 한다.</p>
</div>
```

```css
@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; }
}
```

```js
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 숫자 카운트업

```html
<span class="counter" style="--target: 1284" role="img" aria-label="1284명">
  <span class="counter__num"></span>
</span>
```

```css
@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__num`에 `aria-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()` — 읽기 진행 바

```html
<div class="progress" role="progressbar" aria-label="읽기 진행률"></div>
<article>…긴 글…</article>
```

```css
.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()` — 스크롤 리빌 (가장 자주 쓰는 패턴)

```html
<section class="reveal-group">
  <div class="reveal">항목 1</div>
  <div class="reveal">항목 2</div>
  <div class="reveal">항목 3</div>
</section>
```

```css
.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-duration`은 `auto`가 기본**이라 명시하지 않는다. 초를 넣으면 무시되거나 오작동한다.

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

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

| 범위 이름 | 0% 시점 | 100% 시점 |
|---|---|---|
| `cover` | 요소가 스크롤포트에 **닿기 시작** | 요소가 스크롤포트를 **완전히 벗어남** |
| `entry` | 요소가 닿기 시작 | 요소가 **완전히 들어옴** |
| `exit` | 요소가 **나가기 시작** | 요소가 완전히 벗어남 |
| `contain` | 요소가 스크롤포트에 **완전히 담김** (요소가 뷰포트보다 작을 때) | 담긴 상태가 끝남 |
| `entry-crossing` / `exit-crossing` | 요소가 시작/끝 경계를 **가로지르는** 구간 | — |

```css
/* 들어올 때 페이드인, 나갈 때 페이드아웃 — 키프레임에 범위 이름을 직접 쓴다 */
@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 명명 타임라인 — 다른 요소의 스크롤에 반응시키기

```html
<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>
```

```css
.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) 배경 — 완성 코드

```html
<section class="hero">
  <div class="hero__bg"></div>
  <div class="hero__content"><h1>Parallax</h1></div>
</section>
```

```css
.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 스티키 헤더 축소

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

```html
<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>
```

```css
.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 / 상태 변화)

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

```css
/* 각 카드에 고유 이름을 주면 위치 이동이 자동 애니메이션된다 */
.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; } }
```

```js
// 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 전환 (썸네일 → 상세)

```css
/* 목록의 썸네일과 상세의 큰 이미지에 같은 이름을 준다 */
.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-class`와 `types`

```css
/* 여러 요소에 공통 전환 스타일 적용 */
.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);
}
```

```js
// 전환의 "종류"를 지정해 CSS에서 분기
const dir = newIndex > oldIndex ? 'forwards' : 'backwards';
document.startViewTransition({
  update: () => renderSlide(newIndex),
  types: [`slide-${dir}`],
});
```

```css
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가 있어야 한다. 같은 오리진이어야 한다.

```css
@view-transition {
  navigation: auto;
}
```

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

```js
// 나가는 페이지: 스냅샷 직전에 이름을 부여
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. **렌더 블로킹으로 깜빡임 방지**: 새 페이지의 핵심 요소가 준비되기 전에 렌더되면 전환이 어색해진다.
   ```html
   <link rel="expect" href="#hero" blocking="render">
   ```
3. **스크롤 위치**: 브라우저가 스크롤 복원을 처리하지만, 전환 애니메이션과 겹치면 어색하다.
   상세 페이지로 갈 때는 `history.scrollRestoration = 'manual'`로 두고 명시적으로 최상단으로 보낸다.

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

```js
/**
 * 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` 대응

```css
@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`와 앵커 이동

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

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

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

### 6.2 스크롤 스냅

```css
.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`를 점진 향상으로 겹친다.**

```html
<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>
```

```css
.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; }
}
```

```js
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. 가변 폰트 축 애니메이션

```css
@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 제어)

```html
<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>
```

```css
.distort { display: inline-block; overflow: hidden; border-radius: 12px; }
.distort__img {
  display: block;
  max-width: 100%;
  filter: url(#wobble);
  /* 필터가 걸린 요소는 자체 레이어를 갖는다 — 크기를 작게 유지할 것 */
}
```

```js
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 버전 (참고 — 단순 루프에만)

```html
<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>
```

```js
// 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. 모바일에서는 필터 애니메이션을 아예 끄는 것을 기본값으로 고려한다.

```css
/* 저사양/모바일에서 필터 비활성화 */
@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와의 동기화
