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.
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
'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>
)
}
.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); }