@react-spring/web은 물리 기반 애니메이션을 선언적으로 구현할 수 있게 해주는 라이브러리이다. CSS 키프레임이나 duration 기반이 아닌, 스프링 물리 모델을 사용하여 자연스러운 애니메이션을 구현할 수 있다. @use-gesture/react와 함께 사용하면 네이티브 앱과 같은 인터랙션을 구현할 수 있다.
누가 왜 만들었나?
@react-spring은 poimandres 커뮤니티에서 만들었다. 이 커뮤니티는 @use-gesture, react-three-fiber, zustand 등 유명한 React 생태계 라이브러리들을 만든 곳이다.
웹 애니메이션은 보통 CSS transition, CSS animation, 또는 JavaScript 기반의 duration/easing 모델로 구현된다. 하지만 이런 방식은 사용자의 인터랙션에 반응하는 애니메이션을 구현할 때 부자연스러운 결과를 만들기 쉽다. 예를 들어 드래그 중에 손을 떼면 현재 속도를 반영해서 자연스럽게 감속해야 하는데, duration 기반 애니메이션은 항상 고정된 시간 동안 실행되기 때문에 끊김이 생긴다.
react-spring은 이 문제를 스프링 물리 모델로 해결한다. tension(장력), friction(마찰), mass(질량) 같은 물리 파라미터로 애니메이션을 정의하면, 프레임마다 물리 시뮬레이션을 통해 값이 계산된다. 애니메이션 도중에 목표값이 바뀌어도 현재 속도를 유지하며 자연스럽게 전환된다.
기본 사용법
가장 기본적인 useSpring 훅의 사용 예시를 보자.
import { useSpring, animated } from '@react-spring/web'
function FadeIn() {
const styles = useSpring({
from: { opacity: 0, y: 20 },
to: { opacity: 1, y: 0 },
})
return <animated.div style={styles}>Hello</animated.div>
}
useSpring은 애니메이션 값을 담은 객체를 반환하고, animated.div는 이 값을 매 프레임 DOM에 직접 적용한다. React의 리렌더링 없이 DOM을 업데이트하기 때문에 높은 성능을 유지할 수 있다.
명령형으로 사용할 수도 있다.
function Toggle() {
const [styles, api] = useSpring(() => ({ scale: 1 }))
return (
<animated.div
style={styles}
onMouseEnter={() => api.start({ scale: 1.1 })}
onMouseLeave={() => api.start({ scale: 1 })}
/>
)
}
api 객체를 통해 start, stop, pause, resume 등으로 애니메이션을 제어할 수 있다.
제공되는 API들
| Hook | 설명 |
|---|---|
useSpring | 단일 스프링 애니메이션 |
useSprings | 여러 스프링을 배열로 관리 |
useTrail | 여러 요소가 순차적으로 애니메이션 |
useTransition | 요소의 마운트/언마운트 애니메이션 |
useChain | 여러 애니메이션을 순서대로 연결 |
useSpringRef | 애니메이션을 명령형으로 제어하기 위한 ref |
useSpringValue | 컴포넌트 밖에서 SpringValue를 생성 |
useResize | ResizeObserver 기반 크기 애니메이션 |
useScroll | 스크롤 위치 애니메이션 |
useInView | IntersectionObserver 기반 진입 애니메이션 |
구현 알아보기
이제 라이브러리의 내부 구현을 자세히 알아보자.
패키지 구조
packages/
├── animated # animated() HOC, AnimatedObject 등 DOM 노드 래핑
├── core # SpringValue, Controller, FrameLoop 등 핵심 로직
├── shared # 유틸리티, 전역 설정(Globals)
├── types # 타입 정의
└── web # 웹 전용 animated 요소 (animated.div 등)
core 패키지에 애니메이션의 핵심 로직이 있고, animated와 web 패키지가 이를 DOM에 연결한다. 플랫폼과 독립적인 설계로, @react-spring/native (React Native), @react-spring/three (Three.js) 등 다양한 환경에서 같은 core를 재사용한다.
packages/core SpringValue
SpringValue는 애니메이션 값을 관리하는 핵심 클래스이다.
class SpringValue<T = any> extends FrameValue<T> {
key?: string
animation = new Animation()
queue?: SpringUpdate<T>[]
defaultProps = {} as SpringDefaultProps<T>
protected _state: {
paused: boolean
pauseQueue: Set<AnimationResolver>
resumeQueue: Set<AnimationResolver>
timeouts: Set<Timeout>
}
constructor(arg1?: any, arg2?: any) {
super()
// from 값이나 초기값으로 초기화
if (arg1 !== undefined) {
this.start(arg1, arg2)
}
}
}
SpringValue는 FrameValue를 상속받는다. FrameValue는 옵저버 패턴을 구현하여, 값이 변경될 때마다 구독자(animated 노드)에게 알린다.
advance 메서드가 매 프레임 호출되어 스프링 물리를 계산한다.
advance(dt: number) {
let idle = true
let changed = false
const anim = this.animation
let { toValues } = anim
let { config } = anim
// 각 숫자 값에 대해 스프링 시뮬레이션
each(toValues, (_to, i) => {
const from = anim.fromValues[i]
const to = _to
let position = anim.values[i]
let velocity = anim.velocities[i]
// 스프링 물리 계산
const spring = config.tension * (to - position)
const damper = config.friction * velocity
const acceleration = (spring - damper) / config.mass
velocity += acceleration * dt
position += velocity * dt
// 값 업데이트
anim.values[i] = position
anim.velocities[i] = velocity
// 정지 조건 확인
if (!isAnimating(velocity, config.precision)) {
idle = idle && true
} else {
idle = false
}
})
// 값이 변했으면 구독자들에게 알림
if (changed) {
this._onChange(value)
}
}
tension이 클수록 목표값으로 빠르게 당기고, friction이 클수록 빨리 감속한다. mass가 크면 관성이 커진다. 이 세 파라미터의 조합으로 다양한 느낌의 애니메이션을 만들 수 있다.
packages/core Controller
Controller는 여러 SpringValue를 묶어서 관리하는 클래스이다.
class Controller<State extends Lookup = Lookup> {
readonly id = nextId()
readonly springs: SpringValues<State> = {} as any
readonly ref?: SpringRef<State>
queue: ControllerQueue<State> = []
start(props?: ControllerUpdate<State> | null) {
let { queue } = this
// props가 있으면 큐에 추가
if (props) {
queue = toArray(props).map(createUpdate)
}
// 큐에 있는 업데이트들을 순차적으로 적용
const results = queue.map((props) => {
return this._update(props)
})
return results
}
}
useSpring({ x: 0, y: 0 })을 호출하면 Controller가 x와 y 각각에 대해 SpringValue를 생성한다. start가 호출되면 모든 SpringValue가 동시에 애니메이션을 시작한다.
packages/core FrameLoop
FrameLoop은 requestAnimationFrame을 사용하여 매 프레임 모든 활성 SpringValue의 물리 시뮬레이션을 실행한다.
class FrameLoop {
private _queue = new Set<FrameValue>()
advance(dt: number) {
// 활성화된 모든 SpringValue를 순회
for (const spring of this._queue) {
spring.advance(dt)
// 애니메이션이 끝났으면 큐에서 제거
if (spring.idle) {
this._queue.delete(spring)
}
}
}
start() {
if (this._active) return
this._active = true
// rAF 루프 시작
const loop = () => {
if (!this._active) return
this.advance(/* delta time */)
raf(loop)
}
raf(loop)
}
}
전역에 하나의 FrameLoop 인스턴스가 존재하며, 모든 활성 애니메이션이 여기에 등록된다. 활성 애니메이션이 없으면 rAF 루프가 자동으로 멈춘다.
packages/animated AnimatedObject
AnimatedObject는 애니메이션 값을 실제 DOM에 적용하는 역할을 한다.
class AnimatedObject extends Animated {
constructor(protected source: Lookup) {
super()
}
getValue(animated?: boolean): Lookup {
const values: Lookup = {}
each(this.source, (source, key) => {
if (isAnimated(source)) {
values[key] = source.getValue(animated)
} else {
values[key] = source
}
})
return values
}
}
style 객체의 각 프로퍼티가 AnimatedValue인지 확인하고, 맞으면 현재 시뮬레이션 값을 가져온다. 이 값들이 모여서 DOM 노드의 style을 업데이트한다.
packages/web animated
animated는 React 컴포넌트를 감싸서 애니메이션 가능한 버전을 만든다.
// animated.div, animated.span 등을 생성
const animated = createAnimatedComponent
function createAnimatedComponent(Component: string | React.ComponentType) {
return forwardRef((givenProps: any, givenRef) => {
// AnimatedProps로 감싸서 값 변경을 추적
const props = new AnimatedProps(() => {
// 값이 변경될 때마다 DOM 직접 업데이트
applyAnimatedValues(node, props.getValue(true))
})
// 실제 DOM 렌더링
return <Component {...animatedProps} ref={ref} />
})
}
핵심은 applyAnimatedValues이다. React의 상태 업데이트 → 리렌더링 사이클을 거치지 않고, DOM 노드의 style을 직접 변경한다. 이것이 react-spring이 60fps 애니메이션을 유지할 수 있는 비결이다.
packages/shared Globals
전역 설정을 관리하는 모듈이다.
export const Globals = {
// 프레임 루프
frameLoop: 'always' as 'always' | 'demand',
// DOM에 값을 적용하는 함수 (플랫폼별로 다름)
applyAnimatedValues: undefined as any,
// 색상 문자열 파싱
colorNames: undefined as any,
// rAF 래퍼
requestAnimationFrame: typeof window !== 'undefined'
? window.requestAnimationFrame
: undefined,
// 타임스탬프
now: typeof performance !== 'undefined'
? () => performance.now()
: () => Date.now(),
}
Globals.assign()을 통해 플랫폼별로 필요한 함수들을 주입한다. @react-spring/web은 DOM용 applyAnimatedValues를, @react-spring/native는 React Native용 함수를 주입하는 식이다.
스프링 물리 모델
react-spring의 핵심인 스프링 물리 시뮬레이션을 자세히 보자.
// 후크의 법칙 + 감쇠
// F = -k * x - c * v
// a = F / m
const spring = tension * (to - position) // -k * x (복원력)
const damper = friction * velocity // c * v (감쇠력)
const acceleration = (spring - damper) / mass
velocity += acceleration * dt
position += velocity * dt
이것은 감쇠 조화 진동자(damped harmonic oscillator) 모델이다.
tension은 스프링 상수(k)로, 클수록 빠르게 목표값으로 당긴다friction은 감쇠 계수(c)로, 클수록 진동이 적고 빠르게 정지한다mass는 질량(m)으로, 클수록 느리고 무거운 느낌이 난다
이 모델의 장점은 duration이 없다는 것이다. 물리 시뮬레이션이 자연스러운 정지 지점을 찾아서 알아서 멈춘다. 그래서 애니메이션 중간에 목표값을 바꿔도 끊김 없이 자연스럽게 전환된다.
미리 정의된 preset들도 있다.
const config = {
default: { tension: 170, friction: 26 },
gentle: { tension: 120, friction: 14 },
wobbly: { tension: 180, friction: 12 },
stiff: { tension: 210, friction: 20 },
slow: { tension: 280, friction: 60 },
molasses: { tension: 280, friction: 120 },
}
Interpolation
to 메서드(또는 interpolate)로 애니메이션 값을 변환할 수 있다.
const { x } = useSpring({ x: 0 })
// 범위 매핑
x.to([0, 1], [0, 100]) // 0~1 → 0~100
// 함수 변환
x.to(v => `${v}px`) // 숫자 → 문자열
// 여러 값 조합
to([x, y], (x, y) => `translate(${x}px, ${y}px)`)
내부적으로 Interpolation 클래스가 이를 처리한다.
class Interpolation<In = any, Out = any> extends FrameValue<Out> {
calc: InterpolatorFn<In, Out>
constructor(
readonly source: OneOrMore<FluidValue>,
args: InterpolatorArgs<In, Out>
) {
super()
this.calc = createInterpolator(...args)
}
// 소스 값이 변경되면 변환된 값을 계산
advance(_dt?: number) {
const value = this.calc(...this._get())
if (!isEqual(value, this.get())) {
this._set(value)
}
}
}
Interpolation도 FrameValue를 상속받아, 소스 SpringValue가 변경될 때마다 변환된 값을 자동으로 계산하고 구독자에게 전달한다.
useTransition 구현
useTransition은 리스트의 아이템이 추가/삭제될 때 애니메이션을 적용한다.
function useTransition(data, props) {
// 1. 이전 아이템과 현재 아이템을 비교해서 변화를 감지
const transitions = diffItems(prevItems, items)
// 2. 각 아이템에 대해 phase 결정
// phase: 'enter' | 'leave' | 'update'
transitions.forEach(t => {
if (t.phase === 'enter') {
// from → enter 애니메이션
t.spring.start({ from: props.from, to: props.enter })
} else if (t.phase === 'leave') {
// → leave 애니메이션 후 DOM에서 제거
t.spring.start({ to: props.leave }).then(() => {
// 애니메이션 완료 후 transition 제거
})
} else {
// update 애니메이션
t.spring.start({ to: props.update })
}
})
// 3. 각 transition에 대한 렌더 함수 반환
return transitions.map(t => ({
item: t.item,
key: t.key,
props: t.spring,
}))
}
key를 기반으로 아이템의 추가/삭제를 감지하고, 각 상태에 맞는 애니메이션을 실행한다. leave 애니메이션이 완료될 때까지 DOM에서 요소를 유지하는 것이 핵심이다.
useTrail 구현
useTrail은 여러 요소가 순차적으로 애니메이션되도록 한다.
function useTrail(length, propsArg) {
const items = []
const springs = useSprings(length, (i, spring) => {
// 첫 번째 스프링을 제외하고, 이전 스프링이 완료된 후 시작
if (i > 0) {
// 이전 스프링의 현재 값을 from으로 사용
spring.start({
...props,
delay: props.delay + i * trailDelay,
})
}
return props
})
return springs
}
각 스프링이 이전 스프링보다 약간 늦게 시작하도록 delay를 조절하여 물결 효과를 만든다.
주목할 만한 점
- React 리렌더링 우회: animated 컴포넌트는 React의 상태 업데이트 없이 DOM을 직접 변경한다. 이를 통해 60fps 애니메이션을 유지할 수 있다.
- 물리 기반 중단 처리: 애니메이션 도중 새로운 목표값이 설정되면, 현재 속도를 유지하면서 자연스럽게 방향을 전환한다. duration 기반 애니메이션에서는 불가능한 동작이다.
- 플랫폼 독립적 설계: core 패키지가 플랫폼과 완전히 분리되어 있어, 같은 물리 엔진으로 Web, React Native, Three.js 등 다양한 환경을 지원한다.
- 비동기 애니메이션 체이닝:
start()가 Promise를 반환하여, 애니메이션 완료 후 다음 동작을 연결할 수 있다.useChain으로 여러 애니메이션의 순서를 정밀하게 제어할 수도 있다. - SSR 호환: 서버 사이드 렌더링에서도 초기값으로 안전하게 렌더링된다.
- 타입 안전성: TypeScript로 작성되어
useSpring({ opacity: 0, x: 100 })에서 반환되는 값의 타입이 자동으로 추론된다.
언제 쓰면 좋을까?
- 인터랙티브 UI: 드래그 앤 드롭, 스와이프, 제스처 기반 인터페이스 등 사용자 입력에 반응하는 애니메이션
- 리스트 애니메이션: 아이템 추가/삭제 시 자연스러운 전환 (
useTransition) - 순차 애니메이션: 여러 요소가 물결처럼 순서대로 나타나는 효과 (
useTrail) - 레이아웃 애니메이션: 요소의 크기, 위치 변경에 자연스러운 전환 적용
- @use-gesture와 함께: 제스처 입력의 속도와 방향을 스프링에 전달하면, 네이티브 앱과 같은 물리 기반 인터랙션을 구현할 수 있다
덧붙이는 말
@react-spring은 "애니메이션은 시간이 아니라 물리다"라는 철학을 가진 라이브러리이다. duration 대신 tension, friction, mass로 애니메이션을 정의하는 것이 처음에는 낯설 수 있지만, 한번 익숙해지면 더 자연스러운 인터랙션을 더 쉽게 만들 수 있다. 특히 React의 리렌더링을 우회하여 DOM을 직접 업데이트하는 구조는, 복잡한 애니메이션에서도 성능 문제 없이 사용할 수 있게 해준다. @use-gesture와 함께 사용하면 웹에서도 iOS 앱과 같은 부드러운 인터랙션을 구현할 수 있다.