Hold to Confirm

A fill grows across the button while you hold it down; let go early and it drains back, hold to the end and it confirms.

  • JS
  • Buttons
  • Click
  • Added
Press and hold for 1.2 seconds

AI prompt

Build a React hold-to-confirm button for destructive actions. On pointerdown (and Space or Enter keydown), start a requestAnimationFrame loop that raises a progress value from 0 to 1 over 1200ms and sets it as --p on the button. A ::before fill layer uses transform: scaleX(var(--p)) with transform-origin left. On pointerup, pointerleave or keyup before completion, animate --p back to 0 over 250ms. When progress reaches 1, switch the label to "Deleted", mark it done for 1.6s, then reset. Call setPointerCapture so dragging off still counts. Add aria-describedby pointing at the hint "Press and hold for 1.2 seconds". Under prefers-reduced-motion keep the hold time but update the fill in 4 steps. The idle label reads "Hold to delete".

Source

Demo.tsx
'use client'

import { useEffect, useId, useRef, useState } from 'react'
import s from './demo.module.css'

const HOLD = 1200

export default function HoldToConfirm({ labels }: { labels: Record<string, string> }) {
  const ref = useRef<HTMLButtonElement>(null)
  const frame = useRef(0)
  const progress = useRef(0)
  const [done, setDone] = useState(false)
  const hintId = useId()

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

  const set = (p: number) => {
    progress.current = p
    const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
    ref.current?.style.setProperty('--p', String(reduced ? Math.floor(p * 4) / 4 : p))
  }

  const start = () => {
    if (done) return
    cancelAnimationFrame(frame.current)
    const from = performance.now() - progress.current * HOLD
    const tick = (now: number) => {
      const p = Math.min((now - from) / HOLD, 1)
      set(p)
      if (p < 1) frame.current = requestAnimationFrame(tick)
      else {
        setDone(true)
        setTimeout(() => {
          setDone(false)
          set(0)
        }, 1600)
      }
    }
    frame.current = requestAnimationFrame(tick)
  }

  const cancel = () => {
    if (done || progress.current >= 1) return
    cancelAnimationFrame(frame.current)
    const from = progress.current
    const t0 = performance.now()
    const tick = (now: number) => {
      const p = from * (1 - Math.min((now - t0) / 250, 1))
      set(p)
      if (p > 0) frame.current = requestAnimationFrame(tick)
    }
    frame.current = requestAnimationFrame(tick)
  }

  const isKey = (e: React.KeyboardEvent) => e.key === ' ' || e.key === 'Enter'

  return (
    <div className={s.wrap}>
      <button
        ref={ref}
        type="button"
        className={s.button}
        data-done={done || undefined}
        aria-describedby={hintId}
        onPointerDown={(e) => {
          e.currentTarget.setPointerCapture(e.pointerId)
          start()
        }}
        onPointerUp={cancel}
        onPointerCancel={cancel}
        onKeyDown={(e) => {
          if (isKey(e) && !e.repeat) {
            e.preventDefault()
            start()
          }
        }}
        onKeyUp={(e) => isKey(e) && cancel()}
      >
        <span className={s.label}>{done ? labels.done : labels.idle}</span>
      </button>
      <span id={hintId} className={s.hint}>
        {labels.hint}
      </span>
    </div>
  )
}
demo.module.css
.wrap { display: grid; justify-items: center; gap: 10px; }

.button {
  --p: 0;
  position: relative;
  overflow: hidden;
  height: 52px;
  padding: 0 28px;
  border: 1px solid var(--stage-rule);
  border-radius: 12px;
  background: var(--stage-surface);
  color: var(--stage-ink);
  font: 600 var(--text-base) / 1 var(--font-body);
  white-space: nowrap;
  cursor: pointer;
  touch-action: none;
  user-select: none;
}

.button::before {
  content: '';
  position: absolute;
  inset: 0;
  background: var(--stage-accent);
  transform: scaleX(var(--p));
  transform-origin: left;
}

.label { position: relative; }

.button[data-done] { border-color: var(--stage-accent); color: var(--stage-accent-ink); }
.button[data-done]::before { transform: scaleX(1); }
.button:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 3px; }

.hint { font-size: var(--text-xs); color: var(--stage-muted); }

Suggest an idea

Seen a motion or a theme you would like to find here? Describe it, and it opens as a prefilled GitHub issue for you to post.

What are you suggesting?

Needs a GitHub account. Suggestions are public.