@use-gesture/react 자세히 알아보기

Feb 21, 2026

@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(타임아웃 관리)를 가지고 있다. pointerIdstouchIds는 멀티 터치를 지원하기 위해 현재 활성화된 포인터/터치 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]]
    }
  }
}

드래그와 달리 aliasKeyda(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가 함수일 때 thisresult로 바인딩하여 호출한다. 이를 통해 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 앱과 같은 부드러운 인터랙션을 웹에서도 구현할 수 있다.