/Click feedback
Click feedback • React
Hold to Delete Button
A delete button that only fires when held: an orange bar fills linearly from left to right as a matching glow builds behind it, snapping back if you let go early and landing on a check with a pulse once it completes.
Installation
Requires lucide-react for its icons. The shadcn CLI installs it for you; copying the code by hand means installing it yourself.
Terminal
npx shadcn@latest add @microkit/hold-to-delete-buttonCode
1"use client";23import { Check, Trash2 } from "lucide-react";4import { useEffect, useState } from "react";5import type { KeyboardEvent, PointerEvent } from "react";67type DeleteState = "idle" | "holding" | "deleted";89const HOLD_MS = 1600;10const RESET_MS = 1800;1112const pulseKeyframes = `13@keyframes hold-delete-pulse {14from { outline: 1px solid rgba(249, 115, 22, .9); outline-offset: 0; }15to { outline: 1px solid rgba(249, 115, 22, 0); outline-offset: 12px; }16}`;1718function ButtonContent({ deleted }: { deleted: boolean }) {19return (20<span className="inline-flex items-center gap-[10px]">21<span className="grid shrink-0" aria-hidden="true">22<Trash223className="[grid-area:1/1] [transition:opacity_.24s_ease,transform_.32s_cubic-bezier(.16,1,.3,1)] data-[active=false]:opacity-0 data-[active=false]:[transform:scale(.5)] motion-reduce:transition-none"24data-active={!deleted}25size={20}26strokeWidth={2}27/>28<Check29className="[grid-area:1/1] [transition:opacity_.24s_ease,transform_.32s_cubic-bezier(.16,1,.3,1)] data-[active=false]:opacity-0 data-[active=false]:[transform:scale(.5)] motion-reduce:transition-none"30data-active={deleted}31size={20}32strokeWidth={2.4}33/>34</span>35<span className="grid">36<span37className="justify-self-start whitespace-nowrap [grid-area:1/1] [transition:opacity_.24s_ease,transform_.32s_cubic-bezier(.16,1,.3,1),filter_.24s_ease,visibility_.24s] data-[active=false]:invisible data-[active=false]:opacity-0 data-[active=false]:[transform:translateY(6px)] data-[active=false]:[filter:blur(2px)] motion-reduce:transition-none"38data-active={!deleted}39>40Hold to delete41</span>42<span43className="justify-self-start whitespace-nowrap [grid-area:1/1] [transition:opacity_.24s_ease,transform_.32s_cubic-bezier(.16,1,.3,1),filter_.24s_ease,visibility_.24s] data-[active=false]:invisible data-[active=false]:opacity-0 data-[active=false]:[transform:translateY(6px)] data-[active=false]:[filter:blur(2px)] motion-reduce:transition-none"44data-active={deleted}45>46Deleted47</span>48</span>49</span>50);51}5253export function HoldToDeleteButton() {54const [state, setState] = useState<DeleteState>("idle");5556useEffect(() => {57if (state === "idle") return;58const timeout = window.setTimeout(59() => setState(state === "holding" ? "deleted" : "idle"),60state === "holding" ? HOLD_MS : RESET_MS,61);62return () => window.clearTimeout(timeout);63}, [state]);6465const start = () => setState((current) => (current === "idle" ? "holding" : current));66const cancel = () => setState((current) => (current === "holding" ? "idle" : current));6768const handlePointerDown = (event: PointerEvent<HTMLButtonElement>) => {69if (event.button === 0) start();70};7172const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {73if (event.key !== " " && event.key !== "Enter") return;74event.preventDefault();75if (!event.repeat) start();76};7778const handleKeyUp = (event: KeyboardEvent<HTMLButtonElement>) => {79if (event.key === " " || event.key === "Enter") cancel();80};8182return (83<>84<style>{pulseKeyframes}</style>85<button86type="button"87className="group relative inline-flex h-[52px] cursor-pointer appearance-none items-center justify-center overflow-hidden rounded-full border-0 bg-[light-dark(#ffffff,#121418)] px-[24px] [font-family:inherit] text-[17px] font-medium leading-none tracking-[-.01em] text-[light-dark(#14181e,#f2f4f6)] [box-shadow:inset_0_0_0_1px_light-dark(#dfe3ea,#2b2f36),0_10px_24px_light-dark(rgba(246,247,249,.35),rgba(0,0,0,.35)),0_8px_28px_rgba(249,115,22,0)] data-[state=holding]:[box-shadow:inset_0_0_0_1px_light-dark(#dfe3ea,#2b2f36),0_10px_24px_light-dark(rgba(246,247,249,.35),rgba(0,0,0,.35)),0_8px_28px_rgba(249,115,22,.55)] data-[state=deleted]:[box-shadow:inset_0_0_0_1px_light-dark(#dfe3ea,#2b2f36),0_10px_24px_light-dark(rgba(246,247,249,.35),rgba(0,0,0,.35)),0_8px_28px_rgba(249,115,22,.55)] select-none [-webkit-touch-callout:none] touch-manipulation [transition:transform_.25s_cubic-bezier(.16,1,.3,1),background-color_.2s_ease,box-shadow_.5s_cubic-bezier(.16,1,.3,1)] data-[state=holding]:[transition:transform_.25s_cubic-bezier(.16,1,.3,1),background-color_.2s_ease,box-shadow_1.6s_linear] hover:data-[state=idle]:bg-[light-dark(#f0f2f5,#1b1e23)] motion-safe:data-[state=holding]:[transform:scale(.97)] data-[state=deleted]:cursor-default motion-safe:data-[state=deleted]:[animation:hold-delete-pulse_.8s_cubic-bezier(.16,1,.3,1)] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#f97316] motion-reduce:data-[state=idle]:[transition:none] motion-reduce:data-[state=deleted]:[transition:none] motion-reduce:data-[state=holding]:[transition:box-shadow_1.6s_linear]"88data-state={state}89onPointerDown={handlePointerDown}90onPointerUp={cancel}91onPointerLeave={cancel}92onPointerCancel={cancel}93onKeyDown={handleKeyDown}94onKeyUp={handleKeyUp}95onBlur={cancel}96onContextMenu={(event) => event.preventDefault()}97>98<ButtonContent deleted={state === "deleted"} />99<span100className="pointer-events-none absolute inset-0 flex items-center justify-center bg-[#f97316] text-white [clip-path:inset(0_100%_0_0)] [transition:clip-path_.5s_cubic-bezier(.16,1,.3,1)] group-data-[state=holding]:[clip-path:inset(0)] group-data-[state=holding]:[transition:clip-path_1.6s_linear] group-data-[state=deleted]:[clip-path:inset(0)] motion-reduce:group-data-[state=idle]:[transition:none] motion-reduce:group-data-[state=deleted]:[transition:none]"101aria-hidden="true"102>103<ButtonContent deleted={state === "deleted"} />104</span>105</button>106</>107);108}