跳到正文
DEV Community· Teodor Avadani·· 3 小時前AI 評分61

必須長按才能刪除的按鈕:React + Tailwind,毋須動畫套件

A delete button you have to hold: React + Tailwind, no animation library

AI 導讀

Teodor Avadani 提供一個以 React 和 Tailwind 製作的長按刪除按鈕元件,毋須動畫套件;按住 1,200 ms 才會執行刪除,提前放開則進度回退。

正文

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.

The button at rest, on hover, while holding, and after delete

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:

  • onPointerDown starts the hold, only for the main button (e.button !== 0 ignores right clicks).
  • onPointerUp, onPointerLeave and onPointerCancel all 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.
  • onContextMenu with preventDefault() 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 is bg-zinc-900. For a light theme try bg-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-label follows the phase: "Hold to delete", "Keep holding", "Deleted". The visible label is aria-hidden so screen readers don't read it twice.
  • The focus ring is focus-visible:outline-red-500 with 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.

來源:DEV Community · dev.to