Delete Button
A delete button that asks for confirmation in place, no dialog needed.
Live preview from the library's source
Install
From Rare UI
npx shadcn@latest add https://raw.githubusercontent.com/swamimalode07/rare-ui/main/public/r/delete-button.jsonFrom this collection
npx shadcn@latest add https://allcomps.vercel.app/r/rare-ui--delete-button.jsonCode
"use client";
import { useEffect, useRef, useState } from "react";
import type { ComponentProps, ReactNode } from "react";
import {
animate,
AnimatePresence,
motion,
useMotionTemplate,
useMotionValue,
useReducedMotion,
useTransform,
type Transition,
} from "motion/react";
import { cn } from "@/lib/utils";
const HINGE = "3px 6px";
const LID_OPEN = -35;
const WALL_TOP = 6;
const WALL_TOP_OPEN = 13.5;
const WALL_BASE = 20;
const TILE = 48;
const PANEL = 84;
const HOLD = { deleted: 1400, kept: 600 };
const EASE = [0.32, 0.72, 0, 1] as const;
const EASE_LID = [0.34, 1.1, 0.64, 1] as const;
const WIDTH = { duration: 0.62, ease: EASE } as const;
const LID = { duration: 0.6, ease: EASE_LID } as const;
const WALL = { duration: 0.56, ease: EASE } as const;
const IN = { duration: 0.44, ease: EASE, delay: 0.14 } as const;
const OUT = { duration: 0.3, ease: EASE } as const;
const TAP = { duration: 0.2, ease: EASE } as const;
const SWAP = { duration: 0.22, ease: EASE } as const;
const SETTLE = { duration: 0.45, ease: EASE } as const;
const PRESS = {
type: "spring",
stiffness: 520,
damping: 18,
mass: 0.5,
} as const;
const INSTANT = { duration: 0 } as const;
const SURFACE = "bg-[#F4F4F9] dark:bg-[#262626]";
const RECESS = "bg-[#E7E7EF] dark:bg-[#1B1B1B]";
const GLYPH = "text-[#868593] dark:text-[#9B9AA7]";
const FOCUS = "outline-none focus-visible:ring-2 focus-visible:ring-[#868593]";
const ACCENT = "#FF5F2E";
const LIFT =
"shadow-[0_0.5px_1px_rgba(0,0,0,0.05),0_1px_3px_rgba(0,0,0,0.08),inset_0_0.5px_0_rgba(255,255,255,0.9)] dark:shadow-[0_0.5px_1px_rgba(0,0,0,0.35),0_1.5px_4px_rgba(0,0,0,0.25),inset_0_0.5px_0_rgba(255,255,255,0.07)]";
const CIRCLE = `grid h-7 w-7 place-items-center rounded-full transition-colors duration-200 hover:bg-[#FAFAFD] dark:hover:bg-[#2C2C2C] ${FOCUS} ${SURFACE} ${LIFT}`;
const ICON = {
viewBox: "0 0 24 24",
fill: "none",
strokeLinecap: "round",
strokeLinejoin: "round",
"aria-hidden": true,
} as const;
const panelMotion = {
hidden: { opacity: 0, x: -6, transition: OUT },
shown: { opacity: 1, x: 0, transition: { ...IN, staggerChildren: 0.07 } },
};
const circleMotion = {
hidden: { opacity: 0, scale: 0.9, transition: OUT },
shown: { opacity: 1, scale: 1, transition: IN },
};
function Circle({
label,
onClick,
children,
}: {
label: string;
onClick: () => void;
children: ReactNode;
}) {
const reduced = useReducedMotion() ?? false;
return (
<motion.div className="flex" variants={reduced ? undefined : circleMotion}>
<motion.button
type="button"
aria-label={label}
onClick={onClick}
whileHover={reduced ? undefined : { scale: 1.03 }}
whileTap={reduced ? undefined : { scale: 0.84 }}
transition={PRESS}
className={CIRCLE}
>
<svg
{...ICON}
width="14"
height="14"
stroke="currentColor"
strokeWidth="3.5"
>
{children}
</svg>
</motion.button>
</motion.div>
);
}
type Status = "idle" | "deleted" | "kept";
export type DeleteButtonProps = Omit<
ComponentProps<"div">,
"onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"
> & {
onConfirm?: () => void;
onCancel?: () => void;
};
export function DeleteButton({
className,
onConfirm,
onCancel,
...props
}: DeleteButtonProps) {
const reduced = useReducedMotion() ?? false;
const [open, setOpen] = useState(false);
const [status, setStatus] = useState<Status>("idle");
const trigger = useRef<HTMLButtonElement>(null);
const timing = (transition: Transition) => (reduced ? INSTANT : transition);
const top = useMotionValue(WALL_TOP);
const wall = useTransform(top, (y) => WALL_BASE - y);
const bin = useMotionTemplate`M19 ${top}v${wall}a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V${top}`;
const settle = useMotionValue(1);
useEffect(() => {
const walls = animate(
top,
open ? WALL_TOP_OPEN : WALL_TOP,
reduced ? INSTANT : WALL,
);
return () => walls.stop();
}, [open, reduced, top]);
useEffect(() => {
if (status === "idle") return;
const nudge =
status === "kept" && !reduced
? animate(settle, [1, 0.86, 1], SETTLE)
: null;
const done = setTimeout(() => setStatus("idle"), HOLD[status]);
return () => {
nudge?.stop();
clearTimeout(done);
};
}, [status, reduced, settle]);
const resolve = (next: Exclude<Status, "idle">) => {
setOpen(false);
setStatus(next);
trigger.current?.focus();
(next === "deleted" ? onConfirm : onCancel)?.();
};
return (
<motion.div
data-slot="delete-button"
data-state={open ? "open" : "closed"}
data-status={status}
className={cn("relative h-12 rounded-2xl", SURFACE, GLYPH, className)}
animate={{ width: open ? TILE + PANEL : TILE }}
transition={timing(WIDTH)}
onKeyDown={(event) => {
if (event.key === "Escape" && open) resolve("kept");
}}
{...props}
>
<motion.button
ref={trigger}
type="button"
aria-label="Delete"
aria-expanded={open}
onClick={() => {
if (open) return resolve("kept");
setStatus("idle");
setOpen(true);
}}
whileTap={reduced ? undefined : { scale: 0.94 }}
transition={TAP}
className={cn(
"relative z-10 grid h-12 w-12 place-items-center rounded-2xl",
FOCUS,
)}
>
<AnimatePresence mode="wait" initial={false}>
{status === "deleted" ? (
<motion.svg
key="done"
{...ICON}
width="20"
height="20"
stroke={ACCENT}
strokeWidth="2.5"
initial={{ opacity: 0, scale: 0.6 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.6 }}
transition={timing(SWAP)}
>
<motion.path
d="M4 12.5 9.5 18 20 7"
initial={reduced ? undefined : { pathLength: 0 }}
animate={reduced ? undefined : { pathLength: 1 }}
transition={SETTLE}
/>
</motion.svg>
) : (
<motion.svg
key="bin"
{...ICON}
width="20"
height="20"
stroke="currentColor"
strokeWidth="2"
className="overflow-visible"
style={{ scale: settle }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={timing(SWAP)}
>
<motion.path d={bin} />
<motion.g
style={{ transformBox: "view-box", transformOrigin: HINGE }}
animate={{ rotate: open ? LID_OPEN : 0 }}
transition={timing(LID)}
>
<path d="M3 6h18" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</motion.g>
</motion.svg>
)}
</AnimatePresence>
</motion.button>
<span role="status" aria-live="polite" className="sr-only">
{status === "deleted" ? "Deleted" : status === "kept" ? "Kept" : ""}
</span>
<AnimatePresence>
{open && (
<motion.div
key="panel"
style={{ width: PANEL }}
className={cn(
"absolute inset-y-0 right-0 flex items-center justify-center gap-2 rounded-2xl",
RECESS,
)}
variants={reduced ? undefined : panelMotion}
initial="hidden"
animate="shown"
exit="hidden"
>
<span
aria-hidden
className={cn(
"absolute -left-1.25 top-1/2 z-20 h-2.5 w-1.5 -translate-y-1/2 [clip-path:polygon(100%_0,0_50%,100%_100%)]",
RECESS,
)}
/>
<Circle label="Confirm delete" onClick={() => resolve("deleted")}>
<path d="M4 12.5 9.5 18 20 7" stroke={ACCENT} />
</Circle>
<Circle label="Cancel" onClick={() => resolve("kept")}>
<path d="M6 6 18 18M18 6 6 18" />
</Circle>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
}
export default DeleteButton;
Other buttons
The same kind of component from the other libraries in the collection.