웹 디자인 파이프라인 스킬과 이를 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)
46 KiB
02. CSS 네이티브 모션 — 라이브러리 없이 되는 것 전부
원칙: CSS로 되면 CSS로 한다. 라이브러리는 CSS가 못 하는 일을 할 때만 꺼낸다. CSS 애니메이션은 상당 부분 컴포지터 스레드에서 돌아가므로 메인 스레드가 막혀도 프레임을 떨어뜨리지 않는다. 이 문서의 모든 코드는 복사해서 그대로 동작하는 완성 코드다.
0. 지원 현황 — 2026년 8월 기준 (webstatus.dev API 직접 조회)
| 기능 | Baseline | Chrome | Firefox | Safari | 프로덕션 판단 |
|---|---|---|---|---|---|
prefers-reduced-motion |
widely (2020-01) | 74 | 63 | 10.1 | 무조건 쓴다 |
linear() 이징 |
widely (2023-12) | 113 | 112 | 17.2 | 무조건 쓴다 |
@property (registered custom properties) |
newly (2024-07) | 85 | 128 | 16.4 | 폴백 있으면 쓴다 |
@starting-style |
newly (2024-08) | 117 | 129 | 17.5 | 폴백 있으면 쓴다 |
transition-behavior: allow-discrete |
newly (2024-08) | 117 | 129 | 17.4 | 폴백 있으면 쓴다 |
| Popover API | newly (2025-01) | 116 | 125 | 17 / iOS 18.3 | 쓴다 |
| View Transitions (same-document) | newly (2025-10) | 111 | 144 | 18 | 점진 향상으로 쓴다 |
| Cross-document View Transitions | limited | 126 | ✗ | 18.2 | 점진 향상 전용 (Firefox 미지원) |
| Scroll-driven animations | limited | 115 | ✗(플래그) | 26 | @supports 가드 필수 |
interpolate-size / calc-size() |
limited | 129 | ✗ | ✗ | Chromium 전용 향상 |
content-visibility |
newly (2025-09) | 108 | 130 | 26 | 쓴다 |
| Long Animation Frames API | limited | 123 | ✗ | ✗ | 계측 전용, Chromium만 |
핵심 판단: scroll-driven animations와 cross-document view transitions는 아직 Baseline이 아니다. 반드시
@supports가드를 씌우고, 미지원 브라우저에서도 콘텐츠가 정상적으로 보이게 만든다. 특히 스크롤 리빌은 미지원 브라우저에서opacity: 0으로 남아 콘텐츠가 안 보이는 사고가 잦다.
1. transition 기본기 — 그러나 제대로
1.1 비대칭 타이밍 (진입 ≠ 퇴장)
가장 저렴하면서 가장 효과가 큰 기법. 상태 규칙에 transition을 다시 선언하면 그 상태로 갈 때의
타이밍만 바뀐다.
.card {
--lift: 0px;
translate: 0 var(--lift);
box-shadow: 0 1px 2px rgb(0 0 0 / 0.08);
/* 마우스가 떠날 때: 여유롭게 */
transition:
translate 340ms cubic-bezier(0.16, 1, 0.3, 1),
box-shadow 340ms cubic-bezier(0.16, 1, 0.3, 1);
}
.card:hover,
.card:focus-visible {
--lift: -6px;
box-shadow: 0 12px 32px rgb(0 0 0 / 0.16);
/* 마우스가 들어올 때: 즉각적으로 */
transition:
translate 140ms cubic-bezier(0.16, 1, 0.3, 1),
box-shadow 140ms cubic-bezier(0.16, 1, 0.3, 1);
}
1.2 개별 transform 속성 (transform 충돌 해결)
translate, rotate, scale은 독립 CSS 속성이다. 이걸 쓰면 서로 다른 타이밍으로 애니메이션할 수 있고,
여러 규칙이 transform을 덮어쓰는 문제가 사라진다. (Baseline widely, 2022년~)
.tile {
translate: 0 0;
rotate: 0deg;
scale: 1;
transition:
translate 200ms cubic-bezier(0.16, 1, 0.3, 1),
rotate 500ms cubic-bezier(0.34, 1.56, 0.64, 1), /* 회전만 늦게, 오버슛 */
scale 200ms cubic-bezier(0.16, 1, 0.3, 1);
}
.tile:hover {
translate: 0 -8px;
rotate: -2deg;
scale: 1.03;
}
주의:
transform단축 속성과 개별 속성을 같은 요소에 섞으면 예측이 어려워진다. 하나만 쓴다. 적용 순서는 항상translate → rotate → scale → transform이며, 이 순서는 CSS 선언 순서와 무관하다.
1.3 "doom flicker" 방지
호버 시 요소 자체가 움직이면 커서가 요소를 벗어나 → hover 해제 → 되돌아옴 → 다시 hover의 무한 깜빡임이 생긴다. 트리거 요소와 움직이는 요소를 분리한다.
<button class="lift-btn"><span class="lift-btn__inner">호버해 보세요</span></button>
.lift-btn {
border: 0;
padding: 0;
background: none;
cursor: pointer;
/* 버튼 자체는 절대 움직이지 않는다 → 히트 영역 고정 */
}
.lift-btn__inner {
display: block;
padding: 12px 24px;
border-radius: 10px;
background: #111;
color: #fff;
translate: 0 0;
transition: translate 300ms cubic-bezier(0.16, 1, 0.3, 1);
}
.lift-btn:hover .lift-btn__inner,
.lift-btn:focus-visible .lift-btn__inner {
translate: 0 -4px;
transition-duration: 120ms;
}
1.4 드롭다운 hover 그레이스 기간
메뉴 경계에서 커서가 살짝 벗어나도 바로 닫히지 않게 한다.
.dropdown {
opacity: 0;
translate: 0 -6px;
pointer-events: none;
/* 닫힐 때: 300ms 유예 후 400ms에 걸쳐 사라짐 */
transition:
opacity 400ms ease,
translate 400ms ease;
transition-delay: 300ms;
}
.dropdown-wrapper:hover .dropdown,
.dropdown-wrapper:focus-within .dropdown {
opacity: 1;
translate: 0 0;
pointer-events: auto;
/* 열릴 때: 즉시 */
transition-duration: 140ms;
transition-delay: 0ms;
}
2. @starting-style + transition-behavior: allow-discrete
2.1 문제와 해결
display: 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> 모달
<button id="open-modal">모달 열기</button>
<dialog id="demo-modal" class="modal" aria-labelledby="modal-title">
<h2 id="modal-title">정말 삭제할까요?</h2>
<p>이 작업은 되돌릴 수 없습니다.</p>
<form method="dialog" class="modal__actions">
<button value="cancel">취소</button>
<button value="confirm" class="is-primary">삭제</button>
</form>
</dialog>
.modal {
/* 닫힌 상태 = 퇴장 애니메이션의 종착점 */
opacity: 0;
translate: 0 16px;
scale: 0.97;
border: 0;
border-radius: 16px;
padding: 28px 32px;
max-width: min(90vw, 420px);
background: Canvas;
color: CanvasText;
box-shadow: 0 24px 64px rgb(0 0 0 / 0.24);
transition:
opacity 195ms cubic-bezier(0.5, 0, 0.75, 0),
translate 195ms cubic-bezier(0.5, 0, 0.75, 0),
scale 195ms cubic-bezier(0.5, 0, 0.75, 0),
display 195ms allow-discrete,
overlay 195ms allow-discrete;
}
/* 열린 상태 */
.modal[open] {
opacity: 1;
translate: 0 0;
scale: 1;
transition:
opacity 300ms cubic-bezier(0.16, 1, 0.3, 1),
translate 300ms cubic-bezier(0.16, 1, 0.3, 1),
scale 300ms cubic-bezier(0.16, 1, 0.3, 1),
display 300ms allow-discrete,
overlay 300ms allow-discrete;
}
/* 진입 애니메이션의 출발점 — 반드시 .modal[open] 규칙 뒤에 */
@starting-style {
.modal[open] {
opacity: 0;
translate: 0 16px;
scale: 0.97;
}
}
/* 백드롭 */
.modal::backdrop {
background: rgb(0 0 0 / 0);
backdrop-filter: blur(0px);
transition:
background-color 195ms ease,
backdrop-filter 195ms ease,
display 195ms allow-discrete,
overlay 195ms allow-discrete;
}
.modal[open]::backdrop {
background: rgb(0 0 0 / 0.5);
backdrop-filter: blur(4px);
transition-duration: 300ms;
}
@starting-style {
.modal[open]::backdrop {
background: rgb(0 0 0 / 0);
backdrop-filter: blur(0px);
}
}
.modal__actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 24px;
}
@media (prefers-reduced-motion: reduce) {
.modal,
.modal[open] {
translate: 0 0;
scale: 1;
transition:
opacity 120ms linear,
display 120ms allow-discrete,
overlay 120ms allow-discrete;
}
@starting-style {
.modal[open] { opacity: 0; translate: 0 0; scale: 1; }
}
.modal::backdrop,
.modal[open]::backdrop {
backdrop-filter: none;
transition:
background-color 120ms linear,
display 120ms allow-discrete,
overlay 120ms allow-discrete;
}
}
document.getElementById('open-modal').addEventListener('click', () => {
document.getElementById('demo-modal').showModal();
});
2.3 완성 코드 — Popover API 툴팁
<button popovertarget="tip-1" popovertargetaction="toggle">도움말</button>
<div id="tip-1" popover="auto" class="tip" role="tooltip">
이 필드는 계정 복구에만 사용됩니다.
</div>
.tip {
margin: 0;
inset: auto;
position: fixed;
bottom: 24px;
right: 24px;
max-width: 280px;
padding: 12px 16px;
border: 0;
border-radius: 10px;
background: #1a1a1a;
color: #fff;
font-size: 14px;
line-height: 1.5;
/* 닫힌 상태 */
opacity: 0;
translate: 0 6px;
scale: 0.96;
transition:
opacity 145ms cubic-bezier(0.5, 0, 0.75, 0),
translate 145ms cubic-bezier(0.5, 0, 0.75, 0),
scale 145ms cubic-bezier(0.5, 0, 0.75, 0),
display 145ms allow-discrete,
overlay 145ms allow-discrete;
}
.tip:popover-open {
opacity: 1;
translate: 0 0;
scale: 1;
transition-duration: 220ms;
transition-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}
@starting-style {
.tip:popover-open {
opacity: 0;
translate: 0 6px;
scale: 0.96;
}
}
2.4 완성 코드 — DOM에 새로 삽입되는 리스트 항목
<ul id="todo-list" class="todo"></ul>
<button id="add-todo">항목 추가</button>
.todo { list-style: none; margin: 0; padding: 0; }
.todo li {
display: grid;
grid-template-rows: 1fr;
opacity: 1;
translate: 0 0;
overflow: hidden;
transition:
opacity 180ms ease,
translate 180ms ease,
grid-template-rows 240ms cubic-bezier(0.16, 1, 0.3, 1);
}
.todo li > .todo__row {
min-height: 0;
padding: 12px 16px;
border-bottom: 1px solid rgb(0 0 0 / 0.08);
}
/* 삽입 시작점 — li 규칙 뒤에 배치 */
@starting-style {
.todo li {
opacity: 0;
translate: -12px 0;
grid-template-rows: 0fr;
}
}
/* 제거 직전에 붙이는 클래스 */
.todo li.is-leaving {
opacity: 0;
translate: 12px 0;
grid-template-rows: 0fr;
transition-duration: 160ms;
}
const list = document.getElementById('todo-list');
let counter = 0;
document.getElementById('add-todo').addEventListener('click', () => {
const li = document.createElement('li');
li.innerHTML = `
<div class="todo__row">
항목 ${++counter}
<button type="button" class="todo__remove" aria-label="삭제">×</button>
</div>`;
list.append(li);
});
list.addEventListener('click', (e) => {
const btn = e.target.closest('.todo__remove');
if (!btn) return;
const li = btn.closest('li');
li.classList.add('is-leaving');
// transitionend 대신 이벤트 이름을 확인해 조기 종료 방지
li.addEventListener('transitionend', function onEnd(ev) {
if (ev.propertyName !== 'grid-template-rows') return;
li.removeEventListener('transitionend', onEnd);
li.remove();
});
});
grid-template-rows: 0fr → 1fr트릭:height: auto를 애니메이션하는 가장 호환성 좋은 방법. 부모를display: grid로 두고 행을0fr ↔ 1fr로 전환, 자식에min-height: 0. Baseline widely. Chromium 전용인interpolate-size보다 훨씬 안전하다 (7장 참조).
3. @property — 애니메이션 불가능하던 것을 가능하게
일반 CSS 변수는 타입이 없어서 브라우저가 "0deg에서 360deg 사이"를 보간하지 못한다.
@property로 타입을 등록하면 그라디언트 각도, 색상 정지점, 임의의 숫자가 애니메이션된다.
3.1 회전하는 그라디언트 테두리
<div class="glow-card">
<div class="glow-card__content">
<h3>Pro 플랜</h3>
<p>테두리만 회전합니다. 내용은 정지 상태입니다.</p>
</div>
</div>
@property --glow-angle {
syntax: '<angle>';
inherits: false;
initial-value: 0deg;
}
.glow-card {
--glow-angle: 0deg;
position: relative;
padding: 2px; /* 테두리 두께 */
border-radius: 18px;
background: conic-gradient(
from var(--glow-angle),
#7c3aed, #ec4899, #f59e0b, #7c3aed
);
animation: glow-spin 6s linear infinite;
}
@keyframes glow-spin {
to { --glow-angle: 360deg; }
}
.glow-card__content {
border-radius: 16px;
padding: 28px;
background: #0b0b0f;
color: #f4f4f5;
}
/* @property 미지원 시: 회전 대신 정적 그라디언트 */
@supports not (background: conic-gradient(from 0deg, red, blue)) {
.glow-card { background: linear-gradient(135deg, #7c3aed, #ec4899); }
}
@media (prefers-reduced-motion: reduce) {
.glow-card { animation: none; --glow-angle: 135deg; }
}
3.2 마우스를 따라오는 스포트라이트 (JS는 값만 넘긴다)
<div class="spotlight" id="spotlight">
<h3>커서를 올려보세요</h3>
<p>JS는 좌표만 CSS 변수로 넘기고, 보간은 CSS가 한다.</p>
</div>
@property --mx { syntax: '<length>'; inherits: false; initial-value: 50%; }
@property --my { syntax: '<length>'; inherits: false; initial-value: 50%; }
@property --spot-opacity { syntax: '<number>'; inherits: false; initial-value: 0; }
.spotlight {
position: relative;
padding: 48px;
border-radius: 16px;
background: #0f0f14;
color: #e4e4e7;
overflow: hidden;
isolation: isolate;
transition: --spot-opacity 260ms ease;
}
.spotlight::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
background: radial-gradient(
240px circle at var(--mx) var(--my),
rgb(124 58 237 / 0.35),
transparent 70%
);
opacity: var(--spot-opacity);
/* --mx/--my 자체를 transition하면 커서를 부드럽게 따라온다 */
transition: --mx 120ms linear, --my 120ms linear;
}
.spotlight:hover { --spot-opacity: 1; }
@media (prefers-reduced-motion: reduce) {
.spotlight::before { transition: none; }
}
const spot = document.getElementById('spotlight');
spot.addEventListener('pointermove', (e) => {
const r = spot.getBoundingClientRect();
spot.style.setProperty('--mx', `${e.clientX - r.left}px`);
spot.style.setProperty('--my', `${e.clientY - r.top}px`);
});
3.3 순수 CSS 숫자 카운트업
<span class="counter" style="--target: 1284" role="img" aria-label="1284명">
<span class="counter__num"></span>
</span>
@property --count {
syntax: '<integer>';
inherits: false;
initial-value: 0;
}
.counter {
font-variant-numeric: tabular-nums;
font-weight: 700;
font-size: 2.5rem;
}
.counter__num {
counter-reset: num var(--count);
animation: count-up 1.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.counter__num::after {
content: counter(num);
}
@keyframes count-up {
from { --count: 0; }
to { --count: var(--target); }
}
@media (prefers-reduced-motion: reduce) {
.counter__num { animation: none; --count: var(--target); }
}
접근성 필수: 카운트업은 시각 효과일 뿐이므로 부모에
aria-label로 최종 값을 명시하고 애니메이션되는 요소 자체는 스크린리더에 노출되지 않게 한다(위 코드는::aftercontent라 대부분의 스크린리더가 읽지 않지만, 확실히 하려면.counter__num에aria-hidden="true").
3.4 @property 성능 주의사항
CSS 변수 변경은 paint(그리고 종종 style recalc)를 유발한다. 특히 :root나 상위 요소에 선언된
변수를 매 프레임 바꾸면 하위 트리 전체의 스타일이 재계산된다. 실측 사례로 1300개 요소에서
프레임당 8ms가 소요된 보고가 있다(120fps 예산 전체).
규칙
@property는 항상inherits: false로 선언한다 (상속을 끊어 재계산 범위를 좁힌다).- 변수는 사용하는 요소에 최대한 가깝게 선언한다.
:root에 애니메이션용 변수를 두지 않는다. - 매 프레임 바뀌는 변수는 5개 이하 요소에만 적용한다.
4. CSS 스크롤 구동 애니메이션 (Scroll-driven Animations)
가장 중요한 사실: 이건 컴포지터 스레드에서 돈다. 메인 스레드가 막혀도 스크롤 애니메이션은
끊기지 않는다. IntersectionObserver + JS보다 근본적으로 우월하다.
단, 아직 Baseline이 아니다(Firefox 미지원). @supports 가드가 필수다.
4.1 두 종류의 타임라인
| 종류 | 함수 | 무엇을 추적하나 | 대표 용도 |
|---|---|---|---|
| Scroll Progress Timeline | scroll() |
스크롤 컨테이너의 스크롤 진행률 (0~100%) | 읽기 진행 바, 배경 시프트 |
| View Progress Timeline | view() |
요소 자신이 스크롤포트를 지나가는 진행률 | 등장 리빌, 시차, 카드 스택 |
4.2 scroll() — 읽기 진행 바
<div class="progress" role="progressbar" aria-label="읽기 진행률"></div>
<article>…긴 글…</article>
.progress {
position: fixed;
inset-block-start: 0;
inset-inline: 0;
height: 3px;
background: #7c3aed;
transform-origin: 0 50%;
scale: 0 1;
z-index: 100;
}
@supports (animation-timeline: scroll()) {
.progress {
animation: progress-grow linear both;
animation-timeline: scroll(root block);
}
@keyframes progress-grow {
from { scale: 0 1; }
to { scale: 1 1; }
}
}
/* 미지원 브라우저: 바를 아예 숨긴다 (0 스케일로 남지 않게) */
@supports not (animation-timeline: scroll()) {
.progress { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.progress { display: none; }
}
scroll() 인자: scroll(<scroller> <axis>)
<scroller>:nearest(기본) |root|self<axis>:block(기본) |inline|y|x
4.3 view() — 스크롤 리빌 (가장 자주 쓰는 패턴)
<section class="reveal-group">
<div class="reveal">항목 1</div>
<div class="reveal">항목 2</div>
<div class="reveal">항목 3</div>
</section>
.reveal {
padding: 48px;
border-radius: 14px;
background: #f4f4f5;
}
@supports (animation-timeline: view()) {
.reveal {
animation: reveal-in linear both;
animation-timeline: view();
/* 요소가 뷰포트에 20% 들어왔을 때 시작, 40% 들어왔을 때 끝 */
animation-range: entry 20% cover 40%;
}
@keyframes reveal-in {
from { opacity: 0; translate: 0 32px; }
to { opacity: 1; translate: 0 0; }
}
}
@media (prefers-reduced-motion: reduce) {
.reveal { animation: none !important; opacity: 1; translate: 0 0; }
}
both필수:animation-fill-mode: both가 없으면 범위 밖에서 원래 스타일로 돌아가 깜빡인다.animation: reveal-in linear both에서both가 그것이다.animation-duration은auto가 기본이라 명시하지 않는다. 초를 넣으면 무시되거나 오작동한다.
4.4 animation-range — 범위 이름 완전 정리
view() 타임라인에는 5개의 명명된 범위가 있다. 요소가 스크롤포트를 통과하는 과정을 구간으로 나눈 것이다.
| 범위 이름 | 0% 시점 | 100% 시점 |
|---|---|---|
cover |
요소가 스크롤포트에 닿기 시작 | 요소가 스크롤포트를 완전히 벗어남 |
entry |
요소가 닿기 시작 | 요소가 완전히 들어옴 |
exit |
요소가 나가기 시작 | 요소가 완전히 벗어남 |
contain |
요소가 스크롤포트에 완전히 담김 (요소가 뷰포트보다 작을 때) | 담긴 상태가 끝남 |
entry-crossing / exit-crossing |
요소가 시작/끝 경계를 가로지르는 구간 | — |
/* 들어올 때 페이드인, 나갈 때 페이드아웃 — 키프레임에 범위 이름을 직접 쓴다 */
@keyframes in-and-out {
entry 0% { opacity: 0; translate: 0 40px; }
entry 100% { opacity: 1; translate: 0 0; }
exit 0% { opacity: 1; translate: 0 0; }
exit 100% { opacity: 0; translate: 0 -40px; }
}
@supports (animation-timeline: view()) {
.fly-item {
animation: in-and-out linear both;
animation-timeline: view();
}
}
4.5 명명 타임라인 — 다른 요소의 스크롤에 반응시키기
<div class="gallery">
<div class="gallery__track">
<img src="1.jpg" alt=""><img src="2.jpg" alt=""><img src="3.jpg" alt="">
</div>
<div class="gallery__bar"><span></span></div>
</div>
.gallery__track {
display: flex;
gap: 16px;
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-timeline: --gallery inline; /* 이름 있는 스크롤 타임라인 생성 */
}
.gallery__track img {
flex: 0 0 80%;
scroll-snap-align: center;
border-radius: 12px;
}
.gallery {
/* 형제 요소에게 타임라인을 보이게 하려면 공통 조상에 timeline-scope */
timeline-scope: --gallery;
}
.gallery__bar {
height: 4px;
background: rgb(0 0 0 / 0.1);
border-radius: 2px;
margin-top: 12px;
overflow: hidden;
}
.gallery__bar span {
display: block;
height: 100%;
background: #111;
transform-origin: 0 50%;
scale: 0 1;
}
@supports (animation-timeline: --gallery) {
.gallery__bar span {
animation: bar-grow linear both;
animation-timeline: --gallery;
}
@keyframes bar-grow {
from { scale: 0 1; }
to { scale: 1 1; }
}
}
timeline-scope가 핵심이다. 기본적으로 명명 타임라인은 자손에게만 보인다.
형제/사촌에게 보이게 하려면 공통 조상에 timeline-scope: --name을 선언한다.
4.6 시차(parallax) 배경 — 완성 코드
<section class="hero">
<div class="hero__bg"></div>
<div class="hero__content"><h1>Parallax</h1></div>
</section>
.hero {
position: relative;
min-height: 100svh;
display: grid;
place-items: center;
overflow: hidden;
view-timeline: --hero block; /* 자기 자신을 뷰 타임라인으로 */
}
.hero__bg {
position: absolute;
inset: -20% 0; /* 이동 여유분 확보 */
background: url('/hero.jpg') center / cover no-repeat;
will-change: translate;
}
@supports (animation-timeline: view()) {
.hero__bg {
animation: parallax linear both;
animation-timeline: --hero;
animation-range: cover 0% cover 100%;
}
@keyframes parallax {
from { translate: 0 -10%; }
to { translate: 0 10%; }
}
}
.hero__content { position: relative; z-index: 1; }
@media (prefers-reduced-motion: reduce) {
.hero__bg { animation: none; translate: 0 0; inset: 0; }
}
시차는 전정기관 장애의 1순위 유발 요인이다.
prefers-reduced-motion에서 반드시 완전히 끈다. 이동 폭은 뷰포트 높이의 10~15% 이내로 제한한다.
4.7 스티키 헤더 축소
.site-header {
position: sticky;
top: 0;
z-index: 50;
background: rgb(255 255 255 / 0.9);
backdrop-filter: blur(8px);
--header-pad: 24px;
padding-block: var(--header-pad);
box-shadow: 0 0 0 rgb(0 0 0 / 0);
}
@property --header-pad {
syntax: '<length>';
inherits: false;
initial-value: 24px;
}
@supports (animation-timeline: scroll()) {
.site-header {
animation: header-shrink linear both;
animation-timeline: scroll(root block);
/* 문서 처음 200px 스크롤 동안만 진행 */
animation-range: 0 200px;
}
@keyframes header-shrink {
to {
--header-pad: 10px;
box-shadow: 0 4px 16px rgb(0 0 0 / 0.1);
}
}
}
4.8 스택되는 카드 (stacking cards)
<ul class="stack">
<li class="stack__card"><h3>01</h3></li>
<li class="stack__card"><h3>02</h3></li>
<li class="stack__card"><h3>03</h3></li>
<li class="stack__card"><h3>04</h3></li>
</ul>
.stack {
list-style: none;
margin: 0;
padding: 0;
/* 카드 개수를 CSS에 알려준다 */
--cards: 4;
--card-h: 60vh;
--gap: 24px;
}
.stack__card {
position: sticky;
top: 12vh;
height: var(--card-h);
margin-bottom: var(--gap);
border-radius: 20px;
padding: 40px;
background: #18181b;
color: #fafafa;
transform-origin: 50% 0%;
}
@supports (animation-timeline: view()) {
.stack__card {
animation: card-shrink linear both;
animation-timeline: view();
/* 카드가 뷰포트를 빠져나가는 동안 축소 */
animation-range: exit-crossing 0% exit-crossing 100%;
}
@keyframes card-shrink {
to { scale: 0.9; filter: brightness(0.6); }
}
}
@media (prefers-reduced-motion: reduce) {
.stack__card { animation: none; position: static; }
}
4.9 언제 쓰고 언제 쓰지 말아야 하나
| 쓴다 | 쓰지 않는다 |
|---|---|
| 진행 바, 스크롤 인디케이터 | Firefox 지원이 요구사항인 핵심 기능 |
| 요소 등장 리빌 (1회성) | 요소 핀 고정 + 복잡한 시퀀스 → ScrollTrigger가 낫다 |
| 시차, 배경 시프트 | 스크롤 위치에 따라 DOM을 바꿔야 할 때 (CSS는 스타일만 바꾼다) |
| 헤더 축소/색 변화 | 스크롤 방향에 따라 다르게 동작해야 할 때 (CSS는 방향 인식 불가) |
| 수평 캐러셀 진행 표시 | 스크롤 스냅 후 콜백이 필요할 때 |
5. View Transitions API
5.1 같은 문서 (SPA / 상태 변화)
/**
* DOM을 바꾸는 함수를 view transition으로 감싼다.
* 미지원 브라우저에서는 그냥 즉시 바뀐다 → 점진 향상.
*/
function updateWithTransition(updateDOM) {
if (!document.startViewTransition) {
updateDOM();
return { finished: Promise.resolve() };
}
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
updateDOM();
return { finished: Promise.resolve() };
}
return document.startViewTransition(updateDOM);
}
// 사용 예: 리스트 필터링
document.querySelectorAll('[data-filter]').forEach((btn) => {
btn.addEventListener('click', () => {
updateWithTransition(() => {
const key = btn.dataset.filter;
document.querySelectorAll('.card').forEach((card) => {
card.hidden = key !== 'all' && card.dataset.category !== key;
});
});
});
});
/* 각 카드에 고유 이름을 주면 위치 이동이 자동 애니메이션된다 */
.card { view-transition-name: attr(data-id type(<custom-ident>)); }
/* attr() 타입 지원이 불확실하면 JS로 부여 */
::view-transition-group(*) {
animation-duration: 320ms;
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}
::view-transition-old(root) {
animation: 180ms cubic-bezier(0.5, 0, 0.75, 0) both vt-fade-out;
}
::view-transition-new(root) {
animation: 260ms cubic-bezier(0.16, 1, 0.3, 1) both vt-fade-in;
}
@keyframes vt-fade-out { to { opacity: 0; } }
@keyframes vt-fade-in { from { opacity: 0; } }
// view-transition-name을 JS로 안전하게 부여 (이름 중복은 즉시 에러가 된다)
document.querySelectorAll('.card').forEach((card) => {
card.style.viewTransitionName = `card-${card.dataset.id}`;
});
5.2 의사 요소 트리 구조
::view-transition (오버레이 루트, 뷰포트 전체 덮음)
└── ::view-transition-group(name) (위치·크기를 애니메이션하는 컨테이너)
└── ::view-transition-image-pair(name) (isolation: isolate)
├── ::view-transition-old(name) (이전 상태 스냅샷)
└── ::view-transition-new(name) (새 상태의 라이브 표현)
기본 애니메이션:
group: 위치/크기를 old → new로 보간image-pair: old는 페이드아웃, new는 페이드인 (크로스페이드)
5.3 shared element 전환 (썸네일 → 상세)
/* 목록의 썸네일과 상세의 큰 이미지에 같은 이름을 준다 */
.thumb[data-id="42"] img,
.detail[data-id="42"] img {
view-transition-name: hero-42;
}
/* 스냅샷 왜곡 방지 — 기본 object-fit이 fill이라 가로세로비가 깨진다 */
::view-transition-old(hero-42),
::view-transition-new(hero-42) {
object-fit: cover;
height: 100%;
overflow: clip;
}
::view-transition-group(hero-42) {
animation-duration: 420ms;
animation-timing-function: cubic-bezier(0.76, 0, 0.24, 1);
}
가장 흔한 사고 3가지
- 같은 시점에 같은
view-transition-name이 2개 존재 → 전환 전체가 즉시 실패한다. 필터링/정렬 UI에서 특히 자주 발생.hidden처리한 요소도 이름을 갖고 있으면 카운트된다.object-fit: fill기본값 → 이미지가 늘어난다. 위 코드처럼 명시적으로 덮어쓴다.position: fixed요소 → 스냅샷 좌표계가 달라 이상하게 움직인다.view-transition-name: none으로 제외.
5.4 view-transition-class와 types
/* 여러 요소에 공통 전환 스타일 적용 */
.card { view-transition-class: card-item; }
::view-transition-group(.card-item) {
animation-duration: 300ms;
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}
// 전환의 "종류"를 지정해 CSS에서 분기
const dir = newIndex > oldIndex ? 'forwards' : 'backwards';
document.startViewTransition({
update: () => renderSlide(newIndex),
types: [`slide-${dir}`],
});
html:active-view-transition-type(slide-forwards) {
&::view-transition-old(root) {
animation: 300ms cubic-bezier(0.5, 0, 0.75, 0) both slide-out-left;
}
&::view-transition-new(root) {
animation: 300ms cubic-bezier(0.16, 1, 0.3, 1) both slide-in-right;
}
}
html:active-view-transition-type(slide-backwards) {
&::view-transition-old(root) {
animation: 300ms cubic-bezier(0.5, 0, 0.75, 0) both slide-out-right;
}
&::view-transition-new(root) {
animation: 300ms cubic-bezier(0.16, 1, 0.3, 1) both slide-in-left;
}
}
@keyframes slide-out-left { to { translate: -30% 0; opacity: 0; } }
@keyframes slide-in-right { from { translate: 30% 0; opacity: 0; } }
@keyframes slide-out-right { to { translate: 30% 0; opacity: 0; } }
@keyframes slide-in-left { from { translate: -30% 0; opacity: 0; } }
5.5 문서 간 전환 (MPA)
두 페이지 모두에 아래 CSS가 있어야 한다. 같은 오리진이어야 한다.
@view-transition {
navigation: auto;
}
폐기된 문법 주의:
<meta name="view-transition" content="same-origin">은 더 이상 동작하지 않는다. 콘솔 경고 없이 조용히 실패하므로 오래된 튜토리얼을 복사하지 않는다.
// 나가는 페이지: 스냅샷 직전에 이름을 부여
window.addEventListener('pageswap', (event) => {
if (!event.viewTransition) return;
const targetUrl = new URL(event.activation.entry.url);
const id = targetUrl.searchParams.get('id');
if (id) {
const thumb = document.querySelector(`.thumb[data-id="${id}"] img`);
if (thumb) thumb.style.viewTransitionName = 'shared-hero';
}
// 스냅샷 후 즉시 정리 (다음 방문 시 중복 방지)
event.viewTransition.finished.finally(() => {
document.querySelectorAll('[style*="view-transition-name"]').forEach((el) => {
el.style.viewTransitionName = '';
});
});
});
// 들어오는 페이지: 렌더 직전에 이름을 부여
window.addEventListener('pagereveal', (event) => {
if (!event.viewTransition) return;
const from = navigation.activation?.from?.url;
if (from && new URL(from).pathname === '/list') {
const hero = document.querySelector('.detail img');
if (hero) hero.style.viewTransitionName = 'shared-hero';
}
// 4초 타임아웃 등 실패를 감지
event.viewTransition.finished.catch((err) => {
console.warn('view transition aborted:', err.name, err.message);
});
});
MPA 전환의 함정
- 4초 타임아웃: 새 페이지가 4초 안에 렌더되지 않으면 전환이 조용히 취소된다. 네트워크 지연이 이 시간에 포함된다.
- 렌더 블로킹으로 깜빡임 방지: 새 페이지의 핵심 요소가 준비되기 전에 렌더되면 전환이 어색해진다.
<link rel="expect" href="#hero" blocking="render"> - 스크롤 위치: 브라우저가 스크롤 복원을 처리하지만, 전환 애니메이션과 겹치면 어색하다.
상세 페이지로 갈 때는
history.scrollRestoration = 'manual'로 두고 명시적으로 최상단으로 보낸다.
5.6 전환 중 스크롤 위치 처리 (SPA)
/**
* SPA 라우터에서 view transition과 스크롤 복원을 함께 처리한다.
* 핵심: DOM 업데이트 콜백 안에서 스크롤을 옮겨야 스냅샷이 맞는다.
*/
const scrollPositions = new Map();
async function navigate(url, { isBack = false } = {}) {
scrollPositions.set(location.href, window.scrollY);
const html = await fetch(url).then((r) => r.text());
const doc = new DOMParser().parseFromString(html, 'text/html');
const apply = () => {
document.querySelector('#app').replaceChildren(
...doc.querySelector('#app').childNodes
);
document.title = doc.title;
history[isBack ? 'replaceState' : 'pushState']({}, '', url);
// 스냅샷이 찍히기 전에 스크롤을 확정한다
const y = isBack ? (scrollPositions.get(url) ?? 0) : 0;
window.scrollTo({ top: y, behavior: 'instant' });
};
if (!document.startViewTransition ||
matchMedia('(prefers-reduced-motion: reduce)').matches) {
apply();
return;
}
await document.startViewTransition(apply).finished;
}
5.7 prefers-reduced-motion 대응
@media (prefers-reduced-motion: reduce) {
/* 이동은 죽이고 크로스페이드만 남긴다 */
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation-duration: 120ms !important;
animation-timing-function: linear !important;
}
::view-transition-group(*) {
animation-name: none !important; /* 위치/크기 보간 제거 */
}
}
6. 스크롤 관련 CSS 유틸리티
6.1 scroll-behavior와 앵커 이동
/* 사용자 선호를 존중하는 부드러운 스크롤 — 이게 정답이다 */
@media (prefers-reduced-motion: no-preference) {
:root { scroll-behavior: smooth; }
}
/* 고정 헤더 아래로 앵커가 숨는 문제 해결 */
:target,
[id] { scroll-margin-block-start: 96px; }
scroll-behavior: smooth는 브라우저 네이티브다. Lenis 같은 라이브러리를 도입하기 전에 이걸로 충분한지 먼저 확인한다. 대부분의 경우 충분하다.
6.2 스크롤 스냅
.snap-scroller {
scroll-snap-type: y mandatory;
overflow-y: auto;
height: 100svh;
overscroll-behavior-y: contain; /* 부모로 스크롤 전파 차단 */
}
.snap-scroller > section {
scroll-snap-align: start;
scroll-snap-stop: always; /* 빠르게 스와이프해도 한 칸씩 */
min-height: 100svh;
}
mandatoryvsproximity:mandatory는 반드시 스냅되어 스크롤을 강제로 통제한다. 콘텐츠가 뷰포트보다 긴 섹션에서mandatory를 쓰면 내용을 읽을 수 없는 상태에 갇힌다. 확신이 없으면proximity를 쓴다.
7. height: auto 애니메이션 — 3가지 방법 비교
| 방법 | 지원 | 장점 | 단점 |
|---|---|---|---|
grid-template-rows: 0fr → 1fr |
Baseline widely | 순수 CSS, 모든 브라우저 | 마크업 한 겹 추가, min-height: 0 필요 |
interpolate-size: allow-keywords |
Chromium 129+ | 가장 간결 | Firefox/Safari 미지원 |
JS로 scrollHeight 측정 후 px 지정 |
전부 | 완전한 제어 | 레이아웃 강제 계산(리플로우), 코드 증가 |
권장: grid-template-rows 방식을 기본으로, interpolate-size를 점진 향상으로 겹친다.
<div class="accordion">
<button class="accordion__trigger" aria-expanded="false" aria-controls="panel-1">
섹션 제목
</button>
<div class="accordion__panel" id="panel-1" hidden>
<div class="accordion__inner">
<p>패널 내용이 여기 들어갑니다.</p>
</div>
</div>
</div>
.accordion__panel {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 300ms cubic-bezier(0.16, 1, 0.3, 1);
}
.accordion__panel[data-open] {
grid-template-rows: 1fr;
}
.accordion__inner {
min-height: 0; /* 필수: grid item의 기본 min-height는 auto */
overflow: hidden;
}
.accordion__inner > * { padding-block: 12px; }
/* Chromium 향상: 더 정확한 intrinsic 보간 */
@supports (interpolate-size: allow-keywords) {
:root { interpolate-size: allow-keywords; }
}
@media (prefers-reduced-motion: reduce) {
.accordion__panel { transition-duration: 1ms; }
}
document.querySelectorAll('.accordion__trigger').forEach((trigger) => {
const panel = document.getElementById(trigger.getAttribute('aria-controls'));
trigger.addEventListener('click', () => {
const willOpen = trigger.getAttribute('aria-expanded') === 'false';
trigger.setAttribute('aria-expanded', String(willOpen));
if (willOpen) {
panel.hidden = false;
// hidden 해제 후 리플로우를 강제해야 transition이 발동한다
void panel.offsetHeight;
panel.dataset.open = '';
} else {
delete panel.dataset.open;
panel.addEventListener('transitionend', function onEnd(e) {
if (e.propertyName !== 'grid-template-rows') return;
panel.removeEventListener('transitionend', onEnd);
if (trigger.getAttribute('aria-expanded') === 'false') panel.hidden = true;
});
}
});
});
8. 가변 폰트 축 애니메이션
@font-face {
font-family: 'Inter Var';
src: url('/fonts/InterVariable.woff2') format('woff2-variations');
font-weight: 100 900;
font-display: swap;
}
/* 1) 표준 속성으로 애니메이션 — 브라우저 최적화가 붙어 이 쪽이 낫다 */
.weight-hover {
font-family: 'Inter Var', system-ui, sans-serif;
font-weight: 400;
transition: font-weight 260ms cubic-bezier(0.16, 1, 0.3, 1);
}
.weight-hover:hover { font-weight: 750; }
/* 2) 커스텀 축은 font-variation-settings로 — 단, 전부 한 줄에 나열해야 한다 */
@property --wght { syntax: '<number>'; inherits: false; initial-value: 400; }
@property --slnt { syntax: '<number>'; inherits: false; initial-value: 0; }
.vf-axis {
font-family: 'Inter Var', system-ui, sans-serif;
font-variation-settings: 'wght' var(--wght), 'slnt' var(--slnt);
transition: --wght 300ms cubic-bezier(0.16, 1, 0.3, 1),
--slnt 300ms cubic-bezier(0.16, 1, 0.3, 1);
}
.vf-axis:hover { --wght: 800; --slnt: -8; }
@media (prefers-reduced-motion: reduce) {
.weight-hover, .vf-axis { transition: none; }
}
가변 폰트 애니메이션의 비용과 판단 기준
| 항목 | 사실 |
|---|---|
| 합성 가능? | 아니오. 글리프를 다시 래스터화하므로 매 프레임 paint 발생 |
| 레이아웃 영향? | 있음. wght, wdth 변화는 글자 폭을 바꿔 리플로우를 유발한다 |
| 안전한 사용 | 짧은 텍스트(제목, 버튼 라벨, 로고), 1회성 전환, 소수 요소 |
| 위험한 사용 | 본문 단락, 리스트 전체, 스크롤에 물린 지속 애니메이션 |
| 완화 | 컨테이너에 contain: layout; 또는 font-variation-settings 대신 font-synthesis 없이 고정폭 축(wdth 고정)만 사용 |
권장:
font-variation-settings를 매 프레임 바꾸는 스크롤 연동 효과는 만들지 않는다. 호버·포커스 같은 이산적 상태 변화에만 쓴다. 리플로우를 막으려면 텍스트 컨테이너에 고정 폭을 주거나text-wrap: balance와 함께 쓰지 않는다.
9. SVG 필터 파라미터 애니메이션 — CSS / SMIL / JS 비교
9.1 세 방법
| 방법 | 코드 | 장점 | 단점 | 판정 |
|---|---|---|---|---|
CSS + @property |
@property --scale + feDisplacementMap scale="…" 는 불가 (SVG 속성은 CSS 변수로 직접 못 받음) |
— | SVG 프레젠테이션 속성이 아닌 필터 primitive 속성은 CSS로 제어 불가 | 필터 primitive 속성에는 사용 불가 |
SMIL <animate> |
선언적, 마크업 안에 완결 | 코드가 짧고 JS 불필요 | Chrome이 한때 폐기 예고했다가 철회. IE 미지원(무관). 제어(일시정지/역재생)가 어렵고 prefers-reduced-motion 대응이 번거로움 |
단순 루프에는 OK |
| JS로 속성 직접 갱신 | filter.setAttribute('scale', v) |
완전한 제어, reduced-motion 대응 용이, GSAP attr 플러그인과 궁합 |
메인 스레드 부하 | 프로덕션 권장 |
9.2 완성 코드 — 호버 시 물결 왜곡 (JS 제어)
<div class="distort">
<img src="/photo.jpg" alt="" class="distort__img">
<svg width="0" height="0" aria-hidden="true" focusable="false">
<filter id="wobble" x="-20%" y="-20%" width="140%" height="140%">
<feTurbulence
id="wobble-noise"
type="fractalNoise"
baseFrequency="0.008 0.014"
numOctaves="2"
seed="7"
result="noise" />
<feDisplacementMap
id="wobble-disp"
in="SourceGraphic"
in2="noise"
scale="0"
xChannelSelector="R"
yChannelSelector="G" />
</filter>
</svg>
</div>
.distort { display: inline-block; overflow: hidden; border-radius: 12px; }
.distort__img {
display: block;
max-width: 100%;
filter: url(#wobble);
/* 필터가 걸린 요소는 자체 레이어를 갖는다 — 크기를 작게 유지할 것 */
}
const disp = document.getElementById('wobble-disp');
const noise = document.getElementById('wobble-noise');
const wrap = document.querySelector('.distort');
const reduce = matchMedia('(prefers-reduced-motion: reduce)');
let raf = null;
let current = 0;
let target = 0;
let t = 0;
function tick() {
// 지수 감쇠 보간 (lerp) — 프레임레이트에 덜 민감한 형태
current += (target - current) * 0.12;
t += 0.004;
disp.setAttribute('scale', current.toFixed(2));
// 노이즈 주파수를 아주 미세하게 흔들어 "살아있는" 느낌을 준다
const fx = (0.008 + Math.sin(t) * 0.0015).toFixed(5);
const fy = (0.014 + Math.cos(t * 0.8) * 0.0015).toFixed(5);
noise.setAttribute('baseFrequency', `${fx} ${fy}`);
if (Math.abs(target - current) > 0.05 || target > 0) {
raf = requestAnimationFrame(tick);
} else {
disp.setAttribute('scale', '0');
raf = null;
}
}
function start() {
if (reduce.matches) return;
target = 26;
if (!raf) raf = requestAnimationFrame(tick);
}
function stop() {
target = 0;
if (!raf) raf = requestAnimationFrame(tick);
}
wrap.addEventListener('pointerenter', start);
wrap.addEventListener('pointerleave', stop);
wrap.addEventListener('focusin', start);
wrap.addEventListener('focusout', stop);
9.3 SMIL 버전 (참고 — 단순 루프에만)
<svg width="0" height="0" aria-hidden="true">
<filter id="drift">
<feTurbulence type="fractalNoise" baseFrequency="0.01" numOctaves="2" result="n">
<animate
attributeName="baseFrequency"
dur="14s"
values="0.010;0.016;0.010"
repeatCount="indefinite"
calcMode="spline"
keySplines="0.42 0 0.58 1; 0.42 0 0.58 1" />
</feTurbulence>
<feDisplacementMap in="SourceGraphic" in2="n" scale="12"
xChannelSelector="R" yChannelSelector="G" />
</filter>
</svg>
// SMIL도 reduced-motion을 존중하게 만든다
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
document.querySelectorAll('animate, animateTransform, animateMotion')
.forEach((el) => el.parentElement.removeChild(el));
}
9.4 SVG 필터 성능 규칙 (타협 불가)
feTurbulence는 가장 비싼 primitive다. 필터 영역의 픽셀마다 노이즈를 계산한다. 필터가 걸린 요소 크기를 300×300px 이하로 유지하거나, 큰 영역에는 쓰지 않는다.filter영역(x/y/width/height)을 반드시 명시해 확장 범위를 제한한다. 기본값은 -10%~120%다.- 필터 파라미터와 transform을 동시에 애니메이션하지 않는다. 이동은 transform으로, 왜곡은 필터로 하되 왜곡은 짧은 순간만.
- 정적인 텍스처(그레인, 종이 질감)는 필터를 실시간 계산하지 말고 PNG/WebP로 미리 렌더해서
background-image로 깐다. 비용이 0이 된다. - 모바일에서는 필터 애니메이션을 아예 끄는 것을 기본값으로 고려한다.
/* 저사양/모바일에서 필터 비활성화 */
@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와의 동기화