# Drag Button

> An interactive draggable button requiring a left-to-right swipe to confirm actions, featuring optional success animations and spring physics.

A shadcn/ui block from the [@grootstudio](https://grootstudio.vercel.app) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/grootstudio/drag-button).

- Page, with a live preview: https://shadcn.exchange/r/grootstudio/drag-button
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/grootstudio/drag-button-light-d43c7da0394077e3.png
- Registry item: https://grootstudio.vercel.app/r/drag-button.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://grootstudio.vercel.app/r/drag-button.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`, `motion`
- Registry items: `button`

## Files

### `registry/radix-nova/drag-button/drag-button.tsx`

```tsx
"use client";

import React from "react";
import { Button } from "@/components/ui/button";
import { ArrowRight, Check } from "lucide-react";
import { cn } from "@/lib/utils";
import { motion, useMotionValue, useTransform, AnimatePresence } from "motion/react";

export interface DragButtonProps
    extends Omit<React.ComponentProps<typeof Button>, "variant"> {
    children: React.ReactNode;
    onDragComplete?: () => void; // callback function to be called when the drag is completed
    showConfirmation?: boolean; // whether to show the confirmation animation
    variant?: keyof typeof BOX_SHADOW; // "default" | "outline"
    className?: string;
    successIcon?: React.ReactNode; // optional icon to be shown when the drag is completed
    colorLight?: string; // light color of gradient drag-handle(use hex color code)
    colorDark?: string; // dark color of gradient drag-handle(use hex color code)
}

const DEFAULT_COLORS = {
    light: "#f5f56b",
    dark: "#cc0066"
} as const;

const HANDLE_SIZE = 40;
const GAP_PX = 4;

const SPRING_TRANSITION = { type: "spring", stiffness: 600, damping: 40 } as const;

const SHIMMER_STYLE = {
    background:
        "linear-gradient(180deg,rgba(255,255,255,0.4) 0%,rgba(255,255,255,0) 80%,transparent 100%)",
    filter: "blur(0.5px)",
} as const;

const BOX_SHADOW = {
    default:
        "inset 0 2px 3px 0 rgba(255,255,255,0.15), inset 0 -3px 6px 0 rgba(0,0,0,0.4), inset 0 0 0 1px rgba(255,255,255,0.1)",
    outline:
        "inset 0 2px 4px 0 rgba(0,0,0,0.12), inset 0 -2px 2px 0 rgba(255,255,255,0.3), inset 0 0 0 1px rgba(0,0,0,0.06)",
} as const;

export const DragButton = React.forwardRef<
    HTMLButtonElement,
    DragButtonProps
>(
    (
        {
            children,
            className,
            variant = "default",
            onDragComplete,
            showConfirmation = true,
            successIcon = <Check className="size-5" />,
            colorLight = DEFAULT_COLORS.light,
            colorDark = DEFAULT_COLORS.dark,
            ...props
        },
        externalRef
    ) => {
        const [isSuccess, setIsSuccess] = React.useState(false);
        const [travelDistance, setTravelDistance] = React.useState(0);
        const dragX = useMotionValue(0);
        const textOpacity = useTransform(dragX, [0, travelDistance], [1, 0.1]);

        const gradient = `linear-gradient(to top, ${colorLight}, ${colorDark})`;

        const iconStyle = {
            background: gradient,
            boxShadow: `0 2px 8px 0 ${colorDark}59, 0 1.5px 0 0 rgba(255,255,255,0.25) inset, 0 -2px 8px 0 ${colorDark}80 inset, 0 0 0 1px rgba(0,0,0,0.08)`,
        };

        const innerShadowStyle = {
            boxShadow: `0 0 0 1px rgba(255,255,255,0.15) inset, 0 1.5px 0 0 rgba(255,255,255,0.2) inset, 0 -2px 4px 0 ${colorDark}33 inset`,
        };

        const containerRef = React.useRef<HTMLButtonElement | null>(null);

        const mergedRef = React.useCallback(
            (node: HTMLButtonElement | null) => {
                containerRef.current = node;
                if (typeof externalRef === "function") externalRef(node);
                else if (externalRef)
                    (
                        externalRef as React.MutableRefObject<HTMLButtonElement | null>
                    ).current = node;

                if (!node) return;

                const measure = () => {
                    setTravelDistance(node.offsetWidth - HANDLE_SIZE - GAP_PX * 2.5);
                };

                measure();
                const ro = new ResizeObserver(measure);
                ro.observe(node);
            },
            [externalRef]
        );

        const handleDragEnd = () => {
            if (dragX.get() >= travelDistance - 5) {
                if (showConfirmation) {
                    setIsSuccess(true);
                    onDragComplete?.();

                    setTimeout(() => {
                        setIsSuccess(false);
                        dragX.set(0);
                    }, 1500);
                } else {
                    onDragComplete?.();
                    dragX.set(0);
                }
            } else {
                dragX.set(0);
            }
        };

        return (
            <Button
                ref={mergedRef}
                variant={variant}
                className={cn(
                    "relative h-12 min-w-[200px] cursor-grab overflow-hidden rounded-xl p-1 text-sm font-medium transition-all duration-300 flex items-center select-none",
                    isSuccess && "pointer-events-none",
                    className
                )}
                {...props}
            >
                {/* Progress bar background */}
                <motion.div
                    className="absolute left-1 top-1 bottom-1 bg-white/10 rounded-lg pointer-events-none z-0"
                    style={{ width: useTransform(dragX, (v) => v + HANDLE_SIZE) }}
                />

                {/* Background text */}
                <motion.span
                    style={{ opacity: textOpacity }}
                    className="relative z-10 mx-auto transition-all duration-500 text-shadow-black/10 text-shadow-lg pointer-events-none pl-8"
                >
                    {children}
                </motion.span>

                {/* Draggable Handle */}
                <motion.div
                    drag={!isSuccess ? "x" : false}
                    dragConstraints={{ left: 0, right: travelDistance }}
                    dragElastic={0}
                    dragMomentum={false}
                    onDragEnd={handleDragEnd}
                    style={{
                        x: dragX,
                        ...iconStyle,
                        zIndex: 40,
                        width: HANDLE_SIZE,
                        height: HANDLE_SIZE,
                        left: 4,
                        position: "absolute",
                        borderRadius: "10px",
                        top: 0,
                        bottom: 0,
                        marginTop: "auto",
                        marginBottom: "auto",
                    }}
                    animate={{
                        opacity: isSuccess ? 0 : 1,
                        scale: isSuccess ? 0.8 : 1,
                    }}
                    transition={SPRING_TRANSITION}
                    className={cn(
                        "flex items-center justify-center text-white cursor-grab active:cursor-grabbing",
                        isSuccess && "cursor-default"
                    )}
                >
                    <span
                        className="pointer-events-none absolute left-1/2 top-0 z-20 h-2/5 w-[80%] -translate-x-1/2 rounded-t-[inherit]"
                        style={SHIMMER_STYLE}
                    />
                    <span
                        className="pointer-events-none absolute inset-0 z-0 rounded-[inherit]"
                        style={innerShadowStyle}
                    />
                    <ArrowRight size={20} className="drop-shadow-sm relative z-30" />
                </motion.div>

                {/* Success Overlay */}
                <AnimatePresence>
                    {isSuccess && (
                        <motion.div
                            initial={{ scale: 0, opacity: 0 }}
                            animate={{ scale: 1, opacity: 1 }}
                            exit={{ scale: 0, opacity: 0 }}
                            transition={{ ...SPRING_TRANSITION, bounce: 0.1 }}
                            className="absolute inset-0 z-50 flex items-center justify-center text-white shadow-lg"
                            style={{
                                background: gradient,
                                borderRadius: "8px"
                            }}
                        >
                            <motion.div
                                initial={{ scale: 0.5, rotate: -45, opacity: 0 }}
                                animate={{ scale: 1, rotate: 0, opacity: 1 }}
                                transition={{ delay: 0.1, type: "spring", stiffness: 300, damping: 20 }}
                            >
                                {successIcon}
                            </motion.div>
                        </motion.div>
                    )}
                </AnimatePresence>
            </Button>
        );
    }
);

DragButton.displayName = "DragButton";
```
