# 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 ``` ```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 완성 코드 — `` 모달 ```html

이 작업은 되돌릴 수 없습니다.

``` ```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 ``` ```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 ``` ```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 = `
항목 ${++counter}
`; 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

Pro 플랜

테두리만 회전합니다. 내용은 정지 상태입니다.

``` ```css @property --glow-angle { syntax: ''; 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

커서를 올려보세요

JS는 좌표만 CSS 변수로 넘기고, 보간은 CSS가 한다.

``` ```css @property --mx { syntax: ''; inherits: false; initial-value: 50%; } @property --my { syntax: ''; inherits: false; initial-value: 50%; } @property --spot-opacity { syntax: ''; 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 ``` ```css @property --count { syntax: ''; 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
…긴 글…
``` ```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( )` - ``: `nearest`(기본) | `root` | `self` - ``: `block`(기본) | `inline` | `y` | `x` ### 4.3 `view()` — 스크롤 리빌 (가장 자주 쓰는 패턴) ```html
항목 1
항목 2
항목 3
``` ```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 ``` ```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

Parallax

``` ```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: ''; 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
  • 01

  • 02

  • 03

  • 04

``` ```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()); } /* 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; } ``` > **폐기된 문법 주의**: ``은 더 이상 동작하지 않는다. > 콘솔 경고 없이 조용히 실패하므로 오래된 튜토리얼을 복사하지 않는다. ```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 ``` 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
``` ```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: ''; inherits: false; initial-value: 400; } @property --slnt { syntax: ''; 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 ``** | 선언적, 마크업 안에 완결 | 코드가 짧고 JS 불필요 | Chrome이 한때 폐기 예고했다가 철회. IE 미지원(무관). 제어(일시정지/역재생)가 어렵고 `prefers-reduced-motion` 대응이 번거로움 | 단순 루프에는 OK | | **JS로 속성 직접 갱신** | `filter.setAttribute('scale', v)` | 완전한 제어, reduced-motion 대응 용이, GSAP `attr` 플러그인과 궁합 | 메인 스레드 부하 | **프로덕션 권장** | ### 9.2 완성 코드 — 호버 시 물결 왜곡 (JS 제어) ```html
``` ```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 ``` ```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와의 동기화