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
JavaScript
// 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
};