import React, { useCallback, useEffect, useMemo, useRef, useState, } from "react"; /** * Configuration options for the carousel swipe behavior */ export interface CarouselSwipeConfig { /** Total number of items in the carousel */ itemCount: number; /** Percentage of card width to offset each slide (default: 105) */ cardOffsetPercentage?: number; /** Percentage of container width needed to trigger slide change (default: 0.15) */ swipeThreshold?: number; /** Mobile breakpoint in pixels (default: 768) */ mobileBreakpoint?: number; /** Auto-scroll interval in milliseconds (default: 8000). Set to 0 to disable. */ autoScrollInterval?: number; /** Enable auto-scroll (default: true) */ enableAutoScroll?: boolean; } /** * Return value from the useCarouselSwipe hook */ export interface CarouselSwipeReturn { /** Current active slide index */ currentIndex: number; /** Current swipe offset in pixels */ swipeOffset: number; /** Whether user is currently swiping */ isSwiping: boolean; /** Whether viewport is mobile size */ isMobile: boolean; /** Memoized container width */ containerWidth: number; /** Ref to attach to the carousel container element */ containerRef: React.RefObject; /** Navigate to next slide */ nextSlide: () => void; /** Navigate to previous slide */ prevSlide: () => void; /** Navigate to specific slide index */ goToSlide: (index: number) => void; /** Touch start handler */ handleTouchStart: (e: React.TouchEvent) => void; /** Touch move handler */ handleTouchMove: (e: React.TouchEvent) => void; /** Touch end handler */ handleTouchEnd: () => void; /** Constants used for calculations */ constants: { CARD_OFFSET_PERCENTAGE: number; SWIPE_THRESHOLD: number; MOBILE_BREAKPOINT: number; AUTO_SCROLL_INTERVAL: number; }; } /** * Custom hook for implementing swipe/touch gestures in carousels * * Features: * - Touch/swipe support with smooth finger-following * - Auto-scroll with pause on interaction * - Responsive mobile detection with resize listener * - Performance optimized with memoization * - Configurable thresholds and behavior * * @example * ```tsx * const carousel = useCarouselSwipe({ * itemCount: items.length, * autoScrollInterval: 5000, * }); * * return ( *
* {items.map((item, index) => ( *
* {item} *
* ))} *
* ); * ``` */ export function useCarouselSwipe( config: CarouselSwipeConfig ): CarouselSwipeReturn { const { itemCount, cardOffsetPercentage = 105, swipeThreshold = 0.15, mobileBreakpoint = 768, autoScrollInterval = 8000, enableAutoScroll = true, } = config; // State const [currentIndex, setCurrentIndex] = useState(0); const [swipeOffset, setSwipeOffset] = useState(0); const [isSwiping, setIsSwiping] = useState(false); const [containerWidth, setContainerWidth] = useState( typeof window !== "undefined" ? window.innerWidth : 1, ); // Performance: Store mobile state to avoid repeated window.innerWidth checks on every render // This prevents expensive DOM queries during swipe operations const [isMobile, setIsMobile] = useState(false); // Refs const timeoutRef = useRef | null>(null); const touchStartX = useRef(0); const containerRef = useRef(null); // Constants const constants = { CARD_OFFSET_PERCENTAGE: cardOffsetPercentage, SWIPE_THRESHOLD: swipeThreshold, MOBILE_BREAKPOINT: mobileBreakpoint, AUTO_SCROLL_INTERVAL: autoScrollInterval, }; // Performance: Memoize container width to prevent recalculation on every render // This is especially important during swipe operations where the component re-renders // frequently. Without memoization, it'd query the DOM on every frame while swiping. useEffect(() => { const updateWidth = () => { setContainerWidth(containerRef.current?.offsetWidth || window.innerWidth); }; updateWidth(); // Initial window.addEventListener("resize", updateWidth); return () => window.removeEventListener("resize", updateWidth); }, []); // Navigation functions const nextSlide = useCallback(() => { if (itemCount === 0) return; setCurrentIndex(prev => (prev + 1) % itemCount); }, [itemCount]); const prevSlide = useCallback(() => { if (itemCount === 0) return; setCurrentIndex(prev => (prev === 0 ? itemCount - 1 : prev - 1)); }, [itemCount]); const goToSlide = useCallback( (index: number) => { if (index < 0 || index >= itemCount) return; setCurrentIndex(index); }, [itemCount] ); // Touch handlers for mobile swipe const handleTouchStart = useCallback((e: React.TouchEvent) => { touchStartX.current = e.touches[0].clientX; setIsSwiping(true); // Pause auto-scroll during user interaction if (timeoutRef.current) { clearInterval(timeoutRef.current); } }, []); const handleTouchMove = useCallback( (e: React.TouchEvent) => { if (!isSwiping) return; const currentX = e.touches[0].clientX; const diff = currentX - touchStartX.current; setSwipeOffset(diff); }, [isSwiping] ); const handleTouchEnd = useCallback(() => { setIsSwiping(false); // Use memoized containerWidth and constant threshold for performance const threshold = containerWidth * swipeThreshold; // Determine if swipe was strong enough to change slides if (swipeOffset > threshold) { prevSlide(); } else if (swipeOffset < -threshold) { nextSlide(); } // Reset swipe offset to return card to snapped position setSwipeOffset(0); // Restart auto-scroll after user interaction completes if (enableAutoScroll && autoScrollInterval > 0) { timeoutRef.current = setInterval(() => { nextSlide(); }, autoScrollInterval); } }, [ swipeOffset, containerWidth, swipeThreshold, prevSlide, nextSlide, enableAutoScroll, autoScrollInterval, ]); // Performance: Detect mobile viewport and update on resize // This replaces inline window.innerWidth checks that were running on every render. // By using state and a resize listener, we only check when the viewport actually changes. useEffect(() => { const checkMobile = () => { setIsMobile(window.innerWidth < mobileBreakpoint); }; // Check immediately on mount checkMobile(); // Update when window is resized (e.g., device rotation, browser resize) window.addEventListener("resize", checkMobile); return () => window.removeEventListener("resize", checkMobile); }, [mobileBreakpoint]); // Auto-scroll logic useEffect(() => { if (!enableAutoScroll || itemCount === 0 || autoScrollInterval === 0) { return; } timeoutRef.current = setInterval(() => { nextSlide(); }, autoScrollInterval); return () => { if (timeoutRef.current) clearInterval(timeoutRef.current); }; }, [nextSlide, itemCount, enableAutoScroll, autoScrollInterval]); return { currentIndex, swipeOffset, isSwiping, isMobile, containerWidth, containerRef, nextSlide, prevSlide, goToSlide, handleTouchStart, handleTouchMove, handleTouchEnd, constants, }; }