Skip to content

Slide to Confirm

Drag the knob to the end to lock in a success state.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/slide-to-confirm.json

Manual

  1. Install the dependencies

    npm install clsx lucide-react motion tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/motion.ts
    import type { Transition, Variants } from "motion/react";
    
    /** Durations in seconds. */
    export const duration = {
      instant: 0.1,
      quick: 0.18,
      base: 0.28,
      slow: 0.45,
    } as const;
    
    /** Cubic-bezier curves. */
    export const ease = {
      /** Entering, UI response. */
      out: [0.22, 1, 0.36, 1],
      /** Moving on screen. */
      inOut: [0.65, 0, 0.35, 1],
      /** Sheets and the mobile menu. */
      drawer: [0.32, 0.72, 0, 1],
    } as const;
    
    /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */
    export const ui = {
      press: { scale: 0.96, duration: 0.12 },
      icon: { type: "spring", duration: 0.3, bounce: 0 },
      popover: { duration: 0.18, ease: ease.out },
      modal: { duration: 0.24, ease: ease.out },
      exitFactor: 0.6,
    } as const;
    
    /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */
    export const kinetic = {
      heroLetter: { duration: 0.6, ease: ease.out },
      /** Delay per letter, eased so the wave slows toward the end. */
      heroStep: 0.035,
      reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 },
      /** Two afterimages: lag in seconds and opacity of each. */
      ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] },
    } as const;
    
    /** Spring presets. */
    export const spring = {
      snappy: { type: "spring", stiffness: 500, damping: 30 },
      smooth: { type: "spring", stiffness: 260, damping: 26 },
      bouncy: { type: "spring", stiffness: 300, damping: 15 },
    } as const satisfies Record<string, Transition>;
    
    /** Stagger between siblings, in seconds (0.03–0.06). */
    export const stagger = {
      tight: 0.03,
      base: 0.045,
      loose: 0.06,
    } as const;
    
    /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */
    export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.base } },
          exit: { opacity: 0, transition: { duration: duration.quick } },
        };
      }
      return {
        hidden: { opacity: 0, y, filter: "blur(4px)" },
        visible: {
          opacity: 1,
          y: 0,
          filter: "blur(0px)",
          transition: { duration: duration.base, ease: ease.out },
        },
        exit: {
          opacity: 0,
          y: -y / 2,
          filter: "blur(4px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
      };
    }
    
    /** Scale 0.96 → 1 for popovers, menus and dialogs. */
    export function overlayVariants(reduceMotion?: boolean | null): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.quick } },
          exit: { opacity: 0, transition: { duration: duration.instant } },
        };
      }
      return {
        hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" },
        visible: {
          opacity: 1,
          scale: 1,
          filter: "blur(0px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
        exit: {
          opacity: 0,
          scale: 0.96,
          filter: "blur(2px)",
          transition: { duration: duration.instant + 0.02, ease: ease.out },
        },
      };
    }
    
    /** Parent variants that stagger their children in. */
    export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants {
      return {
        hidden: {},
        visible: { transition: { staggerChildren: gap, delayChildren } },
        exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } },
      };
    }
  3. Add the theme tokens

    This component uses --success, which shadcn does not define by default. Add it to your global CSS:

    app/globals.css
    :root {
      --success: #15803d;
    }
    
    .dark {
      --success: #4ade80;
    }
    
    @theme inline {
      --color-success: var(--success);
    }
  4. Copy the source

    components/ui/slide-to-confirm/slide-to-confirm.tsx
    "use client";
    
    import { useEffect, useId, useRef, useState, type ComponentProps, type ReactNode } from "react";
    import {
      AnimatePresence,
      animate,
      motion,
      useInView,
      useMotionValue,
      useReducedMotion,
      useTransform,
    } from "motion/react";
    import { Check, ChevronsRight } from "lucide-react";
    import { cn } from "@/lib/utils";
    import { duration, ease, spring } from "@/lib/motion";
    
    export interface SlideToConfirmProps
      extends Omit<ComponentProps<"div">, "children" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> {
      /** Called when the knob reaches the end, or when Enter or Space is pressed on it. */
      onConfirm?: () => void;
      /** Text in the track. @default "Slide to confirm" */
      label?: string;
      /** Text once confirmed. @default "Confirmed" */
      confirmedLabel?: string;
      /** Icon on the knob before confirming. @default <ChevronsRight /> */
      icon?: ReactNode;
      /** Fraction of the track the knob must pass before release confirms. @default 0.85 */
      threshold?: number;
      /** Milliseconds before resetting after a confirm. 0 keeps it locked. @default 0 */
      resetAfter?: number;
      /** Fill color while sliding and once confirmed. @default "success" */
      tone?: "success" | "primary" | "destructive";
      /** Disables dragging and keyboard confirmation. @default false */
      disabled?: boolean;
    }
    
    // Track padding and knob size in px (p-1 and size-12).
    const PAD = 4;
    const KNOB = 48;
    
    const fillClasses = {
      success: "bg-success",
      primary: "bg-primary",
      destructive: "bg-destructive",
    } as const;
    
    const toneText = {
      success: "text-success",
      primary: "text-primary",
      destructive: "text-destructive",
    } as const;
    
    export function SlideToConfirm({
      onConfirm,
      label = "Slide to confirm",
      confirmedLabel = "Confirmed",
      icon,
      threshold = 0.85,
      resetAfter = 0,
      tone = "success",
      disabled = false,
      className,
      ...props
    }: SlideToConfirmProps) {
      const trackRef = useRef<HTMLDivElement>(null);
      const confirmedRef = useRef(false);
      const onConfirmRef = useRef(onConfirm);
      const [confirmed, setConfirmed] = useState(false);
      const [maxDistance, setMaxDistance] = useState(0);
      const reduceMotion = useReducedMotion();
      const inView = useInView(trackRef);
      const hintId = useId();
    
      const x = useMotionValue(0);
      const max = useMotionValue(0);
      const fillClip = useTransform([x, max], ([v, m]: number[]) => `inset(0 ${m - v}px 0 0 round 9999px)`);
      const fillOpacity = useTransform(x, [0, 24], [0, 1]);
      const labelOpacity = useTransform([x, max], ([v, m]: number[]) => 1 - Math.min(1, v / Math.max(m * 0.5, 1)));
      const labelX = useTransform(x, (v) => v * 0.2);
    
      useEffect(() => {
        onConfirmRef.current = onConfirm;
      });
    
      // Track the travel distance so the knob stays inside the track at any width.
      useEffect(() => {
        const track = trackRef.current;
        if (!track) return;
        const measure = () => {
          const distance = Math.max(0, track.clientWidth - KNOB - PAD * 2);
          max.set(distance);
          setMaxDistance(distance);
          if (confirmedRef.current || x.get() > distance) x.set(distance);
        };
        measure();
        const observer = new ResizeObserver(measure);
        observer.observe(track);
        return () => observer.disconnect();
      }, [x, max]);
    
      useEffect(() => {
        if (!confirmed || resetAfter <= 0) return;
        const timer = window.setTimeout(() => {
          confirmedRef.current = false;
          setConfirmed(false);
          animate(x, 0, reduceMotion ? { duration: 0 } : spring.smooth);
        }, resetAfter);
        return () => window.clearTimeout(timer);
      }, [confirmed, resetAfter, reduceMotion, x]);
    
      function confirm() {
        if (confirmedRef.current || disabled) return;
        confirmedRef.current = true;
        setConfirmed(true);
        animate(x, max.get(), reduceMotion ? { duration: 0 } : spring.snappy);
        onConfirmRef.current?.();
      }
    
      return (
        <div
          ref={trackRef}
          className={cn(
            "relative h-14 w-full max-w-xs rounded-full border border-border bg-muted p-1 shadow-[inset_0_1px_2px_rgb(0_0_0/0.06)] select-none",
            disabled && "opacity-50",
            className,
          )}
          {...props}
        >
          <motion.div
            aria-hidden="true"
            style={{ clipPath: fillClip, opacity: fillOpacity }}
            className={cn("absolute inset-0 rounded-full", fillClasses[tone])}
          />
    
          <motion.span
            aria-hidden="true"
            style={{ opacity: labelOpacity, x: labelX }}
            className="pointer-events-none absolute inset-0 flex items-center justify-center pr-4 pl-14 text-sm font-medium text-foreground/65"
          >
            <span className="truncate">{label}</span>
          </motion.span>
          <motion.span
            aria-hidden="true"
            initial={false}
            animate={{ opacity: confirmed ? 1 : 0 }}
            transition={{ duration: duration.base, delay: confirmed ? 0.1 : 0 }}
            className="pointer-events-none absolute inset-0 flex items-center justify-center pr-14 pl-4 text-sm font-medium text-white"
          >
            <span className="truncate">{confirmedLabel}</span>
          </motion.span>
    
          <motion.button
            type="button"
            drag={confirmed || disabled ? false : "x"}
            dragConstraints={{ left: 0, right: maxDistance }}
            dragElastic={0.02}
            dragMomentum={false}
            onDragEnd={() => {
              if (x.get() >= max.get() * threshold) confirm();
              else animate(x, 0, reduceMotion ? { duration: 0 } : { type: "spring", stiffness: 420, damping: 32 });
            }}
            onKeyDown={(event) => {
              if (event.key === "Enter" || event.key === " ") {
                event.preventDefault();
                confirm();
              }
            }}
            whileTap={confirmed || disabled || reduceMotion ? undefined : { scale: 0.96 }}
            style={{ x }}
            disabled={disabled}
            aria-label={confirmed ? confirmedLabel : label}
            aria-describedby={confirmed ? undefined : hintId}
            aria-disabled={confirmed || undefined}
            className={cn(
              "absolute top-1 left-1 z-10 flex size-12 touch-none items-center justify-center rounded-full bg-background text-foreground outline-none",
              "shadow-[0_0_0_1px_rgb(0_0_0/0.04),0_1px_2px_rgb(0_0_0/0.1),0_4px_10px_-2px_rgb(0_0_0/0.16)]",
              "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
              confirmed ? "cursor-default" : "cursor-grab active:cursor-grabbing",
              "[&_svg]:size-5",
            )}
          >
            <AnimatePresence mode="popLayout" initial={false}>
              {confirmed ? (
                <motion.span
                  key="check"
                  initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.5, filter: "blur(4px)" }}
                  animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
                  exit={{ opacity: 0 }}
                  transition={reduceMotion ? { duration: duration.quick } : spring.bouncy}
                  className={toneText[tone]}
                >
                  <Check strokeWidth={2.75} aria-hidden="true" />
                </motion.span>
              ) : (
                <motion.span
                  key="icon"
                  initial={{ opacity: 0 }}
                  animate={!reduceMotion && inView ? { opacity: 1, x: [0, 3, 0] } : { opacity: 1, x: 0 }}
                  exit={{ opacity: 0 }}
                  transition={{
                    opacity: { duration: duration.quick },
                    x: { duration: 1, ease: ease.inOut, repeat: Infinity, repeatDelay: 1.6 },
                  }}
                  className="text-muted-foreground"
                >
                  {icon ?? <ChevronsRight aria-hidden="true" />}
                </motion.span>
              )}
            </AnimatePresence>
          </motion.button>
    
          <span id={hintId} className="sr-only">
            Drag the handle to the end of the track, or press Enter.
          </span>
          <span role="status" className="sr-only">
            {confirmed ? confirmedLabel : null}
          </span>
        </div>
      );
    }

Usage

import { SlideToConfirm } from "@/components/ui/slide-to-confirm/slide-to-confirm";

export function PayNow() {
  return <SlideToConfirm label="Slide to pay $49" confirmedLabel="Payment sent" onConfirm={() => pay()} />;
}

API reference

PropTypeDefaultDescription
onConfirm() => void–Called when the knob reaches the end, or when Enter or Space is pressed on it.
labelstring"Slide to confirm"Text in the track.
confirmedLabelstring"Confirmed"Text once confirmed.
iconReactNode<ChevronsRight />Icon on the knob before confirming.
thresholdnumber0.85Fraction of the track the knob must pass before release confirms.
resetAfternumber0Milliseconds before resetting after a confirm. 0 keeps it locked.
tone"success" | "primary" | "destructive""success"Fill color while sliding and once confirmed.
disabledbooleanfalseDisables dragging and keyboard confirmation.

Also accepts every native <div> attribute, including ref and className.

  • The fill is revealed with clip-path driven by the handle's position, so dragging stays on the compositor.
  • The track measures itself with a ResizeObserver, so it works at any width. Set the width with className.

Accessibility

  • The handle is a real button: Tab reaches it and Enter or Space confirms, so nobody has to drag.
  • The handle is described by a hint that explains both the drag and the keyboard shortcut.
  • A polite status region announces the confirmed label; afterwards the handle is aria-disabled.
  • Tapping the handle without dragging does nothing, which prevents accidental confirms on touch screens.
  • With reduced motion enabled, the handle snaps instead of springing and the idle nudge is removed.