Free kit: animated pieces + 8 AI prompts → https://qodesign.dev/free?utm_source=devto&utm_medium=tutorial&utm_campaign=tutorial8&utm_content=top
Every dashboard you generate with Cursor, v0 or Lovable has a delete button somewhere. Usually it's a red button that opens an "Are you sure?" modal, and after the third time people click "Yes" without reading it.
A hold-to-delete button fixes that with less UI. You press and hold, a fill sweeps across, and the delete only happens if you keep holding to the end. Let go early and it drains back. Nobody deletes a file by accident, and there's no modal.
This week I went through the most-favorited buttons on uiverse.io. The top ones all do the same kind of thing: a round icon button stretches into a pill and something new appears. None of the top 15 is just a color change. So this button does both. At rest it's a small round trash icon. Hover or focus it and it stretches into a pill that says "Hold to delete". Press and hold and it fills red while the bin lid tips open.
It's one React component with Tailwind classes. No Framer Motion, no extra package. I tested it today with React 19.1 and Tailwind 4.3 in Chromium, and the code below is the code that ran.
The whole component
Drop this in components/HoldToDelete.tsx:
"use client";
import { useEffect, useRef, useState } from "react";
const HOLD_MS = 1200; // how long you have to hold
const DRAIN_SPEED = 2; // letting go drains twice as fast as it fills
type Phase = "idle" | "holding" | "draining" | "done";
const labels: Record<Phase, string> = {
idle: "Hold to delete",
holding: "Keep holding",
draining: "Hold to delete",
done: "Deleted",
};
export function HoldToDelete({ onConfirm }: { onConfirm: () => void }) {
const [phase, setPhase] = useState<Phase>("idle");
const buttonRef = useRef<HTMLButtonElement>(null);
const progress = useRef(0); // 0 to 1
const holding = useRef(false);
const frame = useRef<number | null>(null);
const lastTime = useRef(0);
function paint() {
buttonRef.current?.style.setProperty("--progress", String(progress.current));
}
function tick(now: number) {
const dt = now - lastTime.current;
lastTime.current = now;
const step = dt / HOLD_MS;
progress.current = holding.current
? Math.min(1, progress.current + step)
: Math.max(0, progress.current - step * DRAIN_SPEED);
paint();
if (progress.current >= 1) {
frame.current = null;
holding.current = false;
setPhase("done");
onConfirm();
return;
}
if (progress.current <= 0 && !holding.current) {
frame.current = null;
setPhase("idle");
return;
}
frame.current = requestAnimationFrame(tick);
}
function startLoop() {
if (frame.current !== null) return;
lastTime.current = performance.now();
frame.current = requestAnimationFrame(tick);
}
function press() {
if (phase === "done") return;
holding.current = true;
setPhase("holding");
startLoop();
}
function release() {
if (!holding.current) return;
holding.current = false;
setPhase("draining");
startLoop();
}
// After "Deleted", shrink back to the icon. Your list will usually
// unmount the row before this runs.
useEffect(() => {
if (phase !== "done") return;
const t = setTimeout(() => {
progress.current = 0;
paint();
setPhase("idle");
}, 1600);
return () => clearTimeout(t);
}, [phase]);
useEffect(() => () => {
if (frame.current !== null) cancelAnimationFrame(frame.current);
}, []);
return (
<button
ref={buttonRef}
type="button"
data-phase={phase}
aria-label={labels[phase]}
onPointerDown={(e) => {
if (e.button !== 0) return;
press();
}}
onPointerUp={release}
onPointerLeave={release}
onPointerCancel={release}
onKeyDown={(e) => {
if ((e.key === " " || e.key === "Enter") && !e.repeat) {
e.preventDefault();
press();
}
}}
onKeyUp={(e) => {
if (e.key === " " || e.key === "Enter") release();
}}
onBlur={release}
onContextMenu={(e) => e.preventDefault()}
className="group relative inline-flex h-11 w-11 select-none items-center overflow-hidden rounded-full bg-zinc-900 text-zinc-100 ring-1 ring-white/10 transition-[width,background-color] duration-300 ease-[cubic-bezier(0.2,0.9,0.2,1)] hover:w-40 focus-visible:w-40 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500 data-[phase=holding]:w-40 data-[phase=draining]:w-40 data-[phase=done]:w-40 data-[phase=done]:bg-red-600 motion-reduce:transition-none [-webkit-touch-callout:none]"
>
{/* the red fill, driven by --progress */}
<span
aria-hidden
className="absolute inset-0 origin-left scale-x-[var(--progress,0)] bg-red-600"
/>
<span className="relative grid size-11 shrink-0 place-items-center">
<svg viewBox="0 0 24 24" className="size-5" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
{/* the lid tips open while you hold */}
<g className="origin-[4px_6px] transition-transform duration-200 group-hover:-rotate-12 group-data-[phase=holding]:-rotate-25 motion-reduce:transition-none">
<path d="M3 6h18" />
<path d="M9 6V4h6v2" />
</g>
<path d="M6 6l1 14h10l1-14" />
<path d="M10 10v6M14 10v6" />
</svg>
</span>
<span
aria-hidden
className="relative whitespace-nowrap pr-4 text-sm font-medium opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus-visible:opacity-100 group-data-[phase=holding]:opacity-100 group-data-[phase=draining]:opacity-100 group-data-[phase=done]:opacity-100"
>
{labels[phase]}
</span>
</button>
);
}
Using it looks like this:
<HoldToDelete onConfirm={() => deleteFile(file.id)} />
The rest of this post walks through the parts that took me more than one try.
The shape change is plain CSS
The stretch from circle to pill is a width transition on the button: w-11 at rest, w-40 on hover:, focus-visible: and while it's busy. The data-phase attribute is how React tells CSS what's going on:
data-phase={phase}
// ...
className="... data-[phase=holding]:w-40 data-[phase=draining]:w-40 data-[phase=done]:w-40"
Children read the same attribute through the group class. The label fades in with group-data-[phase=holding]:opacity-100, and the lid rotates with group-data-[phase=holding]:-rotate-25. One attribute drives the button, the label and the icon, so you never have three pieces of state that can disagree.
The button stays wide while it drains and while it shows "Deleted". Without that, letting go on a phone (where there's no hover) would snap it back to a circle in the middle of the drain, which looks broken.
Animating width isn't free, because it triggers layout. For one small button in a row it's fine. If you put 200 of these in a virtualized table, swap the width for a scale on an inner pill.
Progress lives in a ref, not in state
My first version kept the progress in useState and updated it every frame. That's 60 renders a second for a fill that only needs one CSS value. Now the progress is a ref, and each frame writes it straight to a CSS variable on the button:
function paint() {
buttonRef.current?.style.setProperty("--progress", String(progress.current));
}
The fill is an absolutely positioned span that scales on the x axis from the left:
<span className="absolute inset-0 origin-left scale-x-[var(--progress,0)] bg-red-600" />
React only re-renders when the phase changes: idle, holding, draining, done. A full delete costs a handful of renders instead of about 70.
The bug: a fully red button on first load
The first time I opened the test page, every button was solid red before anyone touched it.
The class was scale-x-(--progress), which compiles to scale: var(--progress) .... Before the first press, --progress doesn't exist. A var() with nothing to resolve makes the whole declaration invalid, the browser falls back to scale: none, and the fill shows at full size.
The fix is a fallback inside the arbitrary value: scale-x-[var(--progress,0)]. I could have set style={{ "--progress": 0 }} on the button instead, but then React owns a value that the animation loop also writes to. The fallback keeps it in one place.
One loop for filling and draining
There's a single requestAnimationFrame loop. Each frame it checks one ref: if you're still holding, progress goes up; if not, it goes down twice as fast.
const step = dt / HOLD_MS;
progress.current = holding.current
? Math.min(1, progress.current + step)
: Math.max(0, progress.current - step * DRAIN_SPEED);
It uses the real time between frames (dt), not a fixed step per frame, so a 120 Hz screen doesn't fill twice as fast as a 60 Hz one. The loop stops itself when progress hits 1 (delete) or drains back to 0 (idle). startLoop refuses to start a second loop, so pressing again while it drains just turns the direction around from wherever the fill is.
In the test, a 400 ms press got to about a third, drained to zero in roughly 200 ms after release, and deleted nothing. A 1.4 s hold fired onConfirm once.
Pointer and keyboard
Pointer events cover mouse, pen and touch with one set of handlers:
-
onPointerDownstarts the hold, only for the main button (e.button !== 0ignores right clicks). -
onPointerUp,onPointerLeaveandonPointerCancelall release. With a mouse, sliding off the button cancels the hold. - On touch, the browser captures the pointer to the button you pressed, so a small wobble of your thumb doesn't cancel. If your thumb starts a scroll on top of the button, the browser fires
pointercancel, which releases. Scrolling a list shouldn't delete anything. -
onContextMenuwithpreventDefault()and[-webkit-touch-callout:none]stop the long-press menu from popping up halfway through a hold.
For the keyboard, Space or Enter starts the hold and releasing the key lets go:
onKeyDown={(e) => {
if ((e.key === " " || e.key === "Enter") && !e.repeat) {
e.preventDefault();
press();
}
}}
The !e.repeat check matters. Holding a key fires keydown over and over, and each one would restart the hold. onBlur releases too, so tabbing away mid-hold can't leave it filling forever.
Using it in a Next.js App Router page
The component starts with "use client", so you can import it anywhere. The catch: you can't pass onConfirm from a Server Component, because functions don't cross the server/client boundary. Next.js throws an error when you try. Put the list that owns the delete in its own client component:
"use client";
import { useState } from "react";
import { HoldToDelete } from "@/components/HoldToDelete";
export function FileList({ initial }: { initial: string[] }) {
const [files, setFiles] = useState(initial);
return (
<ul className="max-w-md space-y-2">
{files.map((name) => (
<li
key={name}
className="flex items-center justify-between rounded-xl bg-zinc-900 px-4 py-2 text-zinc-100"
>
<span>{name}</span>
<HoldToDelete
onConfirm={() =>
// wait a beat so "Deleted" is readable before the row goes
setTimeout(() => setFiles((f) => f.filter((n) => n !== name)), 600)
}
/>
</li>
))}
</ul>
);
}
Your page (a Server Component) fetches the files and renders <FileList initial={files} />. In a real app, onConfirm is where you'd call your server action or API route. Remove the row right away and keep the delay for the network call, or the button disappears before anyone sees "Deleted".
Changing it
- Hold time:
HOLD_MS. 1200 ms felt right for a delete. Under 800 ms it starts to feel like a slow click. - Colors: the fill is
bg-red-600. The idle button isbg-zinc-900. For a light theme trybg-white text-zinc-900 ring-zinc-200. - Other actions: change the icon and the labels and it works as "Hold to archive" or "Hold to send". The trend I mentioned uses the same stretch for logout and share buttons too.
Accessibility, briefly
- It's a real
<button>, so it's focusable and works with Space and Enter. -
aria-labelfollows the phase: "Hold to delete", "Keep holding", "Deleted". The visible label isaria-hiddenso screen readers don't read it twice. - The focus ring is
focus-visible:outline-red-500with an offset, so it shows on keyboard focus and not on click. - With Reduce Motion on, the width and lid transitions are off (
motion-reduce:transition-none). The fill still moves, because it's the progress itself and not decoration. - The touch target is 44 px tall.
One limit to know about: a press-and-hold is harder for some people with motor impairments than a click. If your users include them, keep an undo (a toast with "Undo" for a few seconds) next to this, or use a regular confirm for the irreversible stuff.
A prompt for Cursor or v0
If you want your agent to add it, paste the component file into the chat and then this:
Add this HoldToDelete component to my Next.js App Router project
as components/HoldToDelete.tsx. Don't add any packages.
1. Find every delete or remove button in my app that opens a
confirm dialog. List them before changing anything.
2. For the ones in lists and tables, replace the button and the
dialog with <HoldToDelete onConfirm={...} />, calling the same
delete function the dialog called.
3. If the parent is a Server Component, move the list into a small
client component so onConfirm can be a function.
4. Keep my Tailwind colors: map bg-zinc-900 and bg-red-600 to my
theme tokens if I have them.
5. Run the type-check and next build.
Done when the build passes, the console is clean, a short press does
nothing, a full hold deletes, and Space held on a focused button
deletes too. Tell me which files you changed.
What I tested and what I didn't
I compiled the classes with Tailwind 4.3.3 (all 57 produced CSS), type-checked with TypeScript 5.9 in strict mode, and ran the component with React 19.1 in headless Chromium: hover, a short press that drains, a full hold, keyboard hold, the FileList above, and Reduce Motion. No console errors.
Not tested yet: a real phone, Safari, and a full next build. The pointer code is standard, but if the long-press menu or the scroll cancel acts up on your iPhone, tell me in the comments.
About me, and the links. I'm Teodor, and I make animated UI components at qodesign, so this post is partly about my own work. The button above is free to copy. If you want the full version, Hold to Delete adds a rattling bin lid, a file that crumples into a ball and drops in, and an undo toast. It's on qodesign.dev. Each piece comes with a React + Tailwind version and a PROMPT.md for AI coding tools. You can try the live demo first.
I wrote this post with help from an AI assistant. The component and every code block were type-checked and run in a browser before publishing.

Top comments (0)