"use client";
import * as React from "react";
import { animate, motion, useMotionValue, useTransform, type PanInfo } from "motion/react";
export interface SwipeToConfirmProps
extends Omit<
React.ComponentPropsWithoutRef<"div">,
"children" | "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart" | "onAnimationEnd"
> {
/** Hint shown in the track; also the accessible name of the slider. */
label?: string;
/** Text shown and announced once confirmed. */
confirmedLabel?: string;
/** Fraction of the track the knob must pass before release confirms (0.5–0.95). */
threshold?: number;
/** Reset to the start this many ms after confirming. 0 keeps the confirmed state. */
resetDelay?: number;
/** Fill and confirmed colour. */
accent?: string;
/** Text / icon colour on top of the accent. */
inkColor?: string;
/** Animated shine across the hint text. */
shimmer?: boolean;
disabled?: boolean;
/** Called once when the swipe completes. */
onConfirm?: () => void;
/** Called when the control returns to its start after a confirmation. */
onReset?: () => void;
}
const TRACK = 56; // px — track height
const PAD = 4; // px — inset of the knob inside the track
const KNOB = TRACK - 2 - PAD * 2; // px — knob size (track has a 1px border)
const KEY_STEP = 0.1;
const PAGE_STEP = 0.25;
function subscribeReducedMotion(cb: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", cb);
return () => mq.removeEventListener("change", cb);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/**
* Slide-to-confirm: drag the knob across the track to confirm. Releasing before the threshold
* springs it back. The knob is a role="slider": arrow keys move it in 10% steps and reaching
* the end confirms, so the control works without a pointer.
*/
export function SwipeToConfirm({
label = "Slide to confirm",
confirmedLabel = "Confirmed",
threshold = 0.8,
resetDelay = 2500,
accent = "#FF7448",
inkColor = "#0B0D0F",
shimmer = true,
disabled = false,
onConfirm,
onReset,
className = "",
style,
...props
}: SwipeToConfirmProps) {
const reduceMotion = usePrefersReducedMotion();
const hintId = React.useId();
const trackRef = React.useRef<HTMLDivElement>(null);
const hintRef = React.useRef<HTMLSpanElement>(null);
const resetTimer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
/** Where the knob is heading (0–1), so rapid key presses build on the target, not the mid-spring position. */
const target = React.useRef(0);
const [maxX, setMaxX] = React.useState(0);
const [confirmed, setConfirmed] = React.useState(false);
/** Progress exposed to assistive technology (updated on release / key press, not per frame). */
const [percent, setPercent] = React.useState(0);
const [announcement, setAnnouncement] = React.useState("");
const x = useMotionValue(0);
const range = useMotionValue(1);
const fillWidth = useTransform(x, (v) => v + KNOB);
const hintOpacity = useTransform([x, range], ([v, r]: number[]) => Math.max(0, 1 - v / (r * 0.55)));
const arrowOpacity = useTransform([x, range], ([v, r]: number[]) => Math.max(0, 1 - v / (r * 0.25)));
const spring = reduceMotion ? { duration: 0 } : { type: "spring" as const, stiffness: 480, damping: 38 };
const clampedThreshold = Math.min(0.95, Math.max(0.5, threshold));
// Measure the travel distance; keep the knob at the end if the track resizes while confirmed.
React.useEffect(() => {
const track = trackRef.current;
if (!track) return;
const measure = () => {
const travel = Math.max(0, track.clientWidth - PAD * 2 - KNOB);
range.set(Math.max(1, travel));
setMaxX(travel);
if (track.dataset.state === "confirmed" || x.get() > travel) x.set(travel);
};
const ro = new ResizeObserver(measure);
ro.observe(track);
return () => ro.disconnect();
}, [x, range]);
React.useEffect(() => () => clearTimeout(resetTimer.current), []);
// Shine sweeping across the hint. Paused offscreen, in hidden tabs and with reduced motion.
React.useEffect(() => {
const node = hintRef.current;
if (!node || !shimmer || reduceMotion || confirmed || disabled || typeof node.animate !== "function") return;
const anim = node.animate([{ backgroundPosition: "100% 0" }, { backgroundPosition: "0% 0" }], {
duration: 2400,
iterations: Infinity,
easing: "ease-in-out",
});
let visible = true;
const sync = () => (visible && !document.hidden ? anim.play() : anim.pause());
const io = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
sync();
});
io.observe(node);
document.addEventListener("visibilitychange", sync);
return () => {
io.disconnect();
document.removeEventListener("visibilitychange", sync);
anim.cancel();
};
}, [shimmer, reduceMotion, confirmed, disabled]);
const moveTo = (fraction: number) => {
const f = Math.min(1, Math.max(0, fraction));
target.current = f;
animate(x, f * maxX, spring);
setPercent(Math.round(f * 100));
};
const confirm = () => {
if (confirmed) return;
target.current = 1;
animate(x, maxX, spring);
setConfirmed(true);
setPercent(100);
setAnnouncement(confirmedLabel);
onConfirm?.();
if (resetDelay > 0) {
clearTimeout(resetTimer.current);
resetTimer.current = setTimeout(() => {
setConfirmed(false);
setAnnouncement("");
moveTo(0);
onReset?.();
}, resetDelay);
}
};
const handleDragEnd = (_: PointerEvent | MouseEvent | TouchEvent, info: PanInfo) => {
const progress = maxX > 0 ? x.get() / maxX : 0;
// A confident flick past the middle counts too.
if (progress >= clampedThreshold || (progress > 0.45 && info.velocity.x > 900)) confirm();
else moveTo(0);
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (disabled || confirmed) return;
const current = target.current;
let next: number | null = null;
if (event.key === "ArrowRight" || event.key === "ArrowUp") next = current + KEY_STEP;
else if (event.key === "ArrowLeft" || event.key === "ArrowDown") next = current - KEY_STEP;
else if (event.key === "PageUp") next = current + PAGE_STEP;
else if (event.key === "PageDown") next = current - PAGE_STEP;
else if (event.key === "Home") next = 0;
if (next === null) return;
event.preventDefault();
// Snap to the 10% grid so repeated presses land exactly on 100%.
next = Math.round(next * 100) / 100;
if (next >= 0.999) confirm();
else moveTo(next);
};
const interactive = !disabled && !confirmed;
return (
<div
className={`relative w-full min-w-[220px] select-none ${disabled ? "opacity-50" : ""} ${className}`}
style={{ ["--stc-accent" as string]: accent, ["--stc-ink" as string]: inkColor, ...style }}
{...props}
>
<div
ref={trackRef}
data-state={confirmed ? "confirmed" : "idle"}
className={`relative overflow-hidden rounded-full border transition-colors duration-300 ${confirmed ? "border-transparent bg-[var(--stc-accent)]" : "border-zinc-900/12 bg-zinc-900/[0.04] dark:border-white/10 dark:bg-white/[0.04]"}`}
style={{ height: TRACK }}
>
{/* Fill that trails the knob. */}
<motion.div
aria-hidden="true"
className="absolute rounded-full bg-[color-mix(in_oklab,var(--stc-accent)_22%,transparent)]"
style={{ left: PAD, top: PAD, height: KNOB, width: fillWidth }}
/>
{/* Hint text with an optional shine; fades as the knob travels. */}
<motion.span
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 right-0 flex items-center justify-center pr-4 text-sm font-medium tracking-tight"
style={{ left: KNOB + PAD, opacity: confirmed ? 0 : hintOpacity }}
>
<span
ref={hintRef}
className={`[--stc-shine:#18181b] [--stc-text:#71717a] dark:[--stc-shine:#ffffff] dark:[--stc-text:#a1a1aa] ${shimmer && !reduceMotion ? "bg-clip-text text-transparent" : "text-zinc-500 dark:text-zinc-400"}`}
style={
shimmer && !reduceMotion
? {
backgroundImage:
"linear-gradient(100deg, var(--stc-text) 0%, var(--stc-text) 40%, var(--stc-shine) 50%, var(--stc-text) 60%, var(--stc-text) 100%)",
backgroundSize: "250% 100%",
backgroundPosition: "100% 0",
}
: undefined
}
>
{label}
</span>
</motion.span>
{/* Confirmed label, centred over the filled track. */}
<span
aria-hidden="true"
className={`pointer-events-none absolute inset-0 flex items-center justify-center font-mono text-[11px] uppercase tracking-[0.16em] text-[var(--stc-ink)] transition-opacity duration-300 ${confirmed ? "opacity-100 delay-150" : "opacity-0"}`}
style={{ paddingRight: KNOB }}
>
{confirmedLabel}
</span>
<motion.div
role="slider"
tabIndex={disabled ? -1 : 0}
aria-label={label}
aria-describedby={hintId}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={percent}
aria-valuetext={confirmed ? confirmedLabel : `${percent}%`}
aria-disabled={!interactive || undefined}
drag={interactive && maxX > 0 ? "x" : false}
dragConstraints={{ left: 0, right: maxX }}
dragElastic={{ left: 0, right: 0.04 }}
dragMomentum={false}
onDragEnd={handleDragEnd}
onKeyDown={handleKeyDown}
onBlur={() => {
// A partial keyboard swipe does not survive leaving the control.
if (!confirmed && x.get() > 0) moveTo(0);
}}
whileTap={interactive && !reduceMotion ? { scale: 0.96 } : undefined}
className={`absolute grid place-items-center rounded-full shadow-[0_1px_2px_rgb(0_0_0/0.12),0_4px_12px_-4px_rgb(0_0_0/0.25)] outline-none transition-[background-color,color] duration-300 focus-visible:ring-2 focus-visible:ring-[var(--stc-accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-[#131619] ${confirmed ? "bg-[var(--stc-ink)] text-[var(--stc-accent)]" : "bg-white text-zinc-900 dark:bg-zinc-100"} ${interactive ? "cursor-grab active:cursor-grabbing" : ""}`}
style={{ x, left: PAD, top: PAD, width: KNOB, height: KNOB, touchAction: "pan-y" }}
>
{confirmed ? (
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="size-4">
<motion.path
d="M3.5 8.5l3 3 6-7"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
initial={{ pathLength: reduceMotion ? 1 : 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.35, delay: 0.1, ease: [0.22, 1, 0.36, 1] }}
/>
</svg>
) : (
<motion.svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="size-4" style={{ opacity: arrowOpacity }}>
<path d="M3 8h9M8.5 4.5 12 8l-3.5 3.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</motion.svg>
)}
</motion.div>
</div>
<span id={hintId} className="sr-only">
Drag the handle to the end, or press the right arrow key until it reaches 100%, to confirm.
</span>
<span role="status" aria-live="polite" className="sr-only">
{announcement}
</span>
</div>
);
}