@use-gesture/react는 마우스, 터치 이벤트를 풍부하게 다룰 수 있게 해주는 라이브러리이다. 드래그, 핀치, 스크롤, 휠, 호버 등 다양한 제스처를 쉽게 처리할 수 있다. react-spring과 함께 사용하면 더욱 강력한 인터랙션을 구현할 수 있다.
누가 왜 만들었나?
@use-gesture는 poimandres 커뮤니티에서 만들었다. 이 커뮤니티는 react-spring, react-three-fiber, zustand 등 유명한 React 생태계 라이브러리들을 만든 곳이다.
웹에서 네이티브 앱과 같은 부드러운 제스처 인터랙션을 구현하기 위해서는 복잡한 이벤트 처리가 필요하다. 드래그 중인지, 얼마나 이동했는지, 속도는 얼마인지 등을 직접 계산해야 한다. @use-gesture는 이런 복잡한 로직을 추상화하여, 개발자가 제스처 데이터만으로 원하는 인터랙션을 쉽게 구현할 수 있도록 해준다.
기본 사용법
@use-gesture는 여러 훅을 제공한다. 가장 많이 사용되는 useDrag 훅의 예시를 보자.
import { useSpring, animated } from '@react-spring/web'
import { useDrag } from '@use-gesture/react'
function Example() {
const [{ x, y }, api] = useSpring(() => ({ x: 0, y: 0 }))
const bind = useDrag(({ down, movement: [mx, my] }) => {
api.start({ x: down ? mx : 0, y: down ? my : 0 })
})
return <animated.div {...bind()} style={{ x, y, touchAction: 'none' }} />
}
useDrag 훅은 핸들러 함수를 받아서, 드래그 이벤트가 발생할 때마다 호출한다. 핸들러에는 down(드래그 중인지), movement(이동 거리) 등 다양한 상태 정보가 전달된다.
제공되는 훅들
| Hook | 설명 |
|---|---|
useDrag | 드래그 제스처 |
useMove | 마우스 이동 이벤트 |
useHover | 마우스 진입/퇴장 이벤트 |
useScroll | 스크롤 이벤트 |
useWheel | 휠 이벤트 |
usePinch | 핀치 제스처 |
useGesture | 여러 제스처를 하나의 훅으로 |
구현 알아보기
이제 라이브러리의 내부 구현을 자세히 알아보자.
패키지 구조
packages/
├── core # 핵심 로직 (프레임워크 무관)
├── react # React 통합
└── vanilla # Vanilla JS 통합
core 패키지에 모든 핵심 로직이 있고, react와 vanilla 패키지는 이를 각 환경에 맞게 래핑한다. 프레임워크와 독립적인 설계로 재사용성을 높였다.
packages/core Controller
Controller는 제스처 상태를 관리하는 핵심 클래스이다.
export class Controller {
public gestures = new Set<GestureKey>()
private _targetEventStore = new EventStore(this)
public gestureEventStores: { [key in GestureKey]?: EventStore } = {}
public gestureTimeoutStores: { [key in GestureKey]?: TimeoutStore } = {}
public handlers: InternalHandlers = {}
public config = {} as InternalConfig
public pointerIds = new Set<number>()
public touchIds = new Set<number>()
public state = {
shared: {
shiftKey: false,
metaKey: false,
ctrlKey: false,
altKey: false
}
} as State
}
Controller는 여러 제스처를 동시에 관리할 수 있다. 각 제스처별로 EventStore(이벤트 리스너 관리)와 TimeoutStore(타임아웃 관리)를 가지고 있다. pointerIds와 touchIds는 멀티 터치를 지원하기 위해 현재 활성화된 포인터/터치 ID들을 추적한다.
bind 메서드는 제스처 핸들러를 DOM 요소에 바인딩하는 역할을 한다.
bind(...args: any[]) {
const sharedConfig = this.config.shared
const props: any = {}
// target이 설정된 경우
if (sharedConfig.target) {
target = sharedConfig.target()
if (!target) return
}
if (sharedConfig.enabled) {
// 각 제스처에 대해 Engine을 생성하고 바인딩
for (const gestureKey of this.gestures) {
const gestureConfig = this.config[gestureKey]!
const bindFunction = bindToProps(props, gestureConfig.eventOptions, !!target)
if (gestureConfig.enabled) {
const Engine = EngineMap.get(gestureKey)!
new Engine(this, args, gestureKey).bind(bindFunction)
}
}
}
// props를 체이닝
for (const handlerProp in props) {
props[handlerProp] = chain(...props[handlerProp])
}
// target이 없으면 props 반환, 있으면 직접 리스너 등록
if (!target) return props
// ...
}
target이 없을 때는 React의 props로 사용할 수 있는 객체를 반환하고, target이 있으면 직접 이벤트 리스너를 등록한다.
packages/core Engine
Engine은 각 제스처의 핵심 로직을 담당하는 추상 클래스이다.
export abstract class Engine<Key extends GestureKey> {
ctrl: Controller
readonly key: Key
abstract readonly ingKey: IngKey // 'dragging' | 'pinching' 등
abstract readonly aliasKey: string // 'xy' | 'da'
args: any[]
abstract computeOffset(): void
abstract computeMovement(): void
abstract bind(bindFunction: (...)): void
}
각 제스처(Drag, Pinch, Wheel 등)는 이 클래스를 상속받아 구현한다. 주목할 점은 ingKey인데, 이는 제스처가 진행 중인지를 나타내는 상태 키이다. 예를 들어 드래그의 경우 dragging이 된다.
compute 메서드는 제스처의 모든 상태를 계산한다.
compute(event?: NonUndefined<State[Key]>['event']) {
const { state, config, shared } = this
if (event) {
state.event = event
if (config.preventDefault && event.cancelable) state.event.preventDefault()
state.type = event.type
shared.touches = this.ctrl.pointerIds.size || this.ctrl.touchIds.size
shared.locked = !!document.pointerLockElement
// ...
}
// 의도성 체크
if (this.axisIntent) this.axisIntent(event)
// threshold 처리
const [_m0, _m1] = state._movement
const [t0, t1] = config.threshold
if (_step[0] === false) _step[0] = Math.abs(_m0) >= t0 && Math.sign(_m0) * t0
if (_step[1] === false) _step[1] = Math.abs(_m1) >= t1 && Math.sign(_m1) * t1
state.intentional = _step[0] !== false || _step[1] !== false
if (!state.intentional) return
// 움직임 계산
// ...
// 속도(kinematics) 계산
if (!state.first && dt > 0) {
state.velocity = [absoluteDelta[0] / dt, absoluteDelta[1] / dt]
state.timeDelta = dt
}
}
threshold를 넘어야만 intentional(의도적인 제스처)로 판단하고, 그 전까지는 핸들러를 호출하지 않는다. 이로 인해 의도치 않은 미세한 움직임은 무시된다.
packages/core DragEngine
DragEngine은 드래그 제스처를 처리하는 엔진이다. CoordinatesEngine을 상속받는다.
export class DragEngine extends CoordinatesEngine<'drag'> {
ingKey = 'dragging' as const
reset(this: DragEngine) {
super.reset()
const state = this.state
state._pointerId = undefined
state._pointerActive = false
state._keyboardActive = false
state._preventScroll = false
state._delayed = false
state.swipe = [0, 0]
state.tap = false
state.canceled = false
state.cancel = this.cancel.bind(this)
}
}
드래그 엔진은 포인터 이벤트 외에도 키보드 이벤트를 지원한다. 화살표 키로 요소를 이동할 수 있어 접근성을 높였다.
const KEYS_DELTA_MAP = {
ArrowRight: (displacement: number, factor: number = 1) => [displacement * factor, 0],
ArrowLeft: (displacement: number, factor: number = 1) => [-1 * displacement * factor, 0],
ArrowUp: (displacement: number, factor: number = 1) => [0, -1 * displacement * factor],
ArrowDown: (displacement: number, factor: number = 1) => [0, displacement * factor]
}
keyDown(event: KeyboardEvent) {
const deltaFn = KEYS_DELTA_MAP[event.key]
if (deltaFn) {
const factor = event.shiftKey ? 10 : event.altKey ? 0.1 : 1
this.start(event)
state._delta = deltaFn(this.config.keyboardDisplacement, factor)
state._keyboardActive = true
V.addTo(state._movement, state._delta)
this.compute(event)
this.emit()
}
}
Shift를 누르면 10배, Alt를 누르면 0.1배로 이동 거리가 조절된다.
swipe 감지도 pointerUp에서 처리된다.
pointerUp(event: PointerEvent) {
// ...
const [dx, dy] = state._distance
state.tap = dx <= config.tapsThreshold && dy <= config.tapsThreshold
if (!state.tap) {
const [svx, svy] = config.swipe.velocity
const [sx, sy] = config.swipe.distance
const sdt = config.swipe.duration
if (state.elapsedTime < sdt) {
const _vx = Math.abs(_dx / state.timeDelta)
const _vy = Math.abs(_dy / state.timeDelta)
if (_vx > svx && Math.abs(_mx) > sx) state.swipe[0] = Math.sign(_dx)
if (_vy > svy && Math.abs(_my) > sy) state.swipe[1] = Math.sign(_dy)
}
}
}
일정 시간 내에 충분한 속도와 거리로 이동하면 swipe로 인식한다.
packages/core PinchEngine
PinchEngine은 핀치(확대/축소) 제스처를 처리한다. 터치, 포인터, 휠, WebKit Gesture 이벤트 등 다양한 입력을 지원한다.
export class PinchEngine extends Engine<'pinch'> {
ingKey = 'pinching' as const
aliasKey = 'da' // distance, angle
init() {
this.state.offset = [1, 0] // [scale, rotation]
this.state.lastOffset = [1, 0]
this.state._pointerEvents = new Map()
}
computeOffset() {
const { type, movement, lastOffset } = this.state
if (type === 'wheel') {
this.state.offset = V.add(movement, lastOffset)
} else {
// 터치/포인터의 경우 곱셈으로 스케일 계산
this.state.offset = [(1 + movement[0]) * lastOffset[0], movement[1] + lastOffset[1]]
}
}
}
드래그와 달리 aliasKey가 da(distance, angle)이다. offset의 첫 번째 값은 스케일(1이 기본), 두 번째 값은 회전 각도이다.
휠로 핀치를 에뮬레이트하는 기능도 있다.
wheel(event: WheelEvent) {
const modifierKey = this.config.modifierKey
// Ctrl, Meta 등 modifier key가 눌려있어야 핀치로 인식
if (modifierKey && !event[modifierKey]) return
if (!this.state._active) this.wheelStart(event)
else this.wheelChange(event)
this.timeoutStore.add('wheelEnd', this.wheelEnd.bind(this))
}
트랙패드에서 Ctrl+스크롤로 확대/축소하는 동작을 핀치로 처리할 수 있다.
packages/core EventStore
이벤트 리스너를 관리하는 유틸리티 클래스이다.
export class EventStore {
private _listeners = new Set<() => void>()
add(element: EventTarget, device: string, action: string, handler, options?) {
const type = toDomEventType(device, action)
element.addEventListener(type, handler, eventOptions)
const remove = () => {
element.removeEventListener(type, handler, eventOptions)
listeners.delete(remove)
}
listeners.add(remove)
return remove
}
clean() {
this._listeners.forEach((remove) => remove())
this._listeners.clear()
}
}
리스너를 추가할 때 cleanup 함수를 저장해두고, clean()을 호출하면 모든 리스너를 제거한다. 메모리 누수를 방지하는 패턴이다.
packages/core TimeoutStore
타임아웃을 관리하는 유틸리티 클래스이다.
export class TimeoutStore {
private _timeouts = new Map<string, number>()
add<FunctionType extends (...args: any[]) => any>(
key: string,
callback: FunctionType,
ms = 140,
...args: Parameters<FunctionType>
) {
this.remove(key)
this._timeouts.set(key, window.setTimeout(callback, ms, ...args))
}
remove(key: string) {
const timeout = this._timeouts.get(key)
if (timeout) window.clearTimeout(timeout)
}
}
같은 key로 여러 번 호출해도 이전 타임아웃이 취소되고 새로운 것으로 대체된다. 디바운스 패턴에 유용하다.
packages/core ConfigResolver
설정을 해석하고 기본값을 적용하는 로직이다.
export function resolveWith<T, V>(config: Partial<T> = {}, resolvers: ResolverMap): V {
const result: any = {}
for (const [key, resolver] of Object.entries(resolvers)) {
switch (typeof resolver) {
case 'function':
result[key] = resolver.call(result, config[key], key, config)
break
case 'object':
result[key] = resolveWith(config[key], resolver)
break
case 'boolean':
if (resolver) result[key] = config[key]
break
}
}
return result
}
resolver가 함수일 때 this를 result로 바인딩하여 호출한다. 이를 통해 resolver 내에서 다른 설정값을 참조할 수 있다.
dragConfigResolver의 예시를 보자.
export const dragConfigResolver = {
device(this, _v, _k, { pointer: { touch, lock, mouse } = {} }) {
this.pointerLock = lock && SUPPORT.pointerLock
if (SUPPORT.touch && touch) return 'touch'
if (this.pointerLock) return 'mouse'
if (SUPPORT.pointer && !mouse) return 'pointer'
if (SUPPORT.touch) return 'touch'
return 'mouse'
},
swipe({ velocity = 0.5, distance = 50, duration = 250 } = {}) {
return {
velocity: this.transform(V.toVector(velocity)),
distance: this.transform(V.toVector(distance)),
duration
}
},
delay(value = 0) {
switch (value) {
case true: return 180
case false: return 0
default: return value
}
}
}
device resolver는 다양한 옵션에 따라 사용할 이벤트 타입을 결정한다. this.pointerLock처럼 다른 resolver에서 설정한 값을 참조할 수 있다.
packages/core/utils maths
벡터 연산 유틸리티와 rubberband 효과 구현이 있다.
export const V = {
toVector<T>(v: T | [T, T] | undefined, fallback?): [T, T] {
if (v === undefined) v = fallback
return Array.isArray(v) ? v : [v, v]
},
add(v1: Vector2, v2: Vector2): Vector2 {
return [v1[0] + v2[0], v1[1] + v2[1]]
},
sub(v1: Vector2, v2: Vector2): Vector2 {
return [v1[0] - v2[0], v1[1] - v2[1]]
},
addTo(v1: Vector2, v2: Vector2) {
v1[0] += v2[0]
v1[1] += v2[1]
}
}
toVector는 스칼라 값을 [x, y] 형태로 변환한다. 설정에서 threshold: 10처럼 단일 값을 넘겨도 [10, 10]으로 처리된다.
rubberband 효과는 iOS의 스크롤 바운스와 같은 효과를 구현한다.
function rubberband(distance: number, dimension: number, constant: number) {
if (dimension === 0 || Math.abs(dimension) === Infinity)
return Math.pow(distance, constant * 5)
return (distance * dimension * constant) / (dimension + constant * distance)
}
export function rubberbandIfOutOfBounds(position, min, max, constant = 0.15) {
if (constant === 0) return clamp(position, min, max)
if (position < min) return -rubberband(min - position, max - min, constant) + min
if (position > max) return +rubberband(position - max, max - min, constant) + max
return position
}
bounds를 벗어나면 저항감 있게 움직이다가, 손을 떼면 bounds 안으로 돌아오는 효과를 만들 수 있다.
packages/react useRecognizers
React에서 Controller를 사용하는 핵심 훅이다.
export function useRecognizers<Config extends GenericOptions>(
handlers: InternalHandlers,
config: Config | {} = {},
gestureKey?: GestureKey,
nativeHandlers?: NativeHandlers
): HookReturnType<Config> {
const ctrl = React.useMemo(() => new Controller(handlers), [])
ctrl.applyHandlers(handlers, nativeHandlers)
ctrl.applyConfig(config, gestureKey)
React.useEffect(ctrl.effect.bind(ctrl))
React.useEffect(() => {
return ctrl.clean.bind(ctrl)
}, [])
if (config.target === undefined) {
return ctrl.bind.bind(ctrl) as any
}
return undefined as any
}
useMemo로 Controller 인스턴스를 한 번만 생성하고, 매 렌더링마다 handlers와 config를 업데이트한다. cleanup은 컴포넌트 언마운트 시에만 실행된다.
config.target이 있으면 자동으로 이벤트를 바인딩하고, 없으면 bind 함수를 반환하여 수동으로 바인딩하게 한다.
packages/vanilla Recognizer
Vanilla JS에서 제스처를 사용하기 위한 클래스이다.
export class Recognizer<GK extends GestureKey | undefined = undefined> {
private _ctrl: Controller
private _target: EventTarget
constructor(target, handlers, config, gestureKey?, nativeHandlers?) {
this._target = target
this._ctrl = new Controller(handlers)
this._ctrl.applyHandlers(handlers, nativeHandlers)
this._ctrl.applyConfig({ ...config, target }, gestureKey)
this._ctrl.effect()
}
destroy() {
this._ctrl.clean()
}
setConfig(config) {
this._ctrl.clean()
this._ctrl.applyConfig({ ...config, target: this._target }, this._gestureKey)
this._ctrl.effect()
}
}
React 훅과 달리 클래스 기반이며, destroy() 메서드로 명시적으로 정리해야 한다.
주목할 만한 점
- 프레임워크 독립적 설계: core 패키지가 프레임워크와 완전히 분리되어 있어, React 외에도 Vue, Svelte 등으로 쉽게 확장할 수 있다.
- 접근성 지원: 키보드로도 드래그를 할 수 있어 스크린 리더 사용자도 인터랙션할 수 있다.
- 개발 환경 경고:
process.env.NODE_ENV === 'development'를 활용해 개발 중에만 유용한 경고를 표시하고, 프로덕션에서는 제거된다. - 터치 스크롤 처리:
preventScroll옵션으로 드래그와 스크롤이 충돌하는 문제를 해결할 수 있다. - WebKit Gesture 지원: Safari의
gesturestart/change/end이벤트를 지원하여 네이티브와 같은 핀치 경험을 제공한다.
언제 쓰면 좋을까?
- 드래그 앤 드롭 UI: 카드 재정렬, 슬라이더, 캔버스 편집기 등
- 제스처 기반 내비게이션: 스와이프로 페이지 전환, 액션 시트 등
- 인터랙티브 갤러리: 핀치 줌, 패닝이 필요한 이미지/지도 뷰어
- 애니메이션 라이브러리와 함께: react-spring, framer-motion과 결합하여 물리 기반 애니메이션 구현
덧붙이는 말
@use-gesture는 복잡한 제스처 처리를 추상화하여 몇 줄의 코드로 자연스러운 인터랙션을 구현할 수 있게 해준다. 특히 core와 프레임워크 통합을 분리한 설계는 라이브러리의 재사용성과 유지보수성을 높였다. react-spring과 함께 사용하면 iOS 앱과 같은 부드러운 인터랙션을 웹에서도 구현할 수 있다.