Swish UI
All componentsButtons and controls Free

Hold to confirm

Pressing and holding fills the button from left to right; let go early and it drains, finish and it confirms.

It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.

Demo.tsx

"use client";
import { useEffect, useId, useRef, useState } from "react";
import { usePrefersReducedMotion } from "@/lib/motion";
import styles from "./demo.module.css";

const DURATION = 1200;
const KEYS = [" ", "Enter"];

function Content({ done }: { done: boolean }) {
  return (
    <span className={styles.content}>
      <svg className={styles.icon} viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
        {done ? <path d="M3.5 8.5 6.5 11.5 12.5 4.5" /> : <path d="M3 4.5h10M6.5 4.5V3h3v1.5M4.5 4.5l.6 8.5h5.8l.6-8.5" />}
      </svg>
      {done ? "Deleted" : "Hold to delete"}
    </span>
  );
}

export default function Demo() {
  const button = useRef<HTMLButtonElement>(null);
  const progress = useRef(0);
  const holding = useRef(false);
  const raf = useRef(0);
  const last = useRef(0);
  const [done, setDone] = useState(false);
  const reduced = usePrefersReducedMotion();
  const hint = useId();

  const paint = () => button.current?.style.setProperty("--p", progress.current.toFixed(4));

  const tick = (now: number) => {
    const dt = now - last.current;
    last.current = now;
    // Fill at the hold speed, drain three times faster.
    progress.current = Math.min(1, Math.max(0, progress.current + (holding.current ? dt / DURATION : (-dt * 3) / DURATION)));
    paint();
    if (progress.current >= 1) {
      holding.current = false;
      raf.current = 0;
      setDone(true);
    } else if (progress.current <= 0 && !holding.current) {
      raf.current = 0;
    } else {
      raf.current = requestAnimationFrame(tick);
    }
  };

  const start = () => {
    if (done) return;
    holding.current = true;
    if (!raf.current) {
      last.current = performance.now();
      raf.current = requestAnimationFrame(tick);
    }
  };
  const stop = () => {
    holding.current = false;
    if (reduced && !done) {
      progress.current = 0;
      paint();
    }
  };

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

  useEffect(() => {
    if (!done) return;
    const timer = setTimeout(() => {
      progress.current = 0;
      paint();
      setDone(false);
    }, 2200);
    return () => clearTimeout(timer);
  }, [done]);

  return (
    <div className={styles.root}>
      <div className={styles.stack}>
        <button
          ref={button}
          type="button"
          className={`${styles.button} ${done ? styles.done : ""}`}
          aria-disabled={done}
          aria-describedby={hint}
          onPointerDown={(event) => {
            if (event.button !== 0) return;
            event.currentTarget.setPointerCapture(event.pointerId);
            start();
          }}
          onPointerUp={stop}
          onPointerCancel={stop}
          onLostPointerCapture={stop}
          onKeyDown={(event) => {
            if (!KEYS.includes(event.key)) return;
            event.preventDefault();
            if (!event.repeat) start();
          }}
          onKeyUp={(event) => KEYS.includes(event.key) && stop()}
          onBlur={stop}
          onContextMenu={(event) => event.preventDefault()}
        >
          <Content done={done} />
          <span className={styles.fill} aria-hidden="true">
            <Content done={done} />
          </span>
        </button>
        <p id={hint} className={styles.note}>Removes Villa Azul and its 3 drafts</p>
        <span className={styles.sr} aria-live="polite">{done ? "Listing deleted" : ""}</span>
      </div>
    </div>
  );
}

demo.module.css

.root { width: 100%; height: 100%; display: grid; place-items: center; padding: 16px; }
.stack { display: grid; justify-items: center; gap: 12px; }
.button {
  --p: 0;
  position: relative; overflow: hidden; display: grid;
  min-height: 52px; min-width: 210px; padding: 0 28px; border-radius: 999px; border: 1.5px solid var(--ink);
  background: var(--white); color: var(--ink); font-size: 15px; font-weight: 600; cursor: pointer;
  user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; touch-action: none;
  transition: transform .2s var(--out);
}
.button:active { transform: scale(.98); }
.content { display: inline-flex; align-items: center; justify-content: center; gap: 10px; white-space: nowrap; }
.icon { flex: none; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
.fill {
  position: absolute; inset: 0; display: grid; place-items: center; padding: 0 28px;
  background: var(--ink); color: var(--white);
  clip-path: inset(0 calc(100% - var(--p) * 100%) 0 0);
}
.done { border-color: var(--accent-text); cursor: default; transform: none; }
.done .fill { background: var(--accent-text); clip-path: none; }
.done .fill .content { animation: pop .4s var(--out); }
@keyframes pop { from { transform: scale(.85); opacity: 0; } }
.note { font-size: 13px; color: var(--muted); }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) {
  .done .fill .content { animation: none; }
}

Use it for

  • Destructive actions like delete or cancel booking, instead of a confirm dialog
  • Actions on touch devices that are easy to trigger by accident

Avoid

  • Frequent everyday actions, since the hold adds a second each time
  • Users with motor impairments as the only path, so keep an undo or a dialog fallback

How it works

Pointerdown captures the pointer and starts a requestAnimationFrame loop that adds elapsed time divided by 1200ms to a progress ref, or drains it three times faster once released. Each frame writes the value to a --p custom property. A second copy of the label in inverted colours sits on top and is revealed with clip-path inset, so the text changes colour exactly at the fill edge. Reaching 1 switches to the Deleted state, which resets after a pause.

Accessibility

Holding Space or Enter fills it the same way, with key repeat ignored, and blur cancels. The hint text is linked with aria-describedby and a live region announces the deletion. Reduced motion keeps the progress fill (it is the feedback) but drops the drain and the pop.

Found on