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

1530 lines
46 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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와의 동기화