Communitygithub.com

joo6077/react-animation

설치본 플러그인에는 `docs/react/` 가 없다 — 이 파일의 `docs/...` 경로를 열 수 없으면 `https://raw.githubusercontent.com/joo6077/claude-plugins/main/` 뒤에 같은 경로를 붙여 읽고, 그래도 못 읽으면 내용을 지어내지 말고 못 읽었다고 적는다.

What is react-animation?

react-animation is a Claude Code agent skill that 설치본 플러그인에는 `docs/react/` 가 없다 — 이 파일의 `docs/...` 경로를 열 수 없으면 `https://raw.githubusercontent.com/joo6077/claude-plugins/main/` 뒤에 같은 경로를 붙여 읽고, 그래도 못 읽으면 내용을 지어내지 말고 못 읽었다고 적는다.

Works with✓Claude Code~Codex CLI✓Cursor
npx skills add https://github.com/joo6077/claude-plugins/tree/HEAD/react-kit/skills/react-animation

Ask in your favorite AI

Open a new chat with this agent skill pre-loaded.

Documentation

React Animation

설치본 플러그인에는 docs/react/ 가 없다 — 이 파일의 docs/... 경로를 열 수 없으면 https://raw.githubusercontent.com/joo6077/claude-plugins/main/ 뒤에 같은 경로를 붙여 읽고, 그래도 못 읽으면 내용을 지어내지 말고 못 읽었다고 적는다.

Gotchas

  1. 라이브러리 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 이 빌드 실패를 발행한다. 사용자가 요청하더라도 대안 구현을 제시하고 라이브러리 사용을 거부한다.

  2. 가장 낮은 Tier부터 시도: "animation" 요청을 받으면 무조건 Tier 3부터 쓰는 실수가 잦다. 판정 규칙을 따라 Tier 1로 해결 가능한지 먼저 확인하고, 불가능할 때만 더 높은 Tier로 올라간다.

  3. tailwindcss-animate 플러그인 확인: animate-in, fade-in-*, slide-in-from-* 같은 확장 유틸리티는 tailwindcss-animate 가 있어야 동작한다. shadcn 설치 프로젝트에는 기본 포함되지만, 없으면 pnpm add -D tailwindcss-animate 후 tailwind.config.ts 에 플러그인 등록을 안내한다.

  4. view-transition-name 고유성: 같은 이름을 두 요소가 동시에 가지면 "duplicate name" 오류가 발생한다. 반드시 동적 id (view-transition-name: task-${item.id})를 사용해 고유성을 보장한다.

  5. Pointer cancel 처리 필수: pointercancel 이벤트를 처리하지 않으면 탭 전환, 다른 앱 포커스, 요소 unmount 시 드래그 상태가 leak된다. onPointerCancel 핸들러에서 반드시 FSM을 idle로 리셋한다.

  6. touch-action: none 빠뜨리면 모바일 드래그 불가: pointer event 기반 드래그 요소에 Tailwind touch-none 클래스를 반드시 추가해야 브라우저 기본 스크롤/줌이 차단된다.

  7. Zustand drag store는 전역 단일 인스턴스: feature마다 별도 drag store를 만들지 않는다. src/presentation/shared/stores/drag-store.ts 하나를 모든 feature가 공유한다.

  8. prefers-reduced-motion 미적용: 모든 Tier에서 @media (prefers-reduced-motion: reduce) 또는 Tailwind motion-reduce:* variant로 애니메이션을 비활성화해야 한다. /react-audit 이 누락 시 경고를 발행한다.

  9. 애니메이션은 presentation 전용: domain/, data/ 레이어에 pointer event / view transition / Zustand drag store를 import하지 않는다. 애니메이션 관련 코드는 src/presentation/ 에만 위치한다.

  10. Firefox types 파라미터 미지원: document.startViewTransition({ update, types: [...] }) 의 types 옵션은 2026년 기준 Chromium 계열에서만 안정적으로 지원된다. withViewTransition 래퍼에서 try/catch로 fallback을 처리한다.

  11. 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 하는 방침은 그대로 유지한다.

  12. view-transition-name: match-element 활용 (Chrome 137+): 수십 개 요소에 수동으로 고유 이름을 부여하는 대신 view-transition-name: match-element CSS 값을 사용하면 브라우저가 자동으로 요소를 매칭한다. 단, 2026-Q2 기준 Chromium 전용이므로 fallback 경로를 유지한다.

  13. 모션은 정적 코드로 입증되지 않는다 — 완료 선언 전 증거 필수 (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) 는 이 규약보다 상위이며 어떤 검증 편의로도 완화되지 않는다.

  14. 기준 캡처는 편집 전에 찍는다 — react-kit/references/render-evidence-protocol.md §1 Step 0 과 §2 비교 반복 순서의 1 번을 첫 편집 전에 실행하고 그 결과(되말하기 · 관례 표 · 지금 모습의 캡처 경로와 바뀌어야 할 표식)를 응답에 남긴다. 편집한 뒤에는 편집 전 모습을 다시 찍을 수 없다.

  15. 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.1 withViewTransition 래퍼를 그대로 쓴다. 래퍼를 지우지 않는다. react 패키지 안의 컴포넌트라 라이브러리 0개 원칙(#1)에 걸리지 않는다. 프로젝트 react 판이 19.3 미만이면 래퍼만 쓴다

Process

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도구적용 시나리오난이도
T1Tailwind animate-* + CSS @keyframes + scroll-driven상태 변화, hover, 단순 loop, 스크롤 연동낮음
T2View Transitions API (withViewTransition 래퍼) · React 19.3 <ViewTransition> (Transition 으로 표시된 업데이트만 — Gotcha 15)뷰/라우트 전환, shared element, FLIP중
T3Pointer Events + FSM + requestAnimationFrame드래그앤드롭, 제스처, SVG 연결선높음

2. Tier 1 — Tailwind + CSS 구현

2.1 Tailwind 내장 유틸리티

카테고리유틸예시
Transitiontransition-transform, transition-colors, transition-opacityhover:scale-105 transition-transform duration-200
Durationduration-75~duration-1000duration-300
Easingease-linear, ease-in, ease-out, ease-in-outease-in-out
Animation (내장)animate-spin, animate-ping, animate-pulse, animate-bounce로딩, 알림
Delaydelay-75~delay-1000stagger 효과

2.2 tailwindcss-animate 확장 유틸

tailwindcss-animate 플러그인이 제공하는 enter/exit 애니메이션:

  • animate-in / animate-out
  • fade-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) => {
    e.currentTarget.setPointerCapture(e.pointerId)
    dispatch({ type: 'start', x: e.clientX, y: e.clientY, pointerId: e.pointerId })
  }, [])

  const onPointerMove = useCallback((e: React.PointerEvent) => {
    if (!e.currentTarget.hasPointerCapture(e.pointerId)) return
    dispatch({ type: 'move', x: e.clientX, y: e.clientY })
  }, [])

  const onPointerUp = useCallback((e: React.PointerEvent) => {
    if (e.currentTarget.hasPointerCapture(e.pointerId)) {
      e.currentTarget.releasePointerCapture(e.pointerId)
    }
    dispatch({ type: 'drop' })
  }, [])

  // pointercancel: 탭 전환, 앱 포커스 이탈, 요소 unmount 시 상태 리셋 필수
  const onPointerCancel = useCallback((e: React.PointerEvent) => {
    if (e.currentTarget.hasPointerCapture(e.pointerId)) {
      e.currentTarget.releasePointerCapture(e.pointerId)
    }
    dispatch({ type: 'cancel' })
  }, [])

  const resolve = useCallback(() => {
    dispatch({ type: 'resolved' })
  }, [])

  // ESC 키로 드래그 취소
  useEffect(() => {
    function handleEscape(e: KeyboardEvent) {
      if (e.key === 'Escape') dispatch({ type: 'cancel' })
    }
    window.addEventListener('keydown', handleEscape)
    return () => window.removeEventListener('keydown', handleEscape)
  }, [])

  return { state, handlers: { onPointerDown, onPointerMove, onPointerUp, onPointerCancel }, ref, resolve }
}

사용 예시:

export function DraggableCard() {
  const { state, handlers } = useDrag()
  const translate = state.kind === 'dragging'
    ? `translate(${state.offsetX}px, ${state.offsetY}px)`
    : 'none'

  return (
    <div
      {...handlers}
      // touch-none = touch-action: none — 브라우저 기본 터치 동작 차단
      className="touch-none rounded-lg border p-4 cursor-grab active:cursor-grabbing"
      style={{ transform: translate }}
    >
      드래그하세요
    </div>
  )
}

4.2 Zustand drag store 생성

여러 컴포넌트에 걸친 드래그 상태는 전역 단일 Zustand store로 관리한다:

// src/presentation/shared/stores/drag-store.ts
import { create } from 'zustand'

type DragStore = {
  active: { id: string; sourceColumnId: string } | null
  hoverColumnId: string | null
  start: (id: string, sourceColumnId: string) => void
  hover: (columnId: string | null) => void
  end: () => void
}

export const useDragStore = create<DragStore>()((set) => ({
  active: null,
  hoverColumnId: null,
  start: (id, sourceColumnId) => set({ active: { id, sourceColumnId }, hoverColumnId: null }),
  hover: (columnId) => set({ hoverColumnId: columnId }),
  end: () => set({ active: null, hoverColumnId: null }),
}))

4.3 useDrop 훅 생성

// src/presentation/shared/hooks/use-drop.ts
import { useCallback } from 'react'
import { useDragStore } from '@/presentation/shared/stores/drag-store'

type UseDropOptions<T> = {
  dropZoneId: string
  canAccept?: (active: { id: string; sourceColumnId: string }) => boolean
  onDrop: (active: { id: string; sourceColumnId: string }) => void
}

export function useDrop<T>(options: UseDropOptions<T>): {
  isOver: boolean
  canDrop: boolean
  handlers: {
    onPointerEnter: (e: React.PointerEvent) => void
    onPointerLeave: (e: React.PointerEvent) => void
    onPointerUp: (e: React.PointerEvent) => void
  }
} {
  const active = useDragStore((s) => s.active)
  const hoverColumnId = useDragStore((s) => s.hoverColumnId)
  const setHover = useDragStore((s) => s.hover)
  const endDrag = useDragStore((s) => s.end)

  const isOver = hoverColumnId === options.dropZoneId
  const canDrop = active !== null && (options.canAccept?.(active) ?? true)

  const onPointerEnter = useCallback(() => {
    if (active === null) return
    if (options.canAccept && !options.canAccept(active)) return
    setHover(options.dropZoneId)
  }, [active, options, setHover])

  const onPointerLeave = useCallback(() => {
    if (hoverColumnId === options.dropZoneId) setHover(null)
  }, [hoverColumnId, options.dropZoneId, setHover])

  const onPointerUp = useCallback(() => {
    if (active === null || hoverColumnId !== options.dropZoneId) return
    if (options.canAccept && !options.canAccept(active)) return
    options.onDrop(active)
    endDrag()
  }, [active, hoverColumnId, options, endDrag])

  return { isOver, canDrop, handlers: { onPointerEnter, onPointerLeave, onPointerUp } }
}

4.4 useSortable 훅 생성

// src/presentation/shared/hooks/use-sortable.ts
// 핵심 아이디어: drag 중 각 아이템의 bounding rect 측정 →
// 포인터 위치 비교 → 목표 index 계산 → 배열 재정렬 후 re-render
// 전체 구현은 요청 시 생성 (200~300줄)

export function useSortable<T>(
  items: T[],
  getId: (item: T) => string,
  onReorder: (next: T[]) => void,
): {
  orderedItems: T[]
  getDragHandlers: (id: string) => {
    onPointerDown: (e: React.PointerEvent) => void
    onPointerMove: (e: React.PointerEvent) => void
    onPointerUp: (e: React.PointerEvent) => void
    onPointerCancel: (e: React.PointerEvent) => void
  }
  draggingId: string | null
} {
  // Pointer Events + bounding rect 기반 정렬 로직
  // 구현체는 useDrag 패턴 확장 + itemsRef(배열 ref) 사용
  throw new Error('useSortable stub — /react-animation 스킬이 전체 구현을 생성합니다')
}

4.5 칸반 드래그앤드롭 통합 예시

// src/presentation/features/kanban/components/kanban-board.tsx
import { useDrag } from '@/presentation/shared/hooks/use-drag'
import { useDrop } from '@/presentation/shared/hooks/use-drop'
import { useDragStore } from '@/presentation/shared/stores/drag-store'

type Card = { id: string; title: string; columnId: string }
type Column = { id: string; title: string }

export function KanbanBoard({ columns, cards }: { columns: Column[]; cards: Card[] }) {
  return (
    <div className="flex gap-4 overflow-x-auto p-4">
      {columns.map((column) => (
        <KanbanColumn
          key={column.id}
          column={column}
          cards={cards.filter((c) => c.columnId === column.id)}
        />
      ))}
    </div>
  )
}

function KanbanColumn({ column, cards }: { column: Column; cards: Card[] }) {
  const { isOver, canDrop, handlers } = useDrop({
    dropZoneId: column.id,
    canAccept: (active) => active.sourceColumnId !== column.id,
    onDrop: (active) => {
      // G2 /react-query mutation 패턴 — 서버에 카드 이동 반영
      moveCard(active.id, column.id)
    },
  })

  return (
    <div
      {...handlers}
      className={`
        flex w-72 flex-col gap-2 rounded-lg border p-3 transition-colors
        ${isOver && canDrop ? 'border-primary bg-primary/10' : 'border-border bg-muted/30'}
      `}
    >
      <h3 className="font-semibold">{column.title}</h3>
      {cards.map((card) => <KanbanCard key={card.id} card={card} />)}
    </div>
  )
}

function KanbanCard({ card }: { card: Card }) {
  const startDrag = useDragStore((s) => s.start)
  const endDrag = useDragStore((s) => s.end)
  const { state, handlers: dragHandlers, resolve } = useDrag()

  const isDragging = state.kind === 'dragging'
  const isDropping = state.kind === 'dropping'
  const translate =
    state.kind === 'dragging' || state.kind === 'dropping'
      ? `translate(${state.offsetX}px, ${state.offsetY}px)`
      : 'none'

  return (
    <div
      onPointerDown={(e) => { dragHandlers.onPointerDown(e); startDrag(card.id, card.columnId) }}
      onPointerMove={dragHandlers.onPointerMove}
      onPointerUp={(e) => { dragHandlers.onPointerUp(e); endDrag(); resolve() }}
      onPointerCancel={(e) => { dragHandlers.onPointerCancel(e); endDrag() }}
      // tabIndex + role: 키보드 접근성 필수
      tabIndex={0}
      role="button"
      aria-describedby={`drag-instructions-${card.id}`}
      className={`
        touch-none rounded border bg-card p-3 cursor-grab
        ${isDragging ? 'opacity-50 shadow-lg cursor-grabbing z-10 relative' : ''}
        ${isDropping ? 'opacity-70 pointer-events-none' : ''}
        motion-reduce:transition-none
      `}
      style={{ transform: translate }}
    >
      <span id={`drag-instructions-${card.id}`} className="sr-only">
        스페이스 또는 엔터로 집기, 화살표 키로 이동, 다시 스페이스로 내려놓기
      </span>
      {card.title}
    </div>
  )
}

4.6 SVG 화살표 / 연결선 (Connector)

// src/presentation/shared/components/connector.tsx
import { useEffect, useState } from 'react'

type Point = { x: number; y: number }

export function Connector({
  fromRef,
  toRef,
}: {
  fromRef: React.RefObject<HTMLElement | null>
  toRef: React.RefObject<HTMLElement | null>
}) {
  const [from, setFrom] = useState<Point | null>(null)
  const [to, setTo] = useState<Point | null>(null)

  useEffect(() => {
    function update() {
      const a = fromRef.current?.getBoundingClientRect()
      const b = toRef.current?.getBoundingClientRect()
      if (!a || !b) return
      setFrom({ x: a.right, y: a.top + a.height / 2 })
      setTo({ x: b.left, y: b.top + b.height / 2 })
    }
    update()
    const observer = new ResizeObserver(update)
    if (fromRef.current) observer.observe(fromRef.current)
    if (toRef.current) observer.observe(toRef.current)
    window.addEventListener('scroll', update, { capture: true })
    window.addEventListener('resize', update)
    return () => {
      observer.disconnect()
      window.removeEventListener('scroll', update, { capture: true })
      window.removeEventListener('resize', update)
    }
  }, [fromRef, toRef])

  if (!from || !to) return null

  const midX = (from.x + to.x) / 2
  const d = `M ${from.x} ${from.y} C ${midX} ${from.y}, ${midX} ${to.y}, ${to.x} ${to.y}`

  return (
    // pointer-events-none: SVG가 마우스 이벤트를 차단하지 않게
    <svg className="pointer-events-none fixed inset-0 h-full w-full" aria-hidden="true">
      <path d={d} stroke="currentColor" strokeWidth="2" fill="none" />
      <circle cx={to.x} cy={to.y} r="4" fill="currentColor" />
    </svg>
  )
}

5. 접근성 (a11y)

라이브러리 0개 접근에서 접근성은 사용자 책임이다. /react-animation 스킬은 최소 체크리스트를 스캐폴딩한다.

5.1 prefers-reduced-motion 처리

  • Tier 1: Tailwind motion-reduce:animate-none, motion-reduce:transition-none variant 적용
  • Tier 2: withViewTransition 래퍼에서 window.matchMedia('(prefers-reduced-motion: reduce)') 가드 적용
    • <ViewTransition> 경로(Gotcha 15)는 이 래퍼를 거치지 않아 위 가드가 걸리지 않는다. §3.4 의 prefers-reduced-motion CSS 규칙을 함께 두고, 움직임 줄이기 설정을 켠 브라우저에서 전환이 멈추는지 전 · 후 두 시점 캡처로 확인한다(Gotcha 13). 그 CSS 규칙이 <ViewTransition> 전환에도 먹는다는 바깥 근거는 아직 없다 — 확인하지 못하면 [미검증] 으로 보고한다
  • Tier 3: CSS transform 애니메이션 대신 즉시 이동

5.2 드래그앤드롭 키보드 대안

// 키보드 드래그 대안: Space 집기 → 화살표 이동 → Space 내려놓기
function KanbanCard({ card }: { card: Card }) {
  const [isKeyboardDragging, setIsKeyboardDragging] = useState(false)

  function handleKeyDown(e: React.KeyboardEvent) {
    if (e.key === ' ' || e.key === 'Enter') {
      setIsKeyboardDragging((prev) => !prev)
    }
    if (isKeyboardDragging && e.key === 'ArrowRight') {
      // 오른쪽 컬럼으로 이동 로직
    }
    if (e.key === 'Escape') {
      setIsKeyboardDragging(false)
    }
  }

  return (
    <div
      tabIndex={0}
      role="button"
      onKeyDown={handleKeyDown}
      aria-pressed={isKeyboardDragging}
      // ...
    />
  )
}

5.3 ARIA live region (드래그 상태 알림)

// 드래그 상태를 스크린리더에 알리는 live region
export function DragAnnouncer({ message }: { message: string }) {
  return (
    <div
      aria-live="polite"
      aria-atomic="true"
      className="sr-only"
    >
      {message}
    </div>
  )
}

// 사용: 드래그 시작/이동/완료/취소 시 message 업데이트

5.4 접근성 트레이드오프 고지

라이브러리 0개 접근은 번들 크기·의존성·감사 표면을 줄이는 대신, 드래그앤드롭의 완전한 스크린리더 접근성이 우리 코드와 테스트의 책임이 된다. 완전한 a11y 가 필요하면 W3C APG 드래그앤드롭 패턴(https://www.w3.org/WAI/ARIA/apg/)을 직접 구현한다. 금지 라이브러리를 대안으로 제시하지 않는다 — 이 트레이드오프의 처리 경로는 §6 표준 커버리지 공백에 정리돼 있다.

6. 표준 커버리지 공백 — 원칙은 유지하고 직접 처리한다

라이브러리 0개 원칙은 유지된다 (Gotcha #1 · references/common-gotchas.md G2 · G10). 2026-08-13 현행성 점검에서 View Transitions 와 scroll-driven 의 브라우저 지원이 이 원칙을 뒷받침한다는 것을 확인했다.

다만 표준만으로 자동 커버되지 않는 영역이 있다. 아래 8 종은 "라이브러리를 쓰면 공짜로 얻는" 것이 우리 코드와 테스트의 책임으로 넘어온다는 뜻이다. 이 표는 금지 목록을 완화하는 근거가 아니다 — 여기에 해당하는 요청이 와도 금지 라이브러리를 도입하지 않고 아래 처리 경로로 간다.

#공백처리 경로
1복잡한 physics / spring 감쇠직접 구현 — requestAnimationFrame 루프에서 stiffness/damping 적분을 직접 계산한다. 물리 정확도가 요구사항이 아니면 CSS cubic-bezier 근사로 낮춘다
2inertia (관성 · 플링 감속)직접 구현 — pointerup 시점 속도(px/ms)를 샘플링해 감쇠 적분한다. Tier 3 useDrag FSM 에 릴리스 상태를 추가한다
3collision (충돌 · 겹침 판정)직접 구현 — getBoundingClientRect() AABB 교차 판정. drop 후보 계산은 useDrop 안에 둔다
4accessible sortable DnD직접 구현 — 포인터 경로만 만들고 끝내지 않는다. §5.2 · §5.3 을 필수 산출물로 포함한다
5keyboard reorder (키보드 재정렬)직접 구현 — §5.2 의 Space 집기 → 화살표 이동 → Space 놓기 대안 경로
6live-region announcement직접 구현 — §5.3 DragAnnouncer 를 드래그 시작/이동/드롭/취소 4 시점에 연결한다
7Lottie JSON 직접 재생사전 렌더 자산 — lottie-react 는 금지다. 디자이너에게 WebM/APNG 또는 SVG + CSS @keyframes 로 사전 렌더한 자산을 요청한다
8cross-document (MPA) shared transitionfallback — @view-transition 은 아직 limited availability 다. SPA 라우팅 안의 same-document 전환으로 설계하고, 문서 간 이동은 전환 없이 즉시 이동하는 fallback 을 기본으로 둔다

처리 경로는 3 종뿐이다 — 직접 구현 · fallback · 사전 렌더 자산. 네 번째 선택지("이 경우엔 라이브러리를 쓴다")는 없다. 공수가 과하다고 판단되면 라이브러리를 도입하는 것이 아니라 요구 자체를 낮추는 안을 사용자에게 제시한다 (예: spring → CSS easing 근사, MPA shared transition → 전환 없음).

7. 아키텍처 배치 규칙

산출물위치
CSS @keyframessrc/presentation/styles/globals.css
View Transition 래퍼src/presentation/shared/lib/view-transition.ts
useDrag, useDrop, useSortablesrc/presentation/shared/hooks/
drag-store (Zustand)src/presentation/shared/stores/drag-store.ts
Connector, DraggableItemsrc/presentation/shared/components/
feature 전용 drag 컴포넌트src/presentation/features/<name>/components/
금지domain/, data/ 에 pointer/transition/drag-store import

8. 완료 후 안내

구현 완료 후 사용자에게 다음을 안내한다:

  • Tier 1: 확인된 Tailwind 클래스 목록 + globals.css 변경 사항
  • Tier 2: 생성된 파일 목록 (view-transition.ts 등) + 브라우저 지원 현황
  • Tier 3: 생성된 훅/store 목록 + 접근성 체크리스트

다음 단계 제안:

  • 접근성 검증: /react-audit (reduced-motion, ARIA 가드 검사)
  • 테스트 생성: /react-test (pointer event 테스트는 @testing-library/user-event user.pointer() API)
  • 컴포넌트 감지: widget-inspector-react 에이전트

References

  • references/clean-arch-layout.md — 레이어 배치 규칙 (presentation 전용)
  • references/project-detection.md — 프로젝트 감지 (Tailwind 버전, tailwindcss-animate 설치 여부)
  • docs/react/kit-design/g5b-animation.md — 이 스킬의 전체 설계 문서

Individual skills in this repo

This repo contains 4 individual skills — each has its own dedicated page.

Related Skills