TL;DR A hold to delete button needs a progress value that fills while the pointer or Space key is held and drains on release, a callback when it reaches 100%, and an undo window before the real delete. This post builds one in React and Tailwind CSS with requestAnimationFrame and no animation library, fixes touch, long-press menus and key repeat, and runs the final delete through a Next.js Server Action.
A hold to delete button needs three parts: a progress value that fills while the pointer or the Space key is held and drains when it's released, a callback that fires once the value reaches 100%, and an undo window before anything is actually deleted. This post builds all three in React and Tailwind CSS with requestAnimationFrame, no animation library, and wires the final delete to a Next.js Server Action.
I make qodesign, a set of animated React + TypeScript + Tailwind components. Our Hold to Delete button is one of them: a red fill sweeps across the button, the bin lid rattles past 60%, and after 1.2 seconds the file crumples into the bin with an undo toast. The code below is a smaller version written for this post, not the component's source. The component's React version uses Framer Motion for its frame loop; the snippets here use plain requestAnimationFrame, so they need nothing beyond React.
A confirm dialog asks a question after the click. A hold asks for intent during the press: you can't trigger it with a stray tap, and you don't have to move your eyes to a modal. It fits actions people do a few times a day, like deleting a file, removing a row or clearing a cart.
It's the wrong choice when the action is rare and serious, like deleting an account or a whole project. There a dialog that makes people type the name is the better speed bump. A hold also needs a visible hint, because nobody guesses that a button wants to be held.
Hold to Delete running live. A delete button you press and hold, then the file crumples into the bin.
The button keeps one number between 0 and 1. On each animation frame, while the button is held, it adds the time since the last frame divided by the hold length. When it isn't held, it subtracts the time divided by a shorter drain length, so letting go early makes the fill run back. When the number reaches 1, the confirm callback fires once.
That number lives in a ref and the fill is moved by writing transform: scaleX() straight onto the element. Putting it in React state would re-render the button 60 times a second for no visible gain. State only changes twice per hold: once when holding starts and once when it stops, to swap the label.
scaleX with origin-left animates on the compositor. Animating width would trigger layout on every frame.
The drain is 400 ms against a 1200 ms hold, the same ratio our component uses. A slow drain feels sticky; an instant reset makes an early release look like an error.
After the hold completes the button stays done. To arm it again, remount it with a new key, which the next section does after an undo.
Most hold buttons online handle onMouseDown and onMouseUp and stop there. These are the cases that break on real devices:
setPointerCapture keeps the pointer events on the button even if a finger drifts a few pixels off it, so the release is never missed. MDN's setPointerCapture page explains the behavior. If you'd rather cancel when the pointer leaves, use onPointerLeave instead, which is what our component does.
onPointerCancel and onLostPointerCapture stop the hold when the browser takes the pointer away, for example for a system gesture. The pointercancel event fires in those cases.
touch-none (CSS touch-action: none) stops a touch on the button from starting a scroll. Without it, the browser cancels the pointer as soon as it decides you're scrolling. The trade is that a scroll can't start from the button itself, so keep it button sized.
onContextMenu with preventDefault, select-none and [-webkit-touch-callout:none] stop the long-press menu and text selection that phones show after holding for a moment, right in the middle of your 1.2 seconds.
Keyboard: holding Space or Enter fires keydown over and over. The e.repeat check ignores the repeats, as described on MDN's KeyboardEvent.repeat page. preventDefault stops Enter from also firing a click.
onBlur stops the hold if focus moves away mid-press, so the fill doesn't keep running with no key down.
A hold proves intent. It doesn't protect against the wrong file. So the row below hides the button, shows an undo for five seconds and only then calls the Server Action. Undo just clears the timer, so nothing on the server ever has to be restored.
The action itself runs on the server, checks who is asking and revalidates the page:
ts
'use server';import { revalidatePath } from 'next/cache';export async function deleteFile(id: string): Promise<void> { // Check the session and that this user owns the file before deleting anything. const res = await fetch(`${process.env.FILES_API_URL}/files/${encodeURIComponent(id)}`, { method: 'DELETE', }); if (!res.ok) throw new Error(`Delete failed with ${res.status}`); revalidatePath('/files');}
And a Server Component page passes the action down as a prop:
The delete runs inside a transition from React's useTransition, so isPending covers the request and the Undo button disappears once it's too late to undo. After the action runs, revalidatePath refreshes the list from the server. In a real app the list would come from your database, so the deleted row drops out on that refresh.
One decision to make on purpose: if the user leaves the page during the undo window, the cleanup clears the timer and the file is not deleted. If that's wrong for your app, mark the row deleted on the server right away (a deletedAt column) and have Undo clear it. We have a full walkthrough of that pattern in delete with undo using a Server Action, and a toast version in toast notifications with Server Actions.
The hint under the button is linked with aria-describedby, so screen readers read "Press and hold for 1.2 s, or hold Space" along with the label.
A hold completes while the pointer is still down. WCAG 2.5.2 Pointer Cancellation asks that an action like that can be aborted or undone. Letting go early aborts it, and the undo row covers the case where the hold finished.
The status line has role="status", so "brand-assets.zip will be deleted" is announced when it appears.
Holds are harder for some people: tremor, switch access, some screen reader setups. For an action people repeat all day, consider a setting that swaps the hold for a single press with undo.
Reduced motion: keep the fill. It's the progress indicator, not decoration. Turn off the extras instead, like a shaking lid or a crumple. Our prefers-reduced-motion guide shows how.
Mouse events only. onMouseDown works on a laptop and does nothing useful on a phone, or fires late after the touch emulation delay. Use pointer events.
A setInterval that adds 10% every 120 ms. Intervals drift when the tab is busy and the fill jumps in steps. Measure the real time between frames instead.
No guard against key repeat, so holding Space restarts the hold on every repeat and the fill never moves.
The real delete fires the moment the hold completes, with no undo, which makes the hold a slower click with the same risk.
Name the parts you want, or you'll get a styled button with a CSS transition. Something like: "A hold to delete button in React and Tailwind. Use pointer events with setPointerCapture, handle pointercancel, add touch-action none and block the long-press context menu. Drive progress with requestAnimationFrame and a ref, drain it when released, and support holding Space or Enter while ignoring key repeat. Link a hint with aria-describedby. After the hold, show an undo for 5 seconds, then call a Server Action passed in as a prop." Then test it on a real phone, because the long-press menu doesn't show up in desktop dev tools.
No. The progress loop is requestAnimationFrame and a ref, and the fill is a CSS transform. Framer Motion helps when you add exit animations or spring effects around it, like a file crumpling into a bin. We compared the two approaches in Framer Motion vs CSS for micro-interactions.
Between about 0.8 and 1.5 seconds. Shorter, and it stops filtering out accidental presses. Longer, and people let go thinking it's broken. Our component uses 1.2 seconds, and the hint text shows the number so people know what to expect.
Yes. Every class here exists in Tailwind v3.4, including the arbitrary property for -webkit-touch-callout. In v3, add focus-visible:outline next to focus-visible:outline-2 so the focus ring is solid; in v4, outline-2 sets the style for you.
The Hold to Delete component has the bin lid that rattles as the fill passes 60%, the file that crumples into the bin and the undo toast with a timer bar, in a React version and a single HTML file. There's a short guide in our hold to delete button page.