React Animation
설치본 플러그인에는 docs/react/ 가 없다 — 이 파일의 docs/... 경로를 열 수 없으면 https://raw.githubusercontent.com/joo6077/claude-plugins/main/ 뒤에 같은 경로를 붙여 읽고, 그래도 못 읽으면 내용을 지어내지 말고 못 읽었다고 적는다.
주의사항
-
라이브러리 0개 원칙 — 절대 예외 없음: Motion(framer-motion) / dnd-kit / react-spring / react-transition-group / @formkit/auto-animate / react-dnd / gsap / lottie-react / react-beautiful-dnd / animate.css 는 설치 및 import 금지. 이들의 import 구문이 코드베이스에 존재하면
/react-audit이 빌드 실패를 발행한다. 사용자가 요청하더라도 대안 구현을 제시하고 라이브러리 사용을 거부한다. -
가장 낮은 Tier부터 시도: "animation" 요청을 받으면 무조건 Tier 3부터 쓰는 실수가 잦다. 판정 규칙을 따라 Tier 1로 해결 가능한지 먼저 확인하고, 불가능할 때만 더 높은 Tier로 올라간다.
-
tailwindcss-animate플러그인 확인:animate-in,fade-in-*,slide-in-from-*같은 확장 유틸리티는tailwindcss-animate가 있어야 동작한다. shadcn 설치 프로젝트에는 기본 포함되지만, 없으면pnpm add -D tailwindcss-animate후tailwind.config.ts에 플러그인 등록을 안내한다. -
view-transition-name고유성: 같은 이름을 두 요소가 동시에 가지면 "duplicate name" 오류가 발생한다. 반드시 동적 id (view-transition-name: task-${item.id})를 사용해 고유성을 보장한다. -
Pointer cancel 처리 필수:
pointercancel이벤트를 처리하지 않으면 탭 전환, 다른 앱 포커스, 요소 unmount 시 드래그 상태가 leak된다.onPointerCancel핸들러에서 반드시 FSM을idle로 리셋한다. -
touch-action: none빠뜨리면 모바일 드래그 불가: pointer event 기반 드래그 요소에 Tailwindtouch-none클래스를 반드시 추가해야 브라우저 기본 스크롤/줌이 차단된다. -
Zustand drag store는 전역 단일 인스턴스: feature마다 별도 drag store를 만들지 않는다.
src/presentation/shared/stores/drag-store.ts하나를 모든 feature가 공유한다. -
prefers-reduced-motion미적용: 모든 Tier에서@media (prefers-reduced-motion: reduce)또는 Tailwindmotion-reduce:*variant로 애니메이션을 비활성화해야 한다./react-audit이 누락 시 경고를 발행한다. -
애니메이션은 presentation 전용:
domain/,data/레이어에 pointer event / view transition / Zustand drag store를 import하지 않는다. 애니메이션 관련 코드는src/presentation/에만 위치한다. -
Firefox types 파라미터 미지원:
document.startViewTransition({ update, types: [...] })의types옵션은 2026년 기준 Chromium 계열에서만 안정적으로 지원된다.withViewTransition래퍼에서 try/catch로 fallback을 처리한다. -
scroll-driven animation 브라우저 지원 (2026-08-13 갱신):
animation-timeline: scroll()/scroll-timeline은 Can I Use 기준 전역 85.43% — Chrome/Edge 115+, Safari/iOS 26+, Firefox 156+ 다. Firefox 가 플래그 뒤에 있다는 서술은 낡았다. 다만 MDN 기준view()는 아직 not Baseline 이므로,@supports (animation-timeline: scroll())/@supports (animation-timeline: view())로 감싸고 미지원 시 정적 스타일로 fallback 하는 방침은 그대로 유지한다. -
view-transition-name: match-element활용 (Chrome 137+): 수십 개 요소에 수동으로 고유 이름을 부여하는 대신view-transition-name: match-elementCSS 값을 사용하면 브라우저가 자동으로 요소를 매칭한다. 단, 2026-Q2 기준 Chromium 전용이므로 fallback 경로를 유지한다. -
모션은 정적 코드로 입증되지 않는다 — 완료 선언 전 증거 필수 (E2): 이 스킬의 산출물은 재생되어야만 존재를 확인할 수 있다.
animate-*클래스가 붙어 있다는 사실, 코드가startViewTransition을 부른다는 사실은 R3(정적) 증거이며 애니메이션이 실제로 재생됐다는 증거가 아니다 — keyframe 미정의,prefers-reduced-motion상시 적중, 부모의overflow: hidden클리핑, 미지원 브라우저 fallback 진입은 전부 조용히 "아무 일도 안 일어남" 으로 끝난다. 완료 직전에react-kit/references/render-evidence-protocol.md§4 체크리스트를 채운다. 스냅샷 비교로 검증할 때는 전/후 두 시점을 지목한다 — 정지 프레임 1 장은 어떤 모션에도 같은 결과를 내므로 oracle 이 아니다 (§3 d). 증거를 못 얻으면[미검증]을 달고 네 칸(막는 것 · 시도한 우회 · 통제 불가 사유 · 재검증 명령 — 규약 §2)을 채워 부분 완료로 보고한다.증거 확보를 위해 애니메이션 라이브러리를 도입하지 않는다. Library Policy (Gotcha #1) 는 이 규약보다 상위이며 어떤 검증 편의로도 완화되지 않는다.
-
기준 캡처는 편집 전에 찍는다 —
react-kit/references/render-evidence-protocol.md§1 Step 0 과 §2 비교 반복 순서의 1 번을 첫 편집 전에 실행하고 그 결과(되말하기 · 관례 표 · 지금 모습의 캡처 경로와 바뀌어야 할 표식)를 응답에 남긴다. 편집한 뒤에는 편집 전 모습을 다시 찍을 수 없다. -
Tier 2 는 업데이트 종류로 가른다 — React 19.3
<ViewTransition>은 안정 API 다 (2026-09-26 추가):react에서 가져오는<ViewTransition>은 19.3 에서 실험 API 가 아니라 안정 API 가 됐다. 감싼 자식이 Transition 으로 표시된 업데이트(startTransition안의 갱신 · Suspense reveal ·useDeferredValue)로 스타일이 바뀌거나 마운트 · 언마운트될 때 돌고, DOM 에서만 동작한다 (출처: https://react.dev/blog/2026/09/09/react-19-3 「We shared it as an experimental API last year, and in 19.3 it's stable and ready to use.」). 그러니 Transition 으로 표시된 React 상태 갱신에는<ViewTransition>을 쓰고, 그 밖의 DOM 갱신(Transition 이 아닌 동기 갱신 · React 밖 DOM 조작)에는 §3.1withViewTransition래퍼를 그대로 쓴다. 래퍼를 지우지 않는다. react 패키지 안의 컴포넌트라 라이브러리 0개 원칙(#1)에 걸리지 않는다. 프로젝트react판이 19.3 미만이면 래퍼만 쓴다
프로세스
1. 자동 티어 판정
--tier 플래그가 없으면 사용자 요청을 분석해 가장 낮은 티어를 선택한다.
| 키워드 / 시나리오 | 판정 Tier |
|---|---|
| "fade in", "slide up", "scale", "pulse", "bounce", "hover 효과", "opacity", "shimmer", "진입 애니메이션" | T1 |
| "skeleton 로딩 → 완료 전환", "버튼 hover", "accordion", "모달 open/close", "상태 변화" | T1 |
| "스크롤 애니메이션", "scroll-driven", "parallax", "스크롤 진행 바", "스크롤 기반" | T1 |
| "페이지 전환", "shared element", "grid to board", "뷰 전환", "라우트 전환", "DOM 구조 변경" | T2 |
| "scroll-triggered", "스크롤 교차 시 트리거", "특정 위치에서 애니메이션 시작" | T2 |
| "드래그앤드롭", "정렬 리스트", "sortable", "kanban", "드래그", "드롭", "gesture", "화살표 연결선" | T3 |
T2/T3 경계가 애매하면 animation-architect-react 에이전트에 자문을 요청한다.
3-Tier 요약:
| Tier | 도구 | 적용 시나리오 | 난이도 |
|---|---|---|---|
| T1 | Tailwind animate-* + CSS @keyframes + scroll-driven | 상태 변화, hover, 단순 loop, 스크롤 연동 | 낮음 |
| T2 | View Transitions API (withViewTransition 래퍼) · React 19.3 <ViewTransition> (Transition 으로 표시된 업데이트만 — Gotcha 15) | 뷰/라우트 전환, shared element, FLIP | 중 |
| T3 | Pointer Events + FSM + requestAnimationFrame | 드래그앤드롭, 제스처, SVG 연결선 | 높음 |
2. Tier 1 — Tailwind + CSS 구현
2.1 Tailwind 내장 유틸리티
| 카테고리 | 유틸 | 예시 |
|---|---|---|
| Transition | transition-transform, transition-colors, transition-opacity | hover:scale-105 transition-transform duration-200 |
| Duration | duration-75~duration-1000 | duration-300 |
| Easing | ease-linear, ease-in, ease-out, ease-in-out | ease-in-out |
| Animation (내장) | animate-spin, animate-ping, animate-pulse, animate-bounce | 로딩, 알림 |
| Delay | delay-75~delay-1000 | stagger 효과 |
2.2 tailwindcss-animate 확장 유틸
tailwindcss-animate 플러그인이 제공하는 enter/exit 애니메이션:
animate-in/animate-outfade-in-*,fade-out-*slide-in-from-top/bottom/left/right-*,slide-out-to-*zoom-in-*,zoom-out-*
data-[state=...] 속성 선택자와 조합해 상태 기반 애니메이션을 구현한다:
// 드로어 open/close 예시
<div
data-state={open ? 'open' : 'closed'}
className="
data-[state=open]:animate-in data-[state=closed]:animate-out
data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0
data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left
duration-300
motion-reduce:transition-none motion-reduce:animate-none
"
>
드로어 내용
</div>
2.3 CSS @keyframes 직접 선언
Tailwind 유틸로 커버 안 되는 커스텀 애니메이션은 globals.css 의 @theme 블록에 선언한다:
/* src/presentation/styles/globals.css */
@import "tailwindcss";
@theme {
--animate-wiggle: wiggle 1s ease-in-out infinite;
--animate-shimmer: shimmer 2s linear infinite;
@keyframes wiggle {
0%, 100% { transform: rotate(-3deg); }
50% { transform: rotate(3deg); }
}
@keyframes shimmer {
from { background-position: -200% 0; }
to { background-position: 200% 0; }
}
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
사용: <div className="animate-wiggle">...</div>
2.4 Tier 1 시나리오 예시
Fade-in 진입:
<div className="animate-in fade-in-0 duration-500 motion-reduce:animate-none">
페이지 로드 시 페이드 진입
</div>
Hover scale 버튼:
<button className="transition-transform duration-200 hover:scale-105 active:scale-95 motion-reduce:transition-none">
버튼
</button>
스태거 리스트 진입:
{items.map((item, i) => (
<div
key={item.id}
className="animate-in slide-in-from-bottom-4 fade-in-0 motion-reduce:animate-none"
style={{ animationDelay: `${i * 50}ms`, animationFillMode: 'backwards' }}
>
{item.name}
</div>
))}
2.5 Scroll-driven Animations (CSS 네이티브)
스크롤 위치에 연동되는 애니메이션을 JS 없이 CSS animation-timeline 으로 구현한다. 2026-08-13 기준 scroll() / scroll-timeline 은 Chrome/Edge 115+, Safari/iOS 26+, Firefox 156+ 에서 지원되고 (Can I Use 전역 85.43%), view() 는 MDN 기준 아직 not Baseline 이다. 두 경우 모두 @supports 로 감싸 미지원 브라우저에서 graceful fallback 한다.
스크롤 진행 바:
/* globals.css */
@keyframes progress-grow {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
<div
className="fixed top-0 left-0 h-1 w-full origin-left bg-primary motion-reduce:hidden"
style={{
animation: 'progress-grow linear',
animationTimeline: 'scroll(root block)',
}}
/>
요소 진입 시 fade-in (view timeline):
@keyframes fade-slide-in {
from { opacity: 0; transform: translateY(2rem); }
to { opacity: 1; transform: translateY(0); }
}
@supports (animation-timeline: view()) {
.scroll-reveal {
animation: fade-slide-in linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
}
<section className="scroll-reveal motion-reduce:opacity-100">
스크롤 시 등장하는 콘텐츠
</section>
주의: scroll-triggered animations (Chrome 145, 2026) 는 특정 스크롤 오프셋 교차 시 시간 기반 애니메이션을 트리거하는 별개 개념이다. scroll-driven 과 혼동하지 않는다. scroll-triggered 는 Tier 2 후보로 분류한다.
3. Tier 2 — View Transitions API 구현
same-document View Transitions 는 MDN 기준 Baseline 2025, Can I Use 기준 전역 90.2% — Chrome/Edge 111+, Safari 18+, Firefox 144+ (2026-08-13 갱신). SPA 내 DOM 변경 시 자동 FLIP 애니메이션.
반면 @view-transition 기반 cross-document / MPA 전환과 일부 세부 기능은 아직 limited availability 다. 라우트 전환에 쓸 때는 same-document 경로를 기본으로 두고 feature detection + fallback 을 유지한다 (§6 표준 커버리지 공백 8 번).
3.1 withViewTransition 래퍼 생성
파일이 없으면 생성한다:
// src/presentation/shared/lib/view-transition.ts
export function withViewTransition(
updateDOM: () => void,
options?: { types?: string[] },
): void {
// prefers-reduced-motion 존중
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
updateDOM()
return
}
// Feature detection (구형 브라우저 fallback)
if (typeof document.startViewTransition !== 'function') {
updateDOM()
return
}
// types 파라미터 지원 여부 분기 (Firefox 초기 구현 미지원)
try {
if (options?.types && options.types.length > 0) {
document.startViewTransition({ update: updateDOM, types: options.types })
} else {
document.startViewTransition(updateDOM)
}
} catch {
updateDOM()
}
}
3.2 view-transition-name CSS 공유 요소
DOM 구조가 바뀌어도 "같은 요소"로 식별해 부드럽게 이동시킨다:
// 동적 id로 고유성 보장 (중복 이름 금지)
<div
className="rounded-lg border p-4"
style={{ viewTransitionName: `task-${task.id}` }}
>
{task.title}
</div>
3.3 그리드 ↔ 보드 뷰 전환 (대표 시나리오)
// src/presentation/features/tasks/components/task-switcher.tsx
import { withViewTransition } from '@/presentation/shared/lib/view-transition'
import { useTaskStore } from '../store'
export function TaskViewSwitcher() {
const viewMode = useTaskStore((s) => s.viewMode)
const setViewMode = useTaskStore((s) => s.setViewMode)
return (
<button
onClick={() => {
withViewTransition(() => {
setViewMode(viewMode === 'grid' ? 'board' : 'grid')
})
}}
>
{viewMode === 'grid' ? '보드 뷰로' : '그리드 뷰로'}
</button>
)
}
export function TaskList({ tasks }: { tasks: Task[] }) {
const viewMode = useTaskStore((s) => s.viewMode)
return (
<div className={viewMode === 'grid' ? 'grid grid-cols-3 gap-4' : 'flex gap-4 overflow-x-auto'}>
{tasks.map((task) => (
<div
key={task.id}
className="rounded-lg border p-4"
style={{ viewTransitionName: `task-${task.id}` }}
>
{task.title}
</div>
))}
</div>
)
}
3.4 CSS 전환 세부 조정
/* globals.css — 전환 duration + easing */
::view-transition-group(*) {
animation-duration: 0.4s;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
/* 특정 요소 개별 조정 */
::view-transition-group(hero-image) {
animation-duration: 0.6s;
}
/* reduced-motion 에서 즉시 전환 */
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*) {
animation-duration: 0.01ms;
}
}
4. Tier 3 — 커스텀 Pointer Primitives 구현
라이브러리 없이 Pointer Events API + 상태 머신(FSM)으로 직접 구현한다.
4.1 useDrag 훅 생성
// src/presentation/shared/hooks/use-drag.ts
import { useCallback, useEffect, useReducer, useRef } from 'react'
// FSM 3 상태: idle / dragging / dropping
type DragState =
| { kind: 'idle' }
| { kind: 'dragging'; startX: number; startY: number; offsetX: number; offsetY: number; pointerId: number }
| { kind: 'dropping'; offsetX: number; offsetY: number }
type DragAction =
| { type: 'start'; x: number; y: number; pointerId: number }
| { type: 'move'; x: number; y: number }
| { type: 'drop' }
| { type: 'resolved' }
| { type: 'cancel' }
function dragReducer(state: DragState, action: DragAction): DragState {
switch (action.type) {
case 'start':
return { kind: 'dragging', startX: action.x, startY: action.y, offsetX: 0, offsetY: 0, pointerId: action.pointerId }
case 'move':
if (state.kind !== 'dragging') return state
return { ...state, offsetX: action.x - state.startX, offsetY: action.y - state.startY }
case 'drop':
if (state.kind !== 'dragging') return state
return { kind: 'dropping', offsetX: state.offsetX, offsetY: state.offsetY }
case 'resolved':
case 'cancel':
return { kind: 'idle' }
}
}
type DragHandlers = {
onPointerDown: (e: React.PointerEvent) => void
onPointerMove: (e: React.PointerEvent) => void
onPointerUp: (e: React.PointerEvent) => void
onPointerCancel: (e: React.PointerEvent) => void
}
export function useDrag(): {
state: DragState
handlers: DragHandlers
ref: React.RefObject<HTMLElement | null>
resolve: () => void
} {
const [state, dispatch] = useReducer(dragReducer, { kind: 'idle' })
const ref = useRef<HTMLElement | null>(null)
const onPointerDown = useCallback((e: React.PointerEvent) => {
const el = ref.current
if (!el) return
el.setPointerCapture(e.pointerId)
dispatch({ type: 'start', x: e.clientX, y: e.clientY, pointerId: e.pointerId })
}, [])
const onPointerMove = useCallback((e: React.PointerEvent) => {
dispatch({ type: 'move', x: e.clientX, y: e.clientY })
}, [])
const onPointerUp = useCallback((e: React.PointerEvent) => {
dispatch({ type: 'drop' })
}, [])
const onPointerCancel = useCallback((e: React.PointerEvent) => {
dispatch({ type: 'cancel' })
}, [])
const resolve = useCallback(() => {
dispatch({ type: 'resolved' })
}, [])
return {
state,
handlers: { onPointerDown, onPointerMove, onPointerUp, onPointerCancel },
ref,
resolve,
}
}
4.2 컴포넌트 예시
export function DraggableCard({ id, children }: { id: string; children: React.ReactNode }) {
const { state, handlers, ref, resolve } = useDrag()
return (
<div
ref={ref}
className={`rounded-lg border p-4 cursor-grab touch-none ${state.kind === 'dragging' ? 'opacity-50 scale-105' : ''}`}
{...handlers}
style={{
transform: state.kind === 'dragging' || state.kind === 'dropping'
? `translate(${state.offsetX}px, ${state.offsetY}px)`
: undefined,
transition: state.kind === 'dropping' ? 'transform 0.2s ease' : undefined,
}}
onTransitionEnd={() => {
if (state.kind === 'dropping') resolve()
}}
>
{children}
</div>
)
}
4.3 Zustand drag store 통합
// src/presentation/shared/stores/drag-store.ts
import { create } from 'zustand'
type DragItem = {
id: string
offsetX: number
offsetY: number
} | null
interface DragStore {
activeDrag: DragItem
setActiveDrag: (item: DragItem) => void
clearDrag: () => void
}
export const useDragStore = create<DragStore>((set) => ({
activeDrag: null,
setActiveDrag: (item) => set({ activeDrag: item }),
clearDrag: () => set({ activeDrag: null }),
}))
5. 검증 및 테스트
5.1 정적 검증
/react-audit명령어를 통해 라이브러리 import 유무,prefers-reduced-motion누락,view-transition-name중복 등을 검사한다.- 모든 Tier에서 생성된 코드가 Gotcha 리스트를 위반하지 않는지 확인한다.
5.2 런타임 검증 (증거 프로토콜)
Gotcha #13 과 #14 에 따라, 완료 전에 애니메이션이 실제로 재생되는지 확인한다:
- 기준 캡처: 편집 전 상태의 스냅샷을 찍거나, 현재 UI 동작을 설명한다.
- 편집 적용: 애니메이션 코드를 적용한 후 변경된 상태를 캡처한다.
- 증거 비교: 전/후 두 시점의 차이를 명시적으로 제시한다.
- 검증 불가 시:
[미검증]태그와 함께 사유를 기록한다.
5.3 브라우저 호환성 체크리스트
@supports또는canIuseAPI를 사용하여 최신 브라우저 기능(feature)을 감지하고 fallback을 제공한다.prefers-reduced-motion설정 시 모든 애니메이션이 비활성화되는지 확인한다.- Firefox의
types파라미터 미지원 등 브라우저별 차이를 고려한다.