New: Activity Terrain +12 more

Slide to Confirm

A confirm control for payments and other actions you can't take back. Slide the thumb to the end, or flick it.

1 exampleDepends onlucide-reactmotionFrom KokonutUI (MIT)

Installation

Run this in your project. It adds the files, installs the npm packages and anything this builds on, and sets up the theme:

npx shadcn@latest add https://kryptonite-ui.vercel.app/r/slide-to-confirm.json
Install manually
  1. Install the dependencies:

    npm install lucide-react motion
  2. Copy this into components/kokonut/slide-to-confirm.tsx:

    slide-to-confirm.tsx
    "use client";
    
    /**
     * @author: @dorianbaffier
     * @description: Slide to Confirm
     * @version: 1.0.0
     * @date: 2026-10-21
     * @license: MIT
     * @website: https://kokonutui.com
     * @github: https://github.com/kokonut-labs/kokonutui
     */
    
    import { ArrowRight, Check } from "lucide-react";
    import {
      AnimatePresence,
      animate,
      motion,
      useMotionValue,
      useReducedMotion,
      useTransform,
    } from "motion/react";
    import {
      type MouseEvent,
      type PointerEvent,
      useEffect,
      useLayoutEffect,
      useRef,
      useState,
    } from "react";
    import { cn } from "@/lib/utils";
    
    const EASE_OUT = [0.23, 1, 0.32, 1] as const;
    const EASE_IN_OUT = [0.65, 0, 0.35, 1] as const;
    const HEIGHT = 56;
    const INSET = 4;
    const THUMB = HEIGHT - INSET * 2;
    /** Share of the track the thumb must cross before letting go commits. */
    const COMMIT_AT = 0.9;
    /** A flick this fast (px/s) commits from FLICK_FROM of the way along. */
    const FLICK_SPEED = 900;
    const FLICK_FROM = 0.45;
    /** How far past either end the thumb stretches, at most. */
    const OVERDRAG = 8;
    /** A press that moves less than this is a tap. */
    const TAP_SLOP = 4;
    
    type Phase = "idle" | "dragging" | "confirming" | "done";
    
    export interface SlideToConfirmProps {
      /** What sliding does, e.g. "Slide to pay $48.00". */
      label?: string;
      /** Read out while `onConfirm` runs. */
      confirmingLabel?: string;
      /** Shown once it's done, e.g. "Paid". */
      doneLabel?: string;
      /**
       * The real work. Return a promise to keep the spinner up until it settles;
       * a rejected promise sends the thumb back to the start.
       */
      onConfirm?: () => Promise<unknown> | unknown;
      /** Go back to the start this many ms after it's done. `null` stays done. */
      resetAfter?: number | null;
      disabled?: boolean;
      className?: string;
    }
    
    const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
    
    // Past the ends the thumb follows less and less, so it stretches instead of
    // stopping dead against the edge.
    function band(raw: number, max: number) {
      if (raw > max) {
        return max + OVERDRAG * (1 - Math.exp(-(raw - max) / 40));
      }
      if (raw < 0) {
        return -OVERDRAG * (1 - Math.exp(raw / 40));
      }
      return raw;
    }
    
    export default function SlideToConfirm({
      label = "Slide to pay $48.00",
      confirmingLabel = "Paying",
      doneLabel = "Paid",
      onConfirm = () => wait(1200),
      resetAfter = 2600,
      disabled = false,
      className,
    }: SlideToConfirmProps) {
      const [phase, setPhase] = useState<Phase>("idle");
      const [armed, setArmed] = useState(false);
      const reduceMotion = useReducedMotion();
    
      const trackRef = useRef<HTMLDivElement>(null);
      const dragRef = useRef({ pointer: 0, from: 0, moved: false });
      const runRef = useRef(0);
      const confirmRef = useRef(onConfirm);
      confirmRef.current = onConfirm;
    
      const x = useMotionValue(0);
      const width = useMotionValue(0);
      // 0: the ink ends at the thumb. 1: it fills the whole track, thumb and all.
      const cover = useMotionValue(0);
    
      const max = () => Math.max(0, width.get() - THUMB - INSET * 2);
    
      // The ink fill runs from the left edge to just past the thumb, so the label
      // turns white as the thumb wipes over it. Once committed it floods the rest.
      const fillClip = useTransform(
        [x, width, cover],
        ([value, total, covered]: number[]) =>
          `inset(0px ${(1 - covered) * Math.max(0, total - value - INSET - THUMB / 2)}px 0px 0px round ${HEIGHT}px)`
      );
      // Hidden at rest, so no ink shows round the thumb before it moves.
      const fillOpacity = useTransform(x, (value) => Math.min(1, value / 20));
      const labelOpacity = useTransform([x, width], ([value, total]: number[]) => {
        const progress = value / Math.max(1, total - THUMB - INSET * 2);
        return Math.max(0, 1 - progress * 0.55);
      });
    
      useLayoutEffect(() => {
        const track = trackRef.current;
        if (!track) {
          return;
        }
        const observer = new ResizeObserver(() => {
          const before = width.get();
          width.set(track.offsetWidth);
          // Keep a thumb that sits at the end at the end when the track resizes.
          if (before > 0 && x.get() >= before - THUMB - INSET * 2) {
            x.set(track.offsetWidth - THUMB - INSET * 2);
          }
        });
        observer.observe(track);
        return () => observer.disconnect();
      }, [width, x]);
    
      useEffect(
        () => () => {
          runRef.current += 1;
        },
        []
      );
    
      const spring = (velocity = 0) =>
        reduceMotion
          ? { duration: 0.15, ease: EASE_OUT }
          : { type: "spring" as const, stiffness: 520, damping: 42, velocity };
    
      // The ink drains back behind the thumb as it springs home.
      const settleBack = (velocity = 0) => {
        setArmed(false);
        setPhase("idle");
        animate(cover, 0, { duration: reduceMotion ? 0 : 0.3, ease: EASE_OUT });
        animate(x, 0, spring(velocity));
      };
    
      // The reset nobody asked for runs slower, so it reads as a rewind.
      const rewind = () => {
        setArmed(false);
        setPhase("idle");
        const duration = reduceMotion ? 0 : 0.6;
        animate(cover, 0, { duration: duration * 0.6, ease: EASE_OUT });
        animate(x, 0, { duration, ease: EASE_IN_OUT });
      };
    
      // One motion from release to result: the thumb carries on to the end while
      // the ink floods the track and the label turns to "Paying", and the work
      // starts at once instead of waiting for the thumb to land.
      const commit = async (velocity: number, glide = false) => {
        const run = ++runRef.current;
        const alive = () => runRef.current === run;
        setPhase("confirming");
        setArmed(true);
    
        const landing = animate(
          x,
          max(),
          glide && !reduceMotion
            ? { duration: 0.42, ease: EASE_IN_OUT }
            : spring(velocity)
        );
        const flooding = animate(cover, 1, {
          duration: reduceMotion ? 0 : 0.36,
          ease: EASE_OUT,
        });
    
        try {
          // A floor on the wait, so a fast handler doesn't flash the spinner.
          await Promise.all([confirmRef.current(), wait(800), landing, flooding]);
        } catch {
          if (alive()) {
            settleBack();
          }
          return;
        }
        if (!alive()) {
          return;
        }
        setPhase("done");
        if (resetAfter === null) {
          return;
        }
        await wait(resetAfter);
        if (alive()) {
          rewind();
        }
      };
    
      const onPointerDown = (event: PointerEvent<HTMLButtonElement>) => {
        if (disabled || phase !== "idle" || event.button !== 0) {
          return;
        }
        event.currentTarget.setPointerCapture(event.pointerId);
        x.stop();
        dragRef.current = { pointer: event.clientX, from: x.get(), moved: false };
        setPhase("dragging");
      };
    
      const onPointerMove = (event: PointerEvent<HTMLButtonElement>) => {
        if (phase !== "dragging") {
          return;
        }
        const drag = dragRef.current;
        const delta = event.clientX - drag.pointer;
        if (Math.abs(delta) > TAP_SLOP) {
          drag.moved = true;
        }
        const raw = drag.from + delta;
        x.set(band(raw, max()));
        const isArmed = raw >= max() * COMMIT_AT;
        if (isArmed !== armed) {
          setArmed(isArmed);
        }
      };
    
      const onPointerUp = () => {
        if (phase !== "dragging") {
          return;
        }
        if (!dragRef.current.moved) {
          setPhase("idle");
          return;
        }
        const velocity = x.getVelocity();
        const progress = x.get() / Math.max(1, max());
        const flicked = velocity > FLICK_SPEED && progress > FLICK_FROM;
        if (progress >= COMMIT_AT || flicked) {
          commit(velocity);
        } else {
          settleBack(velocity);
        }
      };
    
      const onClick = async (event: MouseEvent<HTMLButtonElement>) => {
        if (disabled || phase !== "idle") {
          return;
        }
        // Enter and Space click with no pointer, and confirm in one go.
        if (event.detail === 0) {
          commit(0, true);
          return;
        }
        if (dragRef.current.moved || reduceMotion) {
          return;
        }
        // A tap shows which way to go: a short nudge that springs back.
        await animate(x, 28, { duration: 0.18, ease: EASE_OUT });
        if (phase === "idle") {
          animate(x, 0, spring());
        }
      };
    
      const done = phase === "done";
      const busy = phase === "confirming";
      let stage = "slide";
      let stageLabel = label;
      if (busy) {
        stage = "busy";
        stageLabel = `${confirmingLabel}…`;
      } else if (done) {
        stage = "done";
        stageLabel = doneLabel;
      }
    
      return (
        <div
          className={cn("relative w-full max-w-sm select-none", className)}
          data-phase={phase}
        >
          <motion.div
            className={cn(
              "relative overflow-hidden rounded-full border",
              "border-zinc-200 bg-zinc-100 dark:border-zinc-800 dark:bg-zinc-900",
              disabled && "opacity-50"
            )}
            ref={trackRef}
            style={{ height: HEIGHT }}
          >
            {/* The label as it reads on the empty track. Nudged off the thumb by
                half its width, so it sits between the track and free-space
                centers and reads as centered. */}
            <motion.span
              aria-hidden
              className="absolute inset-0 flex items-center justify-center whitespace-nowrap pl-6 font-medium text-sm text-zinc-500 dark:text-zinc-400"
              style={{ opacity: labelOpacity }}
            >
              {label}
            </motion.span>
    
            {/* The ink the thumb leaves behind, with the label in white on it. It
                turns emerald in place when the work is done. */}
            <motion.div
              aria-hidden
              className={cn(
                "absolute inset-0 transition-colors duration-300 ease-out motion-reduce:transition-none",
                done
                  ? "bg-emerald-600 dark:bg-emerald-500"
                  : "bg-zinc-900 dark:bg-zinc-100"
              )}
              style={{ clipPath: fillClip, opacity: fillOpacity }}
            >
              <AnimatePresence initial={false} mode="popLayout">
                <motion.span
                  animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
                  className={cn(
                    "absolute inset-0 flex items-center justify-center gap-2 whitespace-nowrap font-medium text-sm",
                    done
                      ? "text-white dark:text-emerald-950"
                      : "text-white dark:text-zinc-900",
                    phase === "idle" || phase === "dragging" ? "pl-6" : "pr-6"
                  )}
                  exit={{ opacity: 0, y: -8, filter: "blur(2px)" }}
                  initial={{ opacity: 0, y: 8, filter: "blur(2px)" }}
                  key={stage}
                  transition={
                    reduceMotion
                      ? { duration: 0 }
                      : { duration: 0.26, ease: EASE_OUT }
                  }
                >
                  {stageLabel}
                </motion.span>
              </AnimatePresence>
            </motion.div>
    
            <motion.button
              aria-disabled={disabled || phase !== "idle"}
              aria-label={`${label}. Press Enter to confirm.`}
              className={cn(
                "absolute flex touch-none items-center justify-center rounded-full outline-none",
                "bg-white text-zinc-900 shadow-[0_1px_2px_rgb(0_0_0/0.12),0_0_0_1px_rgb(0_0_0/0.06)]",
                "dark:bg-zinc-800 dark:text-zinc-100 dark:shadow-[0_1px_2px_rgb(0_0_0/0.5),0_0_0_1px_rgb(255_255_255/0.08)]",
                "focus-visible:ring-2 focus-visible:ring-zinc-400 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900",
                phase === "idle" && !disabled && "cursor-grab",
                phase === "dragging" && "cursor-grabbing"
              )}
              onClick={onClick}
              onPointerCancel={onPointerUp}
              onPointerDown={onPointerDown}
              onPointerMove={onPointerMove}
              onPointerUp={onPointerUp}
              // Offsets are from inside the 1px border.
              style={{
                x,
                left: INSET - 1,
                top: INSET - 1,
                width: THUMB,
                height: THUMB,
              }}
              type="button"
            >
              <span className="relative size-5">
                <ArrowRight
                  aria-hidden
                  className={cn(
                    "absolute inset-0 size-5 transition-[opacity,translate,filter] duration-200 ease-out motion-reduce:transition-none",
                    armed && !busy && !done && "translate-x-0.5",
                    (busy || done) && "translate-x-1.5 opacity-0 blur-[2px]"
                  )}
                />
                <Check
                  aria-hidden
                  className={cn(
                    "absolute inset-0 size-5 text-emerald-600 transition-[opacity,scale,filter] duration-200 ease-out motion-reduce:transition-none dark:text-emerald-400",
                    done ? "scale-100 opacity-100" : "scale-50 opacity-0 blur-[2px]"
                  )}
                  strokeWidth={2.5}
                />
                <svg
                  aria-hidden
                  className={cn(
                    "absolute -inset-0.5 size-6 animate-spin transition-opacity duration-200 [animation-duration:700ms]",
                    // Paused, not removed, so it fades out without snapping back.
                    busy
                      ? "opacity-100 delay-100"
                      : "opacity-0 [animation-play-state:paused]"
                  )}
                  viewBox="0 0 24 24"
                >
                  <title>Working</title>
                  <circle
                    className="stroke-zinc-200 dark:stroke-zinc-700"
                    cx="12"
                    cy="12"
                    fill="none"
                    r="9"
                    strokeWidth="2.5"
                  />
                  <circle
                    className="stroke-current"
                    cx="12"
                    cy="12"
                    fill="none"
                    r="9"
                    strokeDasharray="14 43"
                    strokeLinecap="round"
                    strokeWidth="2.5"
                  />
                </svg>
              </span>
            </motion.button>
          </motion.div>
    
          <span aria-live="polite" className="sr-only">
            {busy && `${confirmingLabel}…`}
            {done && doneLabel}
          </span>
        </div>
      );
    }

Feedback

Questions, bugs or ideas for Slide to Confirm? Post them here, or reply to someone else. Sign in with GitHub to comment.