{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "use-swipe-navigation", "type": "registry:hook", "title": "useSwipeNavigation", "description": "Swipe between tabs with an axis-locked touch gesture that flips direction under RTL.", "categories": [ "hooks" ], "registryDependencies": [ "https://whiskeyjack.net/r/direction.json", "https://whiskeyjack.net/r/use-reduced-motion.json" ], "files": [ { "path": "hooks/use-swipe-navigation.ts", "type": "registry:hook", "target": "hooks/use-swipe-navigation.ts", "content": "import * as React from 'react'\nimport { useReducedMotion } from '@/hooks/use-reduced-motion'\nimport { isRTL } from '@/lib/direction'\n\nexport interface UseSwipeNavigationOptions {\n /** Total number of tabs/items. */\n count: number\n /** Index of the currently active item. */\n activeIndex: number\n /** Called when the user has committed a swipe. */\n onNavigate: (index: number) => void\n}\n\nexport interface UseSwipeNavigationResult {\n /** Horizontal translate offset while dragging, in pixels. */\n swipeOffset: number\n /** True while the snap-back or slide-in animation is running. */\n isAnimating: boolean\n /** Attach to the outer wrapper div's onTouchStart. */\n handleTouchStart: (e: React.TouchEvent) => void\n /** Attach to the outer wrapper div's onTouchMove. */\n handleTouchMove: (e: React.TouchEvent) => void\n /** Attach to the outer wrapper div's onTouchEnd. */\n handleTouchEnd: () => void\n /**\n * Spread onto the sliding content's `style`. Carries the drag transform, the\n * settle transition and the drag opacity fade, so the motion's feel lives\n * here rather than being restated at each call site.\n *\n * `willChange` is set only while a gesture is in flight; a permanent\n * `will-change-transform` class alongside this would keep the compositing\n * layer promoted at rest.\n */\n slideStyle: React.CSSProperties\n}\n\n/**\n * Swipe-to-switch-tabs touch logic. Extracted verbatim from the Chip Away and\n * Uradi Dashboard pages. Behavioural constants:\n *\n * - 8px direction lock threshold\n * - 0.25 edge dampening factor (when already at the first or last item)\n * - 60px commit threshold\n * - 0.4 opacity floor during swipe\n * - 0.97 scale floor during swipe (1 - |offset| / 4800)\n * - 120px slide-in offset for incoming content\n * - 300ms animation duration; a committed swipe's slide-in settles on the\n * spring curve (a few percent of overshoot, no wind-up), an uncommitted\n * snap-back on ease-out\n *\n * The touch handlers ignore swipes that begin on a `[data-tab-bar]` element so\n * the tab strip can still scroll horizontally without triggering navigation.\n *\n * Usage:\n *\n * ```tsx\n * const { slideStyle, handleTouchStart, handleTouchMove, handleTouchEnd } =\n * useSwipeNavigation({ count: items.length, activeIndex, onNavigate: setActiveIndex })\n *\n * = 2 ? handleTouchStart : undefined}\n * onTouchMove={items.length >= 2 ? handleTouchMove : undefined}\n * onTouchEnd={items.length >= 2 ? handleTouchEnd : undefined}\n * >\n * \n *
{content}
\n * \n * ```\n *\n * `swipeOffset` and `isAnimating` stay available for a consumer that needs to\n * react to the gesture rather than render it. Rendering a transform, transition\n * and opacity out of them by hand is what `slideStyle` replaces.\n */\nexport function useSwipeNavigation({\n count,\n activeIndex,\n onNavigate,\n}: UseSwipeNavigationOptions): UseSwipeNavigationResult {\n const reduced = useReducedMotion()\n const touchStartX = React.useRef(null)\n const touchStartY = React.useRef(null)\n const [swipeOffset, setSwipeOffset] = React.useState(0)\n const [isAnimating, setIsAnimating] = React.useState(false)\n const swipeLocked = React.useRef<'horizontal' | 'vertical' | null>(null)\n const slideDirection = React.useRef<'left' | 'right' | null>(null)\n // Which settle the running animation is: a committed swipe's slide-in takes\n // the spring curve, an uncommitted snap-back the plain ease-out.\n const enterSettle = React.useRef(false)\n\n // Keep a stable ref to the latest activeIndex so the effect below reads the\n // current value without needing it as a dep (prevents re-registering on\n // every navigation).\n const activeIndexRef = React.useRef(activeIndex)\n React.useEffect(() => {\n activeIndexRef.current = activeIndex\n }, [activeIndex])\n\n const handleTouchStart = React.useCallback((e: React.TouchEvent) => {\n if (isAnimating) return\n // Don't capture swipes that start on the tab bar (let it scroll horizontally)\n if ((e.target as HTMLElement).closest('[data-tab-bar]')) return\n touchStartX.current = e.touches[0].clientX\n touchStartY.current = e.touches[0].clientY\n swipeLocked.current = null\n }, [isAnimating])\n\n const handleTouchMove = React.useCallback((e: React.TouchEvent) => {\n if (touchStartX.current === null || touchStartY.current === null) return\n\n const dx = e.touches[0].clientX - touchStartX.current\n const dy = e.touches[0].clientY - touchStartY.current\n\n // Lock direction after a small threshold\n if (swipeLocked.current === null && (Math.abs(dx) > 8 || Math.abs(dy) > 8)) {\n swipeLocked.current = Math.abs(dx) > Math.abs(dy) ? 'horizontal' : 'vertical'\n }\n\n if (swipeLocked.current !== 'horizontal') return\n\n // Reading-direction displacement: in RTL a rightward drag advances (next),\n // so flip the sign for the edge / commit decisions. The visual offset stays\n // physical (`dx`) so the content tracks the finger either way.\n const rd = isRTL() ? -dx : dx\n\n // Resist at edges (no prev / no next), in reading order.\n const atStart = activeIndexRef.current === 0 && rd > 0\n const atEnd = activeIndexRef.current === count - 1 && rd < 0\n const dampened = (atStart || atEnd) ? dx * 0.25 : dx\n\n setSwipeOffset(dampened)\n }, [count])\n\n const handleTouchEnd = React.useCallback(() => {\n if (touchStartX.current === null || swipeLocked.current !== 'horizontal') {\n touchStartX.current = null\n touchStartY.current = null\n swipeLocked.current = null\n setSwipeOffset(0)\n return\n }\n\n touchStartX.current = null\n touchStartY.current = null\n swipeLocked.current = null\n\n const threshold = 60\n const currentIndex = activeIndexRef.current\n const rtl = isRTL()\n\n setSwipeOffset(prev => {\n // Reading-direction offset: RTL flips which physical drag means next/prev.\n const rd = rtl ? -prev : prev\n const didSwipe =\n (rd < -threshold && currentIndex < count - 1) ||\n (rd > threshold && currentIndex > 0)\n\n if (didSwipe) {\n // The incoming content slides in following the PHYSICAL drag (so the\n // gesture reads continuously in either direction), hence `prev` here.\n slideDirection.current = prev < 0 ? 'left' : 'right'\n const nextIndex = rd < 0 ? currentIndex + 1 : currentIndex - 1\n onNavigate(nextIndex)\n return prev\n } else {\n // Snap back -- animate only when the user has no reduced-motion preference\n if (!reduced) {\n enterSettle.current = false\n setIsAnimating(true)\n setTimeout(() => setIsAnimating(false), 300)\n }\n return 0\n }\n })\n }, [count, onNavigate, reduced])\n\n // When activeIndex changes via swipe, slide new content in from the side it\n // was pulled from. This MUST be a layout effect: the commit that swaps in\n // the new tab content still carries the drag offset, and a plain useEffect\n // runs after paint -- the new content would flash for a frame at the old\n // drag position (the WRONG side) before being repositioned. With a\n // synchronous activeIndex source (e.g. useState-driven settings tabs) that\n // frame is reliably visible; useLayoutEffect repositions before paint.\n React.useLayoutEffect(() => {\n if (slideDirection.current) {\n const dir = slideDirection.current\n slideDirection.current = null\n if (reduced) {\n // Skip the slide-in animation -- jump straight to the final state.\n setSwipeOffset(0)\n setIsAnimating(false)\n } else {\n const entryFrom = dir === 'left' ? 120 : -120\n setSwipeOffset(entryFrom)\n setIsAnimating(false)\n // Next frame: animate to 0\n requestAnimationFrame(() => {\n enterSettle.current = true\n setIsAnimating(true)\n setSwipeOffset(0)\n setTimeout(() => setIsAnimating(false), 300)\n })\n }\n } else {\n setSwipeOffset(0)\n }\n }, [activeIndex, reduced])\n\n // Reads the duration and easing from tokens rather than a literal, so the\n // settle matches every other transition in the system. Reduced motion never\n // reaches here: the slide-in above jumps straight to the final offset.\n // The pane scales down slightly as it slides (0.97 floor), so a committed\n // swipe's incoming content grows into place; the spring curve's slight\n // overshoot is what makes it settle alive.\n const slideStyle = React.useMemo(\n () => ({\n transform:\n swipeOffset !== 0\n ? `translateX(${swipeOffset}px) scale(${Math.max(0.97, 1 - Math.abs(swipeOffset) / 4800)})`\n : undefined,\n transition: isAnimating\n ? `transform var(--duration-300) var(--easing-${enterSettle.current ? 'spring' : 'out'})`\n : undefined,\n opacity:\n swipeOffset !== 0 ? Math.max(0.4, 1 - Math.abs(swipeOffset) / 400) : undefined,\n willChange: swipeOffset !== 0 || isAnimating ? 'transform' : undefined,\n }),\n [swipeOffset, isAnimating],\n )\n\n return {\n swipeOffset,\n isAnimating,\n slideStyle,\n handleTouchStart,\n handleTouchMove,\n handleTouchEnd,\n }\n}\n" } ], "docs": "Any TabBar should be paired with this so tabbed content swipes. Attach the handlers to a touch-pan-y wrapper only when count >= 2. The wrapper must be viewport-sized (min-h-[calc(100dvh-6rem)] md:min-h-0), since a content-height wrapper silently ignores swipes below short content.", "meta": { "group": "hooks", "related": [ "tab-bar", "use-pull-to-refresh", "direction" ], "exports": [ "useSwipeNavigation" ], "siteSlug": "use-swipe-navigation" } }