웹 디자인 파이프라인 스킬과 이를 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)
1530 lines
46 KiB
Markdown
1530 lines
46 KiB
Markdown
# 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="삭제">×</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와의 동기화
|