useSpring 같은 훅으로 값을 선언하면, animated.div 같은 특수 컴포넌트가 그 값을 매 프레임 DOM에 직접 써 넣는다.
animated()가 감싼 컴포넌트는 매 프레임 값을 구독해 DOM 노드의 속성만 직접 갱신한다. 그리고 목표 지점(to)이 애니메이션 도중 바뀌어도 처음부터 다시 재생하지 않고, 현재 속도를 유지한 채 새 목표로 자연스럽게 방향을 튼다(리타겟).
pmndrs/react-spring · 제작 Paul Henschel, 현재 유지보수 Josh Ellis 등 pmndrs 팀 · 라이선스 MIT · 레이어드 모노레포 — 코어 패키지 7개(rafz·types·shared·animated·core·mock-raf·parallax) + 렌더 타깃 2개(web·three) · npm 안정판 10.1.2 / 메인 브랜치는 11.0.0-beta.0(ESM 전용 리라이트 진행 중) · 최신 커밋 d0a6847(2026-06-23) · Node ≥24.16.0 · pnpm 11.9.0 워크스페이스 + Turborepo + tsdown)
react-spring은 스프링 물리 시뮬레이터를 애니메이션 엔진으로 쓰는 React 라이브러리다. 보통의 CSS 애니메이션은 "0.3초 동안 ease-out으로"처럼 시간을 기준으로 움직임을 정의한다. react-spring은 대신 "장력 170, 마찰 26짜리 스프링에 매달려 있다"처럼 힘을 기준으로 정의한다. 매 프레임 물리 방정식을 계산해 위치·속도를 갱신하기 때문에, 목표가 중간에 바뀌어도 관성이 자연스럽게 이어진다.
API의 핵심은 두 가지다. useSpring·useSprings·useTrail·useTransition·useChain 같은 훅이 애니메이션 값을 만들어내고, animated.div·animated.svg 같은 특수 컴포넌트가 그 값을 실제 DOM(또는 3D 오브젝트)에 적용한다. 렌더 타깃은 현재 웹(DOM)과 react-three-fiber(3D) 둘로, 같은 스프링 엔진을 다른 "출력 대상"에 연결하는 구조다.
CSS transition은 초시계로 움직임을 재는 것과 같다 — "0초에서 시작해 0.3초에 도착"이라고 미리 정해두면, 도중에 목적지가 바뀌어도 초시계는 그 사실을 모른 채 이미 정해둔 시간표대로 도착 지점만 바꿔 다시 처음부터 잰다.
react-spring은 그네를 미는 손에 가깝다. 그네가 지금 얼마나 빠르게, 어느 방향으로 움직이고 있는지 계속 느끼면서 힘을 준다. 목표 지점이 갑자기 바뀌어도(사용자가 손을 놓거나 방향을 바꿔도) 그네는 멈췄다 다시 출발하지 않고, 지금의 속도를 그대로 살려 새 방향으로 흐른다.
react-spring 자체는 2018년부터 있던 오래된 라이브러리지만, 최근 다시 주목받는 맥락은 크게 세 갈래다.
최근 몇 년 사이 Motion(옛 Framer Motion)이 React 애니메이션의 사실상 표준처럼 자리 잡으면서, "스프링처럼 자연스러운 움직임"이라는 개념 자체가 일반 프론트엔드 개발자에게도 익숙해졌다. react-spring은 이 개념을 React 생태계에 가장 먼저, 가장 순수하게(물리 계산 자체가 목적) 들여온 라이브러리 중 하나였고, "원조 스프링 엔진"이라는 위치에서 다시 조명받고 있다.
react-spring은 pmndrs(Poimandres) 팀이 만드는 react-three-fiber(r3f) 생태계의 표준 애니메이션 도구이기도 하다. r3f로 만드는 3D 웹사이트·인터랙티브 쇼케이스가 늘면서, @react-spring/three로 3D 오브젝트의 위치·회전·색을 스프링으로 움직이는 수요도 함께 늘었다.
이 레포를 직접 클론해 보면, 메인 브랜치가 이미 "v11" 대규모 리팩터링을 진행 중이라는 게 바로 드러난다. 빌드 도구를 tsdown(esbuild/rolldown 기반)으로, 린트·포맷을 러스트 기반 초고속 도구 oxlint·oxfmt로, 테스트를 Playwright 브라우저 모드의 Vitest로 갈아엎었다. 오래된 라이브러리가 최신 프론트엔드 툴체인으로 스스로를 갈아입고 있는 실물 사례라 저장소 구조 자체가 학습 가치가 있다.
Motion(Framer Motion)이 레이아웃 애니메이션·제스처·문서/생태계에서 앞서가고, GSAP은 타임라인 시퀀싱의 최강자로 남아 있다. react-spring은 그 사이에서 "진짜 물리 계산 + 훅 기반 선언형 API + 크로스 타깃(웹·3D)"이라는 원래 강점에 집중하며, ESM 전용·의존성 최소화(외부 애니메이션 라이브러리 의존 없음)로 몸집을 가볍게 다듬는 중이다.
react-spring이 정면으로 노리는 문제는 두 가지다.
CSS transition이나 setTimeout으로 짠 애니메이션은 시작점과 끝점, 걸리는 시간을 미리 고정한다. 그런데 사용자는 얌전히 기다려주지 않는다 — 카드를 드래그하다 도중에 손을 떼거나, 호버가 끝나기 전에 다시 마우스를 올리는 식으로 목표를 실시간으로 바꾼다. duration 기반 방식은 이럴 때 애니메이션이 뚝 끊기고 처음부터 재생되거나, 순간이동하듯 튀는 경우가 많다.
react-spring의 SpringValue는 목표(to)가 바뀌면 현재 위치와 현재 속도를 그대로 이어받아 새 목표를 향해 물리 계산을 계속한다. "재생 중인 애니메이션을 다시 시작하지 않고 방향만 튼다"는 이 성질을 리타겟(retarget)이라 부른다.
움직이는 값을 useState에 담고 매 프레임 setState를 부르면, 컴포넌트(와 그 자식들)가 초당 수십 번씩 리렌더된다. react-spring은 애니메이션 값을 React state 바깥의 별도 객체(SpringValue)로 관리하고, animated.div 같은 컴포넌트가 그 값을 구독해 DOM 노드의 스타일·속성만 직접 고쳐 쓴다. React의 리렌더 사이클은 아예 건너뛴다.
손잡이를 놓으면 관성을 그대로 이어받는 짐수레를 생각하면 쉽다. duration 기반 애니메이션은 레일 위를 달리는 열차다 — 정해진 시간표대로만 움직이고, 중간에 방향을 틀려면 일단 멈춰야 한다. react-spring은 손으로 미는 짐수레다 — 밀던 중에 방향을 바꿔도 수레는 지금 속도를 유지한 채 부드럽게 방향을 튼다.
저장소의 CLAUDE.md(레포 내부 개발 가이드)는 이 아키텍처를 "레이어드 모노레포"라 부른다. 핵심 문장은 "타깃(targets/*)에 도달하기 전까지 모든 계층은 렌더 대상에 무관하다"는 것이다. 즉 물리 계산을 담당하는 아래 4개 패키지는 그 값이 결국 DOM 스타일이 될지, 3D 오브젝트의 좌표가 될지 전혀 모른다.
각 타깃(targets/web/src/index.ts, targets/three/src/index.ts)은 같은 뼈대를 따른다. ① Globals.assign(...)으로 배치 업데이트 함수·색상 보간기 같은 플랫폼별 동작을 꽂아 넣고, ② primitives(만들 수 있는 엘리먼트 목록)를 정의하고, ③ createHost(primitives, hostConfig)로 animated 객체를 만들어 내보낸다. hostConfig가 요구하는 것은 딱 세 가지 — applyAnimatedValues(값을 실제 노드에 쓰기), createAnimatedStyle(style prop 감싸기), getComponentProps(전달 전 prop 거르기) — 뿐이다.
packages/*(엔진 본체)와 targets/*(타깃 어댑터)를 한 저장소에서 관리하며, 서로를 workspace:~ 버전으로 참조한다.이 섹션이 이 문서에서 가장 중요하다. 아래 6가지가 서로 맞물려 "물리 기반이면서도 빠른" 애니메이션 엔진을 이룬다.
@react-spring/core의 SpringValue(packages/core/src/SpringValue.ts, 1,166줄)가 엔진의 심장이다. 애니메이션이 도는 동안 매 프레임 advance(dt) 메서드가 호출되는데, 이 메서드는 세 가지 모드 중 하나로 다음 위치를 계산한다.
config.duration도 config.decay도 없으면, tension·friction·mass로 정의된 운동 방정식을 dt만큼 적분해 새 위치·속도를 구한다.config.duration이 있으면 물리 계산을 접고, 경과 비율 p = elapsed / duration을 easing 함수에 넣어 위치를 구한다. "물리 스프링"이 아니라 "기존 CSS 방식"으로 되돌아가는 모드다.config.decay가 있으면 to 값 자체를 무시하고, 주어진 초기 속도(velocity)에서 지수적으로 감속하며 흘러간다. 던지기(flick) 제스처 뒤에 남는 관성 스크롤 같은 데 쓴다.
애니메이션이 "멈췄다"고 판단하는 기준(precision·restVelocity)도 흥미롭다. 목표까지 남은 거리와 현재 속도가 둘 다 아주 작아지면 멈춘 것으로 본다. 실제로 부동소수점 오차(예: Math.cos(Math.PI/2)가 정확히 0이 아니라 6e-17이 되는 경우) 때문에 스프링이 영원히 안 멈추는 버그(이슈 #2208)가 있었고, 최신 코드는 이를 감안해 값 크기에 비례하는 적응형 precision을 계산하도록 고쳐져 있다.
to)이 바뀌는 것. react-spring은 이때 처음부터 다시 시작하지 않고, 현재 위치·속도를 그대로 물려받아 새 목표를 향해 계속 움직인다. 자동차 크루즈 컨트롤의 목표 속도를 주행 중에 바꾸는 것과 비슷하다 — 차가 멈췄다 다시 가속하지 않고 지금 속도에서 이어서 조절한다.
packages/animated/src/withAnimated.tsx의 withAnimated가 animated.div 같은 컴포넌트를 만드는 핵심 함수다. 동작 방식은 이렇다.
const observer = new PropsObserver(callback, deps)
useIsomorphicLayoutEffect(() => {
each(deps, dep => addFluidObserver(dep, observer)) // 값 변화를 구독
return () => { /* 언마운트 시 구독 해제 */ }
})
class PropsObserver {
eventObserved(event) {
if (event.type == 'change') raf.write(this.update) // rafz의 write 큐에 예약
}
}
즉 animated.div style={styles}는 렌더링될 때 styles 안의 스프링 값들을 구독 대상(FluidValue)으로 등록해두고, 값이 바뀔 때마다 setState가 아니라 raf.write(callback)으로 "다음 프레임에 DOM을 직접 고쳐라"라고 예약한다. 콜백은 host.applyAnimatedValues(instance, props)를 호출해 ref로 잡아둔 실제 DOM 노드의 style·속성을 자바스크립트로 바로 써 넣는다. React의 커밋 단계를 아예 거치지 않는다 — 이게 초당 60번씩 값이 바뀌어도 리렌더가 튀지 않는 이유다. 단, 대상이 함수형 컴포넌트라 ref를 못 받는 등 직접 갱신에 실패하면 마지막 안전장치로 forceUpdate()가 호출돼 평범한 리렌더로 대체된다.
우편함에 매번 새 편지를 넣는 대신, 벽에 붙은 게시판 글자를 직접 지우고 다시 쓰는 것과 비슷하다. React state로 값을 흘려보내면 컴포넌트라는 "우편함"에 매번 새 편지(리렌더)가 도착해 열어봐야 한다. animated()는 그 대신 실제 DOM(게시판)에 손을 뻗어 글자만 지우고 다시 쓴다 — 우편함(컴포넌트 트리)은 아무 일도 없었던 것처럼 조용하다.
@react-spring/rafz(packages/rafz/src/index.ts, 255줄)는 브라우저의 requestAnimationFrame을 단 한 번만 등록하고, 그 안에서 다섯 개의 명명된 큐(onStart·update·onFrame·write·onFinish)를 순서대로 비운다. 수십 개의 스프링이 동시에 움직여도 브라우저에는 rAF 콜백이 하나만 등록된다. 여기에 rAF 프레임을 이용해 흉내 낸 setTimeout까지 얹혀 있어, 지연(delay)이 걸린 애니메이션도 같은 시계에 맞춰 돈다.
그 위의 @react-spring/shared는 frameLoop를 관리한다. 실행 중인 애니메이션들을 우선순위 순으로 정렬된 배열에 담아두고(뒤에 등록된 스프링이 앞선 스프링의 최신 값을 참조할 수 있도록), 매 프레임 animation.advance(dt)를 호출한다. 기본은 'always' 모드(항상 rAF가 돈다)지만, targets/three는 이를 'demand' 모드로 바꿔 react-three-fiber의 invalidate()를 통해 필요할 때만 프레임을 요청하도록 최적화한다 — 3D 씬에서 아무것도 안 움직일 때 GPU를 놀리지 않기 위해서다.
packages/core/src/hooks/ 아래 훅들은 서로를 쌓아 올린 관계다.
useSpring은 사실 useSprings(여러 개의 SpringValue를 Controller로 묶어 관리)를 항목 1개짜리로 얇게 감싼 것이다.useTrail은 여러 항목을 나란히 만들되, 뒤 항목이 앞 항목의 현재 값을 목표로 뒤쫓게 만들어 "줄지어 따라오는" 효과를 낸다.useTransition은 데이터 배열의 각 항목에 키(key)를 부여해 마운트(enter)·갱신(update)·언마운트(leave) 단계별 스타일을 관리한다. 리스트에서 항목이 추가/삭제될 때 사라지는 애니메이션까지 자동으로 챙겨준다.useChain은 여러 개의 SpringRef(각 훅의 명령형 핸들)를 순서대로 실행한다. packages/core/src/hooks/useChain.ts를 보면, 각 컨트롤러의 대기열(queue)에 있는 props의 delay 값을 이전 애니메이션이 끝나는 시점 이후로 밀어 넣는 방식으로 구현돼 있다 — 별도의 타이머 관리 없이 "지연 시간 조작"만으로 순서를 만든다.useTrail·useChain은 기차 놀이(계주)와 비슷하다. useTrail은 앞사람 뒤통수만 보고 따라 뛰는 술래잡기 기차라면, useChain은 "1번 주자가 결승선을 통과해야 2번 주자가 출발하는" 이어달리기다.
packages/core/src/constants.ts에는 자주 쓰는 프리셋이 정의돼 있다.
| 프리셋 | tension / friction | 느낌 |
|---|---|---|
| default | 170 / 26 | 기본값 — 무난하게 빠르고 안정적 |
| gentle | 120 / 14 | 부드럽고 느긋하게 |
| wobbly | 180 / 12 | 탱글탱글 — 도착 후에도 살짝 흔들림 |
| stiff | 210 / 20 | 빠르고 단단하게 |
| slow / molasses | 280 / 60, 280 / 120 | 천천히, 아주 천천히 |
mass(질량, 기본 1)는 별도로 있는데, 값이 클수록 "무거운 물체처럼" 멈추는 데 더 큰 마찰이 필요해진다. 반대로 config.duration을 지정하면 위 물리 파라미터는 전부 무시되고, "몇 밀리초 동안 어떤 easing으로"라는 전통적인 CSS 방식으로 되돌아간다 — 즉 react-spring은 물리 엔진이면서도, 필요하면 평범한 duration 애니메이션도 같은 API로 지원한다.
Interpolation(packages/core/src/Interpolation.ts)은 하나 이상의 SpringValue를 입력받아 다른 값으로 변환하는 파생 값이다. 예를 들어 스크롤 진행도 스프링값 하나를 받아 "0~1 진행도"를 "0~360도 회전"으로 매핑할 수 있다. 원본이 바뀔 때만 다시 계산되는 메모이즈드 구조라, 관찰자(observer)가 없으면 아예 구독을 걸지 않아 불필요한 계산을 피한다.
react-spring 코어는 런타임 의존성이 거의 없다. 물리 계산·프레임 스케줄링·보간을 전부 자체 구현했기 때문에, 애니메이션 로직 자체를 감싼 서드파티 라이브러리가 없다. 대신 아래처럼 계층별 패키지로 나뉜다.
| 패키지 | 역할 | 주요 의존/peer |
|---|---|---|
| @react-spring/rafz | 전역 rAF 스케줄러(5개 큐 + setTimeout) | 의존성 없음 |
| @react-spring/types | 런타임 없는 순수 TypeScript 타입 | 없음 |
| @react-spring/shared | Globals·FrameLoop·색상/문자열 보간·내부 훅 | rafz 참조 |
| @react-spring/animated | Animated 클래스 계층, createHost, withAnimated | shared 참조 |
| @react-spring/core | SpringValue·Controller·SpringRef·훅·컴포넌트 | peer: react ^16.8~19 |
| @react-spring/mock-raf | 테스트 전용 — rAF를 수동으로 한 프레임씩 흉내 | 테스트 전용 |
| @react-spring/parallax | 스크롤 패럴랙스 컴포넌트 | web 위에 얹힘 |
| @react-spring/web | DOM 타깃 — animated.div 등 | peer: react-dom, dep: csstype |
| @react-spring/three | react-three-fiber 3D 타깃 | peer: @react-three/fiber ≥6, three ≥0.126 |
| 영역 | 도구 |
|---|---|
| 패키지 매니저 | pnpm 11.9.0(packageManager 필드로 고정, Corepack 경유) |
| 모노레포 태스크 러너 | Turborepo(turbo.json) — 빌드 캐시·의존 순서 관리 |
| 번들러 | tsdown(rolldown 기반) — 예전에 쓰이던 preconstruct는 현재 코드베이스에 존재하지 않는다 |
| 린트 / 포맷 | oxlint / oxfmt — 러스트로 작성된 oxc 프로젝트의 초고속 도구 |
| 테스트 | Vitest — 브라우저 모드(Playwright, Chromium)로 unit·e2e·타입 테스트 3종 실행 |
| 릴리스 | Changesets — 패키지별 semver 변경 로그를 PR 단위로 누적, "beta" 프리릴리스 모드 사용 중 |
require()) 출력 없이 ES 모듈(import) 번들만 배포하는 방식. v11부터 각 패키지는 dist/이름.modern.mjs 하나만 내보내고, 예전의 CJS·Webpack4 대응 번들은 모두 제거됐다.
react-spring을 쓰는 앱 입장에서 설치는 간단하다. 웹이면 @react-spring/web, react-three-fiber 3D면 @react-spring/three를 설치하면 된다.
# DOM(웹) 프로젝트
npm install @react-spring/web
# react-three-fiber 3D 프로젝트
npm install @react-spring/three
복사만 하면 끝이다 — 별도 설정 파일이나 CSS 임포트가 필요 없다. 필요한 peer dependency는 아래와 같다.
| 패키지 | 필요한 peer dependency |
|---|---|
| @react-spring/web | react ^16.8 | ^17 | ^18 | ^19, react-dom(동일 범위) |
| @react-spring/three | react(동일 범위), @react-three/fiber ≥6, three ≥0.126 |
레포의 package.json은 "engines": {"node": ">=24.16.0"}, pnpm@11.9.0을 못박아 두고 있다. 이건 react-spring 저장소 자체를 클론해 빌드·기여할 때 필요한 버전이다.
내 앱에 @react-spring/web을 설치해 쓰는 것과는 별개다 — 다만 v11부터 ESM 전용이라, CommonJS로 require()해야 하는 구식 환경이면 Node ≥22.12가 필요하고(그래야 require()가 ESM 패키지를 읽을 수 있다), Webpack 4처럼 아주 오래된 번들러는 더 이상 지원되지 않는다.
가장 간단한 예시부터 보자. 요소가 나타날 때 투명도와 위치가 함께 움직이는 "페이드인" 애니메이션이다.
import { useSpring, animated } from '@react-spring/web'
function FadeIn({ isVisible, children }) {
const styles = useSpring({
opacity: isVisible ? 1 : 0,
y: isVisible ? 0 : 24,
})
return (
<animated.div style={styles}>
{children}
</animated.div>
)
}
isVisible이 바뀔 때마다 useSpring은 새 목표값을 SpringValue에 넘긴다. 컴포넌트 자체는 리렌더되지 않고, animated.div가 값이 바뀔 때마다 알아서 style을 갱신한다.
여러 항목을 순서대로 밀어 넣는 useTrail, 리스트 항목의 등장/퇴장을 관리하는 useTransition도 같은 원리다.
import { useTransition, animated } from '@react-spring/web'
function List({ items }) {
const transitions = useTransition(items, {
from: { opacity: 0, height: 0 },
enter: { opacity: 1, height: 40 },
leave: { opacity: 0, height: 0 },
keys: item => item.id,
})
return transitions((style, item) => (
<animated.div style={style}>{item.label}</animated.div>
))
}
항목이 배열에서 사라져도 leave 스타일이 재생될 때까지 DOM에서 바로 지워지지 않는다 — useTransition이 내부적으로 키 기준으로 각 항목의 생애주기(enter → update → leave)를 추적하기 때문이다.
useSpring 같은 훅이 @react-spring/core 안에서 Controller와 그 안의 SpringValue들을 만든다.
SpringValue는 shared가 관리하는 FluidValue(관찰 가능한 값)로 등록된다.
shared의 FrameLoop가 rafz의 update 큐에 "매 프레임 이 값을 계산해줘"라고 예약한다.
매 프레임 advance(dt)가 새 값을 계산하고 change 이벤트를 쏘면, withAnimated가 만든 관찰자가 이를 받는다.
관찰자가 타깃의 applyAnimatedValues(웹이면 DOM 스타일 갱신, three면 applyProps로 3D 오브젝트 갱신)를 호출해 화면에 값이 반영된다.
마지막으로, 여러 훅을 순서대로 잇는 useChain은 이렇게 쓴다.
const [springStyles, springApi] = useSpring(() => ({ opacity: 0 }))
const [transStyles, transApi] = useTransition(items, { ... })
useChain([springApi, transApi], [0, 0.4]) // springApi 먼저, 0.4 지점에 transApi
이 레포는 지금 "보이는 코드"와 "실제로 npm에 설치되는 것"이 다른 과도기다. 아래 함정들은 실제로 저장소 소스와 npm 레지스트리를 직접 대조해 확인한 내용이다.
이 레포를 지금 클론하면 모든 패키지의 package.json 버전이 11.0.0-beta.0이다. 하지만 npm 레지스트리를 직접 조회해 보면 @react-spring/web의 배포 태그는 {"latest": "10.1.2", "beta": "11.0.0-beta.0"}다.
즉 지금 여러분이 npm install @react-spring/web을 하면 10.1.2(구 버전)가 설치되고, 이 문서와 레포 소스가 설명하는 것은 아직 베타 태그로만 받을 수 있는 v11이다. 튜토리얼이나 블로그 글이 어느 버전을 기준으로 쓰였는지 항상 확인해야 한다.
v11의 changeset(remove-deprecated-targets.md)에는 "react-spring 우산(umbrella) 패키지를 제거한다"고 명시돼 있다. 예전 자료에서 npm install react-spring이라고 나와도, 지금은 @react-spring/web이나 @react-spring/three처럼 타깃을 직접 설치해야 한다.
같은 changeset에서 @react-spring/native, @react-spring/konva, @react-spring/zdog가 제거됐다고 밝힌다. 실제로 이 레포의 targets/ 폴더에는 web과 three 딱 둘만 있다. React Native 프로젝트라면 공식 안내대로 Reanimated(UI 스레드에서 도는 스프링 엔진)로 넘어가는 것이 권장된다 — react-spring의 스프링은 JS 스레드에서 계산되기 때문이다.
과거 react-spring 관련 글·이슈에는 빌드 도구로 preconstruct가 자주 언급됐다. 하지만 현재 저장소 어디에도 preconstruct는 없다 — 지금은 tsdown(rolldown 기반)과 Turborepo로 전면 교체됐다. 예전 자료를 보고 preconstruct.config.js 같은 걸 찾으면 헛수고다.
onRest가 더 이상 안 불린다changeset(clever-islands-dig.md 계열)에 따르면, v11부터 진행 중인 애니메이션이 새 목표로 리타겟될 때 이전 애니메이션의 onRest가 호출되지 않는다. "멈춘 것"이 아니라 "계속 움직이는 중"이라는 게 이유다. 대신 이전 start()의 Promise는 finished: false로 여전히 resolve된다. 예전 코드에서 onRest로 "목표가 취소됐다"를 감지하던 로직이 있다면 v11에서 더는 동작하지 않는다.
번들 크기 회귀를 잡는 CI(bundle-size.yml), 매일 도는 nightly.yml, 커버리지 임계값(문장 80% / 분기 74% / 함수 71% / 라인 82%)을 강제하는 설정이 실제로 존재한다. 레포 내부의 CLAUDE.md는 아키텍처·테스트 규칙을 매우 구체적으로 문서화해뒀다 — "장난감 프로젝트"가 아니라 실제로 관리되는 라이브러리라는 근거다.
| 도구 | 애니메이션 모델 | 강점 / 약점 |
|---|---|---|
| react-spring | 물리 스프링(tension·friction·mass), JS 스레드 계산, DOM/3D 직접 갱신 | 목표 변경(리타겟)에 가장 자연스럽게 대응. 대신 레이아웃 애니메이션·제스처 편의 기능은 상대적으로 얇음 |
| Motion(구 Framer Motion) | 스프링 + CSS/WAAPI 가속을 섞은 하이브리드 | layout prop 등 레이아웃·제스처 애니메이션이 매우 편함, 생태계·문서가 큼. 순수 물리 계산의 세밀함은 react-spring보다 덜 파고듦 |
| GSAP | 프레임워크 무관 타임라인 엔진 | 복잡한 시퀀싱·타임라인 제어에 최강. React 전용 최적화가 약해 직접 ref를 다뤄야 하고, 일부 고급 플러그인은 상용 라이선스 |
| 순수 CSS transition/animation | 브라우저 네이티브 duration 기반 | 가장 가볍고 GPU 가속이 쉬움. 애니메이션 도중 목표 변경(인터럽트)과 JS 상태 동기화에는 근본적으로 약함 |
드래그하다 손을 떼는 카드, 스와이프 제스처, 실시간으로 방향이 바뀌는 UI처럼 애니메이션 도중 목표가 계속 바뀌는 상호작용에서 가장 빛난다. react-three-fiber로 만든 3D 오브젝트를 물리적으로 움직이고 싶을 때도 마찬가지다.
요소 위치가 바뀔 때 자동으로 부드럽게 이어주는 "레이아웃 애니메이션"이 핵심이라면 Motion이 더 적은 코드로 해결한다. 여러 애니메이션을 정밀한 타임라인으로 지휘해야 한다면 GSAP의 타임라인 API가 더 편하다. 단순한 hover·클릭 색 전환 정도라면 CSS만으로 충분하다.
react-spring은 "움직임을 시간표가 아니라 힘으로 정의한다"는 한 가지 아이디어를 매우 깊게 판 라이브러리다. SpringValue의 물리 적분, animated()의 리렌더 우회, rafz의 단일 프레임 루프가 서로 맞물려 "물리적으로 자연스러우면서도 React 리렌더 비용이 없는" 애니메이션을 만든다. 지금은 그 위에 ESM 전용·최신 툴체인(tsdown·oxlint·Turborepo)으로 몸집을 다듬는 v11 리라이트가 진행 중이라, 소스를 열어보면 "오래된 라이브러리가 스스로 현대화하는 과정"까지 함께 볼 수 있다.
· 드래그·스와이프처럼 사용자가 애니메이션 도중에 개입하는 인터랙션을 자주 만드는 팀
· react-three-fiber로 3D 웹을 만들며 오브젝트를 물리적으로 움직이고 싶은 팀
· 훅 기반의 선언형 API로 애니메이션 상태를 관리하고 싶은 React 팀
· 카드·리스트의 레이아웃 변경 애니메이션이 핵심이라면 Motion
· 여러 애니메이션을 정밀한 타임라인으로 지휘해야 한다면 GSAP
· hover 색상 전환 정도의 단순한 효과만 필요하다면 순수 CSS로 충분