{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "use-swipe-navigation",
  "title": "useSwipeNavigation",
  "description": "A touch-first React hook for previous and next navigation with configurable feedback, live gesture progress, direction locking, ownership, and reduced-motion support.",
  "files": [
    {
      "path": "registry/base/hooks/use-swipe-navigation.ts",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useRef } from \"react\";\n\nexport type SwipeNavigationDirection = \"previous\" | \"next\";\n\nexport type SwipeNavigationProgress = {\n  direction: SwipeNavigationDirection;\n  /** Horizontal travel from the gesture origin, in pixels. */\n  deltaX: number;\n  /** Vertical travel from the gesture origin, in pixels. */\n  deltaY: number;\n  /** Average horizontal speed since touch start, in pixels per millisecond. */\n  velocity: number;\n  /** Distance-threshold progress clamped from 0 to 1. */\n  progress: number;\n  /** Damped offset a custom renderer can apply; zero under reduced motion. */\n  feedbackX: number;\n  available: boolean;\n  reduceMotion: boolean;\n};\n\nexport type SwipeNavigationFeedbackOptions = {\n  /** Set false to keep calculations but let the consumer render feedback. */\n  enabled?: boolean;\n  /** Maximum visual travel while navigation is available, in pixels. */\n  distance?: number;\n  /** Multiplier applied to finger travel while navigation is available. */\n  resistance?: number;\n  /** Maximum visual travel past an unavailable edge, in pixels. */\n  edgeDistance?: number;\n  /** Multiplier applied to finger travel past an unavailable edge. */\n  edgeResistance?: number;\n  /** Return-to-origin duration for an uncommitted swipe, in milliseconds. */\n  resetDuration?: number;\n  /** CSS easing used when an uncommitted swipe returns to its origin. */\n  resetEasing?: string;\n};\n\ntype SwipeState = {\n  touchIdentifier: number;\n  startX: number;\n  startY: number;\n  currentX: number;\n  currentY: number;\n  startedAt: number;\n  axis: \"pending\" | \"horizontal\" | \"vertical\";\n  reduceMotion: boolean;\n};\n\nexport type UseSwipeNavigationOptions<\n  T extends HTMLElement = HTMLElement,\n> = {\n  onPrevious: () => void;\n  onNext: () => void;\n  hasPrevious?: boolean;\n  hasNext?: boolean;\n  disabled?: boolean;\n  ignoreOwnedGestures?: boolean;\n  /** Adds app-specific gesture ownership on top of the built-in exclusions. */\n  shouldIgnoreTarget?: (target: EventTarget | null, boundary: T) => boolean;\n  onIntentChange?: (direction: SwipeNavigationDirection | null) => void;\n  /** Emits live gesture metrics, then `null` when tracking ends. */\n  onSwipeProgress?: (progress: SwipeNavigationProgress | null) => void;\n  /** Minimum horizontal travel, in pixels, before a swipe navigates. */\n  distanceThreshold?: number;\n  /** Minimum pixels per millisecond for a shorter flick to navigate. */\n  velocityThreshold?: number;\n  /** Finger travel before the hook chooses horizontal or vertical movement. */\n  directionLockThreshold?: number;\n  /** Horizontal movement must exceed vertical movement by this ratio. */\n  directionLockRatio?: number;\n  /** Built-in transform feedback, or `false` when the consumer renders it. */\n  feedback?: boolean | SwipeNavigationFeedbackOptions;\n  /** @deprecated Use `feedback.distance`. */\n  feedbackDistance?: number;\n  /** @deprecated Use `feedback.resistance`. */\n  feedbackResistance?: number;\n};\n\nconst DEFAULT_DISTANCE_THRESHOLD = 52;\nconst DEFAULT_VELOCITY_THRESHOLD = 0.35;\nconst DEFAULT_DIRECTION_LOCK_THRESHOLD = 8;\nconst DEFAULT_DIRECTION_LOCK_RATIO = 1.25;\nconst DEFAULT_FEEDBACK_DISTANCE = 16;\nconst DEFAULT_FEEDBACK_RESISTANCE = 0.2;\nconst DEFAULT_EDGE_FEEDBACK_DISTANCE = 8;\nconst DEFAULT_EDGE_FEEDBACK_RESISTANCE = 0.08;\nconst DEFAULT_RESET_DURATION = 140;\nconst DEFAULT_RESET_EASING = \"cubic-bezier(0.23, 1, 0.32, 1)\";\nconst OWNED_GESTURE_SELECTOR = [\n  \"input\",\n  \"textarea\",\n  \"select\",\n  \"[contenteditable]:not([contenteditable='false'])\",\n  \"[role='slider']\",\n  \"[draggable='true']\",\n  \"video[controls]\",\n  \"audio[controls]\",\n  \"[data-swipe-navigation='ignore']\",\n].join(\",\");\n\n/**\n * Recognizes one-finger horizontal touch gestures without taking over vertical\n * scrolling. A non-passive touchmove listener lets nested horizontal controls\n * keep their own gestures while the surrounding surface remains swipeable.\n */\nexport function useSwipeNavigation<T extends HTMLElement>({\n  onPrevious,\n  onNext,\n  hasPrevious = true,\n  hasNext = true,\n  disabled = false,\n  ignoreOwnedGestures = false,\n  shouldIgnoreTarget,\n  onIntentChange,\n  onSwipeProgress,\n  distanceThreshold = DEFAULT_DISTANCE_THRESHOLD,\n  velocityThreshold = DEFAULT_VELOCITY_THRESHOLD,\n  directionLockThreshold = DEFAULT_DIRECTION_LOCK_THRESHOLD,\n  directionLockRatio = DEFAULT_DIRECTION_LOCK_RATIO,\n  feedback = true,\n  feedbackDistance,\n  feedbackResistance,\n}: UseSwipeNavigationOptions<T>) {\n  const elementRef = useRef<T>(null);\n  const swipeStateRef = useRef<SwipeState | null>(null);\n  const suppressClickRef = useRef(false);\n  const resetTimeoutRef = useRef<number | null>(null);\n  const callbacksRef = useRef({\n    onPrevious,\n    onNext,\n    onIntentChange,\n    onSwipeProgress,\n    shouldIgnoreTarget,\n  });\n  const feedbackOptions = typeof feedback === \"object\" ? feedback : undefined;\n  const feedbackEnabled =\n    feedback !== false && feedbackOptions?.enabled !== false;\n  const resolvedFeedbackDistance = nonNegative(\n    feedbackOptions?.distance ??\n      feedbackDistance ??\n      DEFAULT_FEEDBACK_DISTANCE,\n  );\n  const resolvedFeedbackResistance = nonNegative(\n    feedbackOptions?.resistance ??\n      feedbackResistance ??\n      DEFAULT_FEEDBACK_RESISTANCE,\n  );\n  const edgeFeedbackDistance = nonNegative(\n    feedbackOptions?.edgeDistance ?? DEFAULT_EDGE_FEEDBACK_DISTANCE,\n  );\n  const edgeFeedbackResistance = nonNegative(\n    feedbackOptions?.edgeResistance ?? DEFAULT_EDGE_FEEDBACK_RESISTANCE,\n  );\n  const resetDuration = nonNegative(\n    feedbackOptions?.resetDuration ?? DEFAULT_RESET_DURATION,\n  );\n  const resetEasing =\n    feedbackOptions?.resetEasing?.trim() || DEFAULT_RESET_EASING;\n  const resolvedDistanceThreshold = nonNegative(distanceThreshold);\n  const resolvedVelocityThreshold = nonNegative(velocityThreshold);\n  const resolvedDirectionLockThreshold = nonNegative(directionLockThreshold);\n  const resolvedDirectionLockRatio = nonNegative(directionLockRatio);\n\n  useEffect(() => {\n    callbacksRef.current = {\n      onPrevious,\n      onNext,\n      onIntentChange,\n      onSwipeProgress,\n      shouldIgnoreTarget,\n    };\n  }, [\n    onIntentChange,\n    onNext,\n    onPrevious,\n    onSwipeProgress,\n    shouldIgnoreTarget,\n  ]);\n\n  const clearResetTimeout = useCallback(() => {\n    if (resetTimeoutRef.current === null) return;\n\n    window.clearTimeout(resetTimeoutRef.current);\n    resetTimeoutRef.current = null;\n  }, []);\n\n  const clearFeedbackStyles = useCallback((element = elementRef.current) => {\n    if (!element) return;\n\n    element.style.removeProperty(\"transition\");\n    element.style.removeProperty(\"transform\");\n    element.style.removeProperty(\"will-change\");\n  }, []);\n\n  const resetSwipePosition = useCallback(() => {\n    const element = elementRef.current;\n\n    if (!element) return;\n\n    if (!feedbackEnabled) {\n      clearResetTimeout();\n      clearFeedbackStyles();\n      return;\n    }\n\n    const reduceMotion =\n      window.matchMedia?.(\"(prefers-reduced-motion: reduce)\").matches ?? false;\n    const resetsInstantly = reduceMotion || resetDuration === 0;\n\n    element.style.transition = resetsInstantly\n      ? \"none\"\n      : `transform ${resetDuration}ms ${resetEasing}`;\n    element.style.transform = \"translate3d(0, 0, 0)\";\n\n    clearResetTimeout();\n    resetTimeoutRef.current = window.setTimeout(() => {\n      clearFeedbackStyles();\n      resetTimeoutRef.current = null;\n    }, resetsInstantly ? 0 : resetDuration + 20);\n  }, [\n    clearFeedbackStyles,\n    clearResetTimeout,\n    feedbackEnabled,\n    resetDuration,\n    resetEasing,\n  ]);\n\n  useEffect(() => {\n    const element = elementRef.current;\n\n    if (!element || disabled) {\n      swipeStateRef.current = null;\n      callbacksRef.current.onIntentChange?.(null);\n      clearResetTimeout();\n      clearFeedbackStyles();\n      return;\n    }\n\n    const swipeElement = element;\n    let currentIntent: SwipeNavigationDirection | null = null;\n    let hasActiveProgress = false;\n\n    function updateIntent(direction: SwipeNavigationDirection | null) {\n      if (currentIntent === direction) return;\n\n      currentIntent = direction;\n      callbacksRef.current.onIntentChange?.(direction);\n    }\n\n    function updateProgress(progress: SwipeNavigationProgress | null) {\n      if (progress === null && !hasActiveProgress) return;\n\n      hasActiveProgress = progress !== null;\n      callbacksRef.current.onSwipeProgress?.(progress);\n    }\n\n    function handleTouchStart(event: TouchEvent) {\n      if (event.touches.length !== 1) {\n        const state = swipeStateRef.current;\n\n        if (state) {\n          updateIntent(null);\n          updateProgress(null);\n          if (state.axis === \"horizontal\") {\n            resetSwipePosition();\n          } else {\n            clearFeedbackStyles();\n          }\n        }\n\n        swipeStateRef.current = null;\n        return;\n      }\n\n      if (\n        callbacksRef.current.shouldIgnoreTarget?.(\n          event.target,\n          swipeElement,\n        ) ||\n        (ignoreOwnedGestures &&\n          ownsHorizontalGesture(event.target, swipeElement))\n      ) {\n        swipeStateRef.current = null;\n        return;\n      }\n\n      const touch = event.touches[0];\n\n      if (!touch) return;\n\n      clearResetTimeout();\n      swipeElement.style.removeProperty(\"transition\");\n      const reduceMotion =\n        window.matchMedia?.(\"(prefers-reduced-motion: reduce)\").matches ??\n        false;\n\n      if (feedbackEnabled && !reduceMotion) {\n        swipeElement.style.willChange = \"transform\";\n      }\n\n      swipeStateRef.current = {\n        touchIdentifier: touch.identifier,\n        startX: touch.clientX,\n        startY: touch.clientY,\n        currentX: touch.clientX,\n        currentY: touch.clientY,\n        startedAt: performance.now(),\n        axis: \"pending\",\n        reduceMotion,\n      };\n    }\n\n    function handleTouchMove(event: TouchEvent) {\n      const state = swipeStateRef.current;\n\n      if (!state) return;\n\n      const touch = findTouch(event.touches, state.touchIdentifier);\n\n      if (!touch) return;\n\n      state.currentX = touch.clientX;\n      state.currentY = touch.clientY;\n\n      const deltaX = state.currentX - state.startX;\n      const deltaY = state.currentY - state.startY;\n\n      if (\n        state.axis === \"pending\" &&\n        Math.max(Math.abs(deltaX), Math.abs(deltaY)) >=\n          resolvedDirectionLockThreshold\n      ) {\n        state.axis =\n          Math.abs(deltaX) > Math.abs(deltaY) * resolvedDirectionLockRatio\n            ? \"horizontal\"\n            : \"vertical\";\n      }\n\n      if (state.axis !== \"horizontal\") {\n        if (state.axis === \"vertical\") {\n          updateIntent(null);\n          updateProgress(null);\n        }\n        return;\n      }\n\n      event.preventDefault();\n\n      const direction = getDirection(deltaX);\n      const available = direction === \"previous\" ? hasPrevious : hasNext;\n      const resistance = available\n        ? resolvedFeedbackResistance\n        : edgeFeedbackResistance;\n      const maximumDistance = available\n        ? resolvedFeedbackDistance\n        : edgeFeedbackDistance;\n      const visualFeedbackX = clamp(\n        deltaX * resistance,\n        -maximumDistance,\n        maximumDistance,\n      );\n      const elapsed = Math.max(performance.now() - state.startedAt, 1);\n      const velocity = Math.abs(deltaX) / elapsed;\n      const feedbackX = state.reduceMotion ? 0 : visualFeedbackX;\n\n      updateIntent(direction);\n      updateProgress({\n        direction,\n        deltaX,\n        deltaY,\n        velocity,\n        progress: getDistanceProgress(deltaX, resolvedDistanceThreshold),\n        feedbackX,\n        available,\n        reduceMotion: state.reduceMotion,\n      });\n      if (feedbackEnabled && !state.reduceMotion) {\n        swipeElement.style.transform = `translate3d(${feedbackX}px, 0, 0)`;\n      }\n    }\n\n    function finishTouch(event: TouchEvent, cancelled: boolean) {\n      const state = swipeStateRef.current;\n\n      if (!state) return;\n\n      const touch = findTouch(event.changedTouches, state.touchIdentifier);\n\n      if (touch) {\n        state.currentX = touch.clientX;\n        state.currentY = touch.clientY;\n      }\n\n      const deltaX = state.currentX - state.startX;\n      const elapsed = Math.max(performance.now() - state.startedAt, 1);\n      const velocity = Math.abs(deltaX) / elapsed;\n      const direction = getDirection(deltaX);\n      const available = direction === \"previous\" ? hasPrevious : hasNext;\n      const navigates =\n        !cancelled &&\n        available &&\n        state.axis === \"horizontal\" &&\n        (Math.abs(deltaX) >= resolvedDistanceThreshold ||\n          (Math.abs(deltaX) >= resolvedDistanceThreshold / 2 &&\n            velocity >= resolvedVelocityThreshold));\n\n      suppressClickRef.current =\n        state.axis === \"horizontal\" &&\n        Math.abs(deltaX) >= resolvedDirectionLockThreshold;\n      swipeStateRef.current = null;\n      updateIntent(null);\n      updateProgress(null);\n\n      if (navigates) {\n        clearFeedbackStyles();\n\n        if (direction === \"previous\") {\n          callbacksRef.current.onPrevious();\n        } else {\n          callbacksRef.current.onNext();\n        }\n      } else if (state.axis === \"horizontal\") {\n        resetSwipePosition();\n      } else {\n        clearFeedbackStyles();\n      }\n\n      if (suppressClickRef.current) {\n        window.setTimeout(() => {\n          suppressClickRef.current = false;\n        }, 0);\n      }\n    }\n\n    function handleTouchEnd(event: TouchEvent) {\n      finishTouch(event, false);\n    }\n\n    function handleTouchCancel(event: TouchEvent) {\n      finishTouch(event, true);\n    }\n\n    function handleClick(event: MouseEvent) {\n      if (!suppressClickRef.current) return;\n\n      event.preventDefault();\n      event.stopPropagation();\n      suppressClickRef.current = false;\n    }\n\n    swipeElement.addEventListener(\"touchstart\", handleTouchStart, {\n      passive: true,\n    });\n    swipeElement.addEventListener(\"touchmove\", handleTouchMove, {\n      passive: false,\n    });\n    swipeElement.addEventListener(\"touchend\", handleTouchEnd);\n    swipeElement.addEventListener(\"touchcancel\", handleTouchCancel);\n    swipeElement.addEventListener(\"click\", handleClick, true);\n\n    return () => {\n      swipeElement.removeEventListener(\"touchstart\", handleTouchStart);\n      swipeElement.removeEventListener(\"touchmove\", handleTouchMove);\n      swipeElement.removeEventListener(\"touchend\", handleTouchEnd);\n      swipeElement.removeEventListener(\"touchcancel\", handleTouchCancel);\n      swipeElement.removeEventListener(\"click\", handleClick, true);\n      swipeStateRef.current = null;\n      updateIntent(null);\n      updateProgress(null);\n      clearResetTimeout();\n      clearFeedbackStyles(swipeElement);\n    };\n  }, [\n    clearFeedbackStyles,\n    clearResetTimeout,\n    edgeFeedbackDistance,\n    edgeFeedbackResistance,\n    disabled,\n    feedbackEnabled,\n    hasNext,\n    hasPrevious,\n    ignoreOwnedGestures,\n    resetSwipePosition,\n    resolvedDirectionLockThreshold,\n    resolvedDirectionLockRatio,\n    resolvedDistanceThreshold,\n    resolvedFeedbackDistance,\n    resolvedFeedbackResistance,\n    resolvedVelocityThreshold,\n  ]);\n\n  return elementRef;\n}\n\nfunction ownsHorizontalGesture(target: EventTarget | null, boundary: HTMLElement) {\n  if (!(target instanceof Element)) return false;\n\n  const ownedGestureTarget = target.closest(OWNED_GESTURE_SELECTOR);\n\n  if (ownedGestureTarget && boundary.contains(ownedGestureTarget)) return true;\n\n  let current: HTMLElement | null =\n    target instanceof HTMLElement ? target : target.parentElement;\n\n  while (current && current !== boundary) {\n    const style = window.getComputedStyle(current);\n    const ownsTouchGesture =\n      style.touchAction === \"none\" || style.touchAction.includes(\"pan-x\");\n    const scrollsHorizontally =\n      (style.overflowX === \"auto\" || style.overflowX === \"scroll\") &&\n      current.scrollWidth > current.clientWidth;\n\n    if (ownsTouchGesture || scrollsHorizontally) return true;\n\n    current = current.parentElement;\n  }\n\n  return false;\n}\n\nfunction findTouch(touches: TouchList, identifier: number) {\n  for (let index = 0; index < touches.length; index += 1) {\n    const touch = touches.item(index);\n\n    if (touch?.identifier === identifier) return touch;\n  }\n\n  return null;\n}\n\nfunction getDirection(deltaX: number): SwipeNavigationDirection {\n  return deltaX > 0 ? \"previous\" : \"next\";\n}\n\nfunction clamp(value: number, minimum: number, maximum: number) {\n  return Math.min(Math.max(value, minimum), maximum);\n}\n\nfunction nonNegative(value: number) {\n  return Math.max(value, 0);\n}\n\nfunction getDistanceProgress(deltaX: number, distanceThreshold: number) {\n  const distance = Math.abs(deltaX);\n\n  if (distanceThreshold <= 0) return distance > 0 ? 1 : 0;\n\n  return clamp(distance / distanceThreshold, 0, 1);\n}\n",
      "type": "registry:hook",
      "target": "@hooks/use-swipe-navigation.ts"
    }
  ],
  "meta": {
    "tags": [
      "swipe",
      "touch-gesture",
      "previous-next",
      "direction-lock",
      "velocity",
      "gesture-ownership",
      "gesture-progress",
      "custom-feedback"
    ],
    "effects": [
      "drag-feedback",
      "edge-resistance",
      "reduced-motion"
    ]
  },
  "categories": [
    "motion"
  ],
  "type": "registry:hook"
}