UNPKG

swipe-button

Version:

A lightweight, unstyled, and fully accessible swipe-to-action button for React. Built with zero dependencies.

425 lines (393 loc) 13.4 kB
// src/swipe-button.tsx import { forwardRef } from "react"; // src/styles.ts var defaultStyles = ` :root { --background: 0 0% 100%; --foreground: 222.2 84% 4.9%; --card: 0 0% 100%; --card-foreground: 222.2 84% 4.9%; --popover: 0 0% 100%; --popover-foreground: 222.2 84% 4.9%; --primary: 222.2 47.4% 11.2%; --primary-foreground: 210 40% 98%; --secondary: 210 40% 96.1%; --secondary-foreground: 222.2 47.4% 11.2%; --muted: 210 40% 96.1%; --muted-foreground: 215.4 16.3% 46.9%; --accent: 210 40% 96.1%; --accent-foreground: 222.2 47.4% 11.2%; --destructive: 0 84.2% 60.2%; --destructive-foreground: 210 40% 98%; --border: 214.3 31.8% 91.4%; --input: 214.3 31.8% 91.4%; --ring: 222.2 84% 4.9%; --radius: 0.5rem; } .dark { --background: 222.2 84% 4.9%; --foreground: 210 40% 98%; --card: 222.2 84% 4.9%; --card-foreground: 210 40% 98%; --popover: 222.2 84% 4.9%; --popover-foreground: 210 40% 98%; --primary: 210 40% 98%; --primary-foreground: 222.2 47.4% 11.2%; --secondary: 217.2 32.6% 17.5%; --secondary-foreground: 210 40% 98%; --muted: 217.2 32.6% 17.5%; --muted-foreground: 215 20.2% 65.1%; --accent: 217.2 32.6% 17.5%; --accent-foreground: 210 40% 98%; --destructive: 0 62.8% 30.6%; --destructive-foreground: 210 40% 98%; --border: 217.2 32.6% 17.5%; --input: 217.2 32.6% 17.5%; --ring: 212.7 26.8% 83.9%; } /* Structural Properties */ :root { --sw-height: 48px; --sw-slider-width: 48px; /* --- MODIFIED: Changed to a large value for a fully rounded pill shape --- */ --sw-border-radius: 9999px; --sw-font-size: 14px; --sw-transition-duration: 150ms; } .swipe-button__root { position: relative; display: flex; align-items: center; width: 100%; height: var(--sw-height); /* --- MODIFIED: Using secondary for the background track for better contrast --- */ background-color: hsl(var(--secondary)); border: 1px solid hsl(var(--border)); border-radius: var(--sw-border-radius); overflow: hidden; user-select: none; touch-action: none; font-family: sans-serif; color: hsl(var(--muted-foreground)); font-size: var(--sw-font-size); } .swipe-button__root[data-disabled='true'] { opacity: 0.5; cursor: not-allowed; } .swipe-button__rail { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; transition: opacity var(--sw-transition-duration) ease-in-out; } .swipe-button__root[data-swiping='true'] .swipe-button__rail { opacity: 0; } .swipe-button__overlay { position: absolute; inset-block: 0; left: 0; display: flex; align-items: center; justify-content: center; background-color: hsl(var(--primary)); color: hsl(var(--primary-foreground)); font-weight: 500; pointer-events: none; white-space:nowrap; } .swipe-button__root[data-reverse='true'] .swipe-button__overlay { left: auto; right: 0; } .swipe-button__slider { position: absolute; inset-block: 0; width: var(--sw-slider-width); height: 100%; /* --- MODIFIED: Using background for the slider to contrast with the track --- */ background-color: hsl(var(--background)); border-radius: var(--sw-border-radius); cursor: grab; display: flex; align-items: center; justify-content: center; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); } .swipe-button__slider:active { cursor: grabbing; } `; // src/SwipeContext.tsx import { createContext, useContext } from "react"; var SwipeContext = createContext(null); var useSwipeContext = () => { const context = useContext(SwipeContext); if (!context) { throw new Error( "SwipeButton components must be used within a SwipeButton.Root" ); } return context; }; // src/useSwipe.ts import { useState, useRef, useEffect, useCallback } from "react"; function useSwipe({ onSuccess, onFail, disabled = false, reverseSwipe = false, delta }) { const [isSwiping, setIsSwiping] = useState(false); const [sliderPosition, setSliderPosition] = useState(0); const [overlayWidth, setOverlayWidth] = useState(0); const [initialSliderPosition, setInitialSliderPosition] = useState(0); const [hasSucceeded, setHasSucceeded] = useState(false); const [progress, setProgress] = useState(0); const sliderRef = useRef(null); const containerRef = useRef(null); const isDragging = useRef(false); const startX = useRef(0); const positionRef = useRef(0); const calculateDimensions = useCallback(() => { const containerWidth = containerRef.current?.offsetWidth || 0; const sliderWidth = sliderRef.current?.offsetWidth || 0; const swipeableWidth = containerWidth - sliderWidth; return { containerWidth, sliderWidth, swipeableWidth }; }, []); const clampPosition = useCallback((pos, max) => Math.max(0, Math.min(pos, max)), []); const calculateProgress = useCallback((pos, sw, rev) => { if (sw === 0) return 0; return rev ? (sw - pos) / sw * 100 : pos / sw * 100; }, []); useEffect(() => { const { sliderWidth, swipeableWidth } = calculateDimensions(); const startPos = reverseSwipe ? swipeableWidth : 0; setInitialSliderPosition(startPos); setSliderPosition(startPos); positionRef.current = startPos; setOverlayWidth(sliderWidth / 2); setProgress(calculateProgress(startPos, swipeableWidth, reverseSwipe)); }, [reverseSwipe, calculateDimensions, calculateProgress]); const onSuccessRef = useRef(onSuccess); const onFailRef = useRef(onFail); const deltaRef = useRef(delta); const reverseSwipeRef = useRef(reverseSwipe); useEffect(() => { onSuccessRef.current = onSuccess; onFailRef.current = onFail; deltaRef.current = delta; reverseSwipeRef.current = reverseSwipe; }, [onSuccess, onFail, delta, reverseSwipe]); const handleDragging = useCallback((e) => { if (!isDragging.current) return; let currentX; if ("touches" in e) { const touch = e.touches[0]; if (!touch) return; currentX = touch.clientX; } else { currentX = e.clientX; } const displacement = currentX - startX.current; let newPosition = initialSliderPosition + displacement; const { containerWidth, sliderWidth, swipeableWidth } = calculateDimensions(); newPosition = clampPosition(newPosition, swipeableWidth); setSliderPosition(newPosition); positionRef.current = newPosition; const overlayWidthValue = reverseSwipeRef.current ? containerWidth - (newPosition + sliderWidth / 2) : newPosition + sliderWidth / 2; setOverlayWidth(overlayWidthValue); setProgress(calculateProgress(newPosition, swipeableWidth, reverseSwipeRef.current)); checkSuccess(newPosition, swipeableWidth); }, [initialSliderPosition, calculateDimensions, clampPosition, calculateProgress]); const checkSuccess = useCallback((currentPosition, swipeableWidth) => { const successThreshold = deltaRef.current ?? (reverseSwipeRef.current ? 0 : swipeableWidth); const isSuccess = reverseSwipeRef.current ? currentPosition <= successThreshold : currentPosition >= successThreshold; if (isSuccess) { const endPosition = reverseSwipeRef.current ? 0 : swipeableWidth; setSliderPosition(endPosition); positionRef.current = endPosition; setOverlayWidth(containerRef.current?.offsetWidth || 0); setProgress(100); onSuccessRef.current(); setHasSucceeded(true); isDragging.current = false; return true; } return false; }, []); const handleDragEnd = useCallback(() => { if (!isDragging.current) return; isDragging.current = false; const { swipeableWidth, sliderWidth } = calculateDimensions(); const didSucceed = checkSuccess(positionRef.current, swipeableWidth); if (!didSucceed) { onFailRef.current?.(); setSliderPosition(initialSliderPosition); positionRef.current = initialSliderPosition; setOverlayWidth(sliderWidth / 2); setProgress(calculateProgress(initialSliderPosition, swipeableWidth, reverseSwipeRef.current)); setIsSwiping(false); } window.removeEventListener("mousemove", handleDragging); window.removeEventListener("touchmove", handleDragging); window.removeEventListener("mouseup", handleDragEnd); window.removeEventListener("touchend", handleDragEnd); window.removeEventListener("touchcancel", handleDragEnd); }, [checkSuccess, initialSliderPosition, calculateDimensions, calculateProgress, handleDragging]); const handleDragStart = useCallback((e) => { if (disabled || hasSucceeded) return; isDragging.current = true; setIsSwiping(true); let clientX; if ("touches" in e) { clientX = e.touches[0]?.clientX ?? 0; } else { clientX = e.clientX; } startX.current = clientX; window.addEventListener("mousemove", handleDragging); window.addEventListener("touchmove", handleDragging); window.addEventListener("mouseup", handleDragEnd); window.addEventListener("touchend", handleDragEnd); window.addEventListener("touchcancel", handleDragEnd); }, [disabled, hasSucceeded, handleDragging, handleDragEnd]); const handleKeyDown = useCallback((e) => { if (disabled || hasSucceeded) return; const step = 10; const { containerWidth, sliderWidth, swipeableWidth } = calculateDimensions(); let newPosition = positionRef.current; if (e.key === "ArrowRight" || e.key === "ArrowUp") { newPosition += step; } else if (e.key === "ArrowLeft" || e.key === "ArrowDown") { newPosition -= step; } else { return; } newPosition = clampPosition(newPosition, swipeableWidth); setSliderPosition(newPosition); positionRef.current = newPosition; const overlayWidthValue = reverseSwipe ? containerWidth - (newPosition + sliderWidth / 2) : newPosition + sliderWidth / 2; setOverlayWidth(overlayWidthValue); setIsSwiping(true); setProgress(calculateProgress(newPosition, swipeableWidth, reverseSwipe)); checkSuccess(newPosition, swipeableWidth); if (!isDragging.current) { if (newPosition === positionRef.current) { handleDragEnd(); } } }, [disabled, hasSucceeded, checkSuccess, handleDragEnd, calculateDimensions, clampPosition, calculateProgress, reverseSwipe]); useEffect(() => { return () => { window.removeEventListener("mousemove", handleDragging); window.removeEventListener("touchmove", handleDragging); window.removeEventListener("mouseup", handleDragEnd); window.removeEventListener("touchend", handleDragEnd); window.removeEventListener("touchcancel", handleDragEnd); }; }, [handleDragging, handleDragEnd]); return { isSwiping, sliderPosition, overlayWidth, progress, disabled, reverseSwipe, containerRef, sliderRef, handleDragStart, handleKeyDown }; } // src/swipe-button.tsx import { jsx, jsxs } from "react/jsx-runtime"; var Root = forwardRef( ({ className, style, children, onSuccess, onFail, disabled = false, reverseSwipe = false, delta, ...props }, ref) => { const swipeValues = useSwipe({ onSuccess, onFail, disabled, reverseSwipe, delta }); return /* @__PURE__ */ jsxs(SwipeContext.Provider, { value: swipeValues, children: [ /* @__PURE__ */ jsx("style", { children: defaultStyles }), /* @__PURE__ */ jsx( "div", { ref: swipeValues.containerRef, className: `swipe-button__root ${className || ""}`, style, "data-disabled": disabled, "data-swiping": swipeValues.isSwiping, "data-reverse": reverseSwipe, ...props, children } ) ] }); } ); Root.displayName = "SwipeButton.Root"; var Rail = forwardRef( ({ className, ...props }, ref) => /* @__PURE__ */ jsx( "div", { ref, className: `swipe-button__rail ${className || ""}`, ...props } ) ); Rail.displayName = "SwipeButton.Rail"; var Overlay = forwardRef(({ className, style, children, ...props }, ref) => { const { overlayWidth, isSwiping } = useSwipeContext(); return /* @__PURE__ */ jsx( "div", { ref, className: `swipe-button__overlay ${className || ""}`, style: { width: `${overlayWidth}px`, ...style }, ...props, children: isSwiping ? children : null } ); }); Overlay.displayName = "SwipeButton.Overlay"; var Slider = forwardRef( ({ className, style, ...props }, ref) => { const { sliderPosition, handleDragStart, handleKeyDown, sliderRef, progress } = useSwipeContext(); return /* @__PURE__ */ jsx( "div", { ref: sliderRef, className: `swipe-button__slider ${className || ""}`, style: { transform: `translateX(${sliderPosition}px)`, ...style }, onMouseDown: handleDragStart, onTouchStart: handleDragStart, onKeyDown: handleKeyDown, role: "slider", "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": Math.round(progress), tabIndex: 0, "aria-label": "Swipe to confirm", ...props } ); } ); Slider.displayName = "SwipeButton.Slider"; var SwipeButton = { Root, Rail, Overlay, Slider }; export { SwipeButton };