# Hold to Delete Button in React and Tailwind CSS

> 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.

- URL: https://qodesign.dev/blog/hold-to-delete-button-react-tailwind
- Author: Teodor Avadani (https://qodesign.dev/blog/author/teodor-avadani)
- Published: 2026-10-21
- Updated: 2026-10-21
- Category: Animation
- Publisher: qodesign (https://qodesign.dev), a shop of animated React + Tailwind CSS components for Next.js, each sold with its full source code and an AI prompt.

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](/components/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.

## When a hold beats a confirm dialog

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.

## How the hold works

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.

## HoldButton.tsx

```tsx
'use client';

import { useCallback, useEffect, useId, useRef, useState } from 'react';

type HoldButtonProps = {
  label: string;
  onConfirm: () => void;
  holdMs?: number;
  drainMs?: number;
};

export function HoldButton({ label, onConfirm, holdMs = 1200, drainMs = 400 }: HoldButtonProps) {
  const hintId = useId();
  const fillRef = useRef<HTMLSpanElement>(null);
  const loop = useRef({ progress: 0, holding: false, done: false, last: 0, frame: 0 });
  const confirmRef = useRef(onConfirm);
  const [holding, setHolding] = useState(false);

  useEffect(() => {
    confirmRef.current = onConfirm;
  }, [onConfirm]);

  const tick = useCallback(
    (now: number) => {
      const s = loop.current;
      const dt = s.last ? now - s.last : 16;
      s.last = now;
      s.progress += s.holding ? dt / holdMs : -dt / drainMs;
      s.progress = Math.min(1, Math.max(0, s.progress));
      if (fillRef.current) fillRef.current.style.transform = `scaleX(${s.progress})`;

      if (s.progress >= 1 && !s.done) {
        s.done = true;
        s.holding = false;
        s.frame = 0;
        s.last = 0;
        setHolding(false);
        confirmRef.current();
        return;
      }
      if (s.holding || s.progress > 0) {
        s.frame = requestAnimationFrame(tick);
      } else {
        s.frame = 0;
        s.last = 0;
      }
    },
    [holdMs, drainMs],
  );

  useEffect(() => () => cancelAnimationFrame(loop.current.frame), []);

  const start = () => {
    const s = loop.current;
    if (s.done || s.holding) return;
    s.holding = true;
    setHolding(true);
    if (!s.frame) s.frame = requestAnimationFrame(tick);
  };

  const stop = () => {
    const s = loop.current;
    if (!s.holding) return;
    s.holding = false;
    setHolding(false);
    if (!s.frame && s.progress > 0) s.frame = requestAnimationFrame(tick);
  };

  const isHoldKey = (key: string) => key === ' ' || key === 'Enter';

  return (
    <div className="inline-flex flex-col items-start gap-2">
      <button
        type="button"
        aria-describedby={hintId}
        onPointerDown={(e) => {
          if (e.button !== 0) return;
          e.currentTarget.setPointerCapture(e.pointerId);
          start();
        }}
        onPointerUp={stop}
        onPointerCancel={stop}
        onLostPointerCapture={stop}
        onKeyDown={(e) => {
          if (isHoldKey(e.key) && !e.repeat) {
            e.preventDefault();
            start();
          }
        }}
        onKeyUp={(e) => {
          if (isHoldKey(e.key)) stop();
        }}
        onBlur={stop}
        onContextMenu={(e) => e.preventDefault()}
        className="relative touch-none select-none overflow-hidden rounded-xl border border-red-500/40 bg-red-950/40 px-5 py-3 font-medium text-red-100 [-webkit-touch-callout:none] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-400"
      >
        <span
          ref={fillRef}
          aria-hidden="true"
          className="absolute inset-0 origin-left scale-x-0 bg-gradient-to-r from-red-600 to-red-400"
        />
        <span className="relative">{holding ? 'Keep holding' : label}</span>
      </button>
      <p id={hintId} className="text-sm text-neutral-400">
        Press and hold for {(holdMs / 1000).toFixed(1)} s, or hold Space. Let go to cancel.
      </p>
    </div>
  );
}
```

A few choices worth knowing:

- `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.

## What each event handler is for

Most hold buttons online handle `onMouseDown` and `onMouseUp` and stop there. These are the cases that break on real devices:

1. `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](https://developer.mozilla.org/en-US/docs/Web/API/Element/setPointerCapture) explains the behavior. If you'd rather cancel when the pointer leaves, use `onPointerLeave` instead, which is what our component does.
2. `onPointerCancel` and `onLostPointerCapture` stop the hold when the browser takes the pointer away, for example for a system gesture. The [pointercancel event](https://developer.mozilla.org/en-US/docs/Web/API/Element/pointercancel_event) fires in those cases.
3. `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.
4. `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.
5. 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](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/repeat). `preventDefault` stops Enter from also firing a click.
6. `onBlur` stops the hold if focus moves away mid-press, so the fill doesn't keep running with no key down.

## Undo first, delete later

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.

```tsx
'use client';

import { useEffect, useRef, useState, useTransition } from 'react';
import { HoldButton } from './HoldButton';

type FileRowProps = {
  file: { id: string; name: string };
  deleteFile: (id: string) => Promise<void>;
  undoMs?: number;
};

export function FileRow({ file, deleteFile, undoMs = 5000 }: FileRowProps) {
  const [waiting, setWaiting] = useState(false);
  const [attempt, setAttempt] = useState(0);
  const [isPending, startTransition] = useTransition();
  const timer = useRef<number | undefined>(undefined);

  useEffect(() => () => window.clearTimeout(timer.current), []);

  function onConfirm() {
    setWaiting(true);
    timer.current = window.setTimeout(() => {
      startTransition(async () => {
        await deleteFile(file.id);
      });
    }, undoMs);
  }

  function undo() {
    window.clearTimeout(timer.current);
    setWaiting(false);
    setAttempt((n) => n + 1);
  }

  return (
    <li className="flex items-center justify-between gap-4 rounded-xl border border-neutral-800 p-4">
      <span className={waiting ? 'text-neutral-500 line-through' : ''}>{file.name}</span>
      {waiting ? (
        <div role="status" className="flex items-center gap-3 text-sm">
          <span>{isPending ? 'Deleting' : `${file.name} will be deleted`}</span>
          {!isPending && (
            <button
              type="button"
              onClick={undo}
              className="rounded-lg bg-lime-300 px-3 py-1 font-medium text-black"
            >
              Undo
            </button>
          )}
        </div>
      ) : (
        <HoldButton key={attempt} label="Hold to delete" onConfirm={onConfirm} />
      )}
    </li>
  );
}
```

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:

```tsx
import { FileRow } from './FileRow';
import { deleteFile } from './actions';

const files = [
  { id: 'f1', name: 'brand-assets.zip' },
  { id: 'f2', name: 'q3-report.pdf' },
];

export default function FilesPage() {
  return (
    <ul className="mx-auto max-w-xl space-y-3 p-6">
      {files.map((file) => (
        <FileRow key={file.id} file={file} deleteFile={deleteFile} />
      ))}
    </ul>
  );
}
```

The delete runs inside a transition from [React's useTransition](https://react.dev/reference/react/useTransition), so `isPending` covers the request and the Undo button disappears once it's too late to undo. After the action runs, [revalidatePath](https://nextjs.org/docs/app/api-reference/functions/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](/react/delete-with-undo-server-action), and a toast version in [toast notifications with Server Actions](/blog/toast-notifications-nextjs-server-actions).

## Accessibility notes

- 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](https://www.w3.org/WAI/WCAG22/Understanding/pointer-cancellation.html) 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](/blog/prefers-reduced-motion-react) shows how.

## Four bugs AI-generated hold buttons ship with

1. 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.
2. 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.
3. No guard against key repeat, so holding Space restarts the hold on every repeat and the fill never moves.
4. The real delete fires the moment the hold completes, with no undo, which makes the hold a slower click with the same risk.

## Prompting Cursor, v0 or Lovable for this

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.

## FAQ

### Do I need Framer Motion for a hold to delete button?

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](/blog/framer-motion-vs-css-micro-interactions).

### How long should the hold be?

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.

### Does this work with Tailwind v3?

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.

### Where can I see the full animation?

The [Hold to Delete](/components/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](/react/hold-to-delete-button).
