Slide To Confirm Button
Free Buttons React component — SlideToConfirmButton. MIT licensed, copy-paste ready for Next.js and Tailwind CSS.
Drag the knob across the track to commit an action — snaps back if you release early, locks green when confirmed. Great for irreversible or high-intent actions.
Preview
Setup
How to use
Free for personal and commercial use. No UI attribution required. Include the MIT copyright notice when copying component source into your project. Read the MIT license.
- 1
Run in your terminal:
$npm install clsx tailwind-merge lucide-react - 2
Copy
lib/cn.tsbelow. Skip if you already havecn(). - 3
Copy the code below and create
components/buttons/slide-to-confirm-button.tsxin your project. - 4
Import and render:
Exampleimport { SlideToConfirmButton } from "@/components/buttons/slide-to-confirm-button";
lib/cn.ts
import { type ClassValue, clsx } from "clsx";import { twMerge } from "tailwind-merge";export function cn(...inputs: ClassValue[]) {return twMerge(clsx(inputs));}
components/buttons/slide-to-confirm-button.tsx
"use client";import {forwardRef,useRef,useState,type ComponentPropsWithoutRef,type PointerEvent as ReactPointerEvent,} from "react";import { cn } from "@/lib/cn";import { ArrowRight, Check } from "lucide-react";const PAD = 4;const KNOB = 48;export type SlideToConfirmButtonProps = Readonly<{label?: string;confirmedLabel?: string;onConfirm?: () => void;} & Omit<ComponentPropsWithoutRef<"div">, "onConfirm">>;// Slide-to-confirm — drag the knob across the track to commit an action.export const SlideToConfirmButton = forwardRef<HTMLDivElement,SlideToConfirmButtonProps>(({className,label = "Slide to confirm",confirmedLabel = "Confirmed",onConfirm,...props},ref,) => {const trackRef = useRef<HTMLDivElement>(null);const draggingRef = useRef(false);const [x, setX] = useState(0);const [confirmed, setConfirmed] = useState(false);const maxX = () => {const track = trackRef.current;if (!track) return 0;return track.offsetWidth - KNOB - PAD * 2;};const handleDown = (event: ReactPointerEvent<HTMLButtonElement>) => {if (confirmed) return;draggingRef.current = true;event.currentTarget.setPointerCapture(event.pointerId);};const handleMove = (event: ReactPointerEvent<HTMLButtonElement>) => {if (!draggingRef.current || confirmed) return;const track = trackRef.current;if (!track) return;const rect = track.getBoundingClientRect();const next = Math.min(Math.max(event.clientX - rect.left - PAD - KNOB / 2, 0),maxX(),);setX(next);};const handleUp = () => {if (!draggingRef.current) return;draggingRef.current = false;if (x >= maxX() - 4) {setX(maxX());setConfirmed(true);onConfirm?.();} else {setX(0);}};const progress = maxX() > 0 ? x / maxX() : 0;return (<divref={ref}data-slot="slide-to-confirm-button"className="font-sans select-none"{...props}><divref={trackRef}className={cn(// transform-gpu + isolate promote the track to its own compositor// layer so the knob's moving shadow can't leave stale ghost pixels."relative h-14 w-72 transform-gpu isolate overflow-hidden rounded-full p-1 transition-[background-color,box-shadow] duration-300","shadow-[inset_0_1px_2px_rgba(0,0,0,0.08),inset_0_2px_4px_rgba(0,0,0,0.05),inset_0_-2px_3px_rgba(0,0,0,0.06),0_1px_0_rgba(255,255,255,0.9)]",confirmed? "bg-emerald-600 shadow-[inset_0_1px_3px_rgba(0,0,0,0.2),inset_0_-2px_3px_rgba(0,0,0,0.12),0_1px_0_rgba(255,255,255,0.9)]": "bg-neutral-100",className,)}><spanaria-hiddenclassName={cn("absolute inset-0 flex items-center justify-center text-sm font-medium transition-colors duration-200",confirmed? "text-white [text-shadow:0_1px_1px_rgba(0,0,0,0.25)]": "text-neutral-500 [text-shadow:0_1px_0_rgba(255,255,255,0.8)]",)}style={{ opacity: confirmed ? 1 : 1 - progress * 1.4 }}>{confirmed ? confirmedLabel : label}</span><buttontype="button"aria-label={label}disabled={confirmed}onPointerDown={handleDown}onPointerMove={handleMove}onPointerUp={handleUp}onPointerCancel={handleUp}className={cn(// will-change keeps the knob on its own compositor layer so the// moving box-shadow doesn't leave stale ghost pixels on the track."absolute top-1 left-1 flex size-12 touch-none items-center justify-center rounded-full bg-neutral-50 text-neutral-700 will-change-transform",// Keycap material like the quantity stepper, but with a tight,// downward-only shadow so the knob sits in the groove instead of// floating in a soft halo."shadow-[0_1px_1px_rgba(0,0,0,0.12),0_2px_3px_rgba(0,0,0,0.12),inset_0_1.5px_0_rgba(255,255,255,1),inset_0_-2px_3px_rgba(0,0,0,0.1)]",confirmed? "cursor-default": "cursor-grab active:cursor-grabbing active:bg-neutral-100 active:shadow-[0_1px_1px_rgba(0,0,0,0.06),inset_0_1px_1px_rgba(0,0,0,0.06),inset_0_2px_3px_rgba(0,0,0,0.03),inset_0_-2px_3px_rgba(0,0,0,0.05)]",draggingRef.current? "transition-[box-shadow,background-color] duration-200 ease-out": "transition-[transform,box-shadow,background-color] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)]",)}style={{ transform: `translate3d(${x}px, 0, 0)` }}>{confirmed ? (<Checksize={18}strokeWidth={2.5}aria-hiddenclassName="text-emerald-600 filter-[drop-shadow(0_1px_0_rgba(255,255,255,0.9))_drop-shadow(0_-1px_0.5px_rgba(0,0,0,0.12))]"/>) : (<ArrowRightsize={18}strokeWidth={2.5}aria-hiddenclassName="filter-[drop-shadow(0_1px_0_rgba(255,255,255,0.9))_drop-shadow(0_-1px_0.5px_rgba(0,0,0,0.12))]"/>)}</button></div></div>);},);SlideToConfirmButton.displayName = "SlideToConfirmButton";