'use client';
import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
import './HoldButton.css';
const TAP_MS = 250;
const HIT_PAD = 10;
const LINEAR = t => t;
const EASE_OUT = t => 1 - Math.pow(1 - t, 3);
export default function HoldButton({
children = 'Hold to delete',
doneLabel = 'Deleted',
icon = null,
doneIcon = null,
backgroundColor = '#27272a',
fillColor = '#5227FF',
textColor = '#f5f5f5',
fillTextColor = '#ffffff',
size = 'md',
radius = 14,
fillDirection = 'right',
holdTime = 2000,
releaseTime = 200,
pressScale = 0.97,
wave = true,
waveAmplitude = 6,
glow = true,
resetAfter = 1200,
disabled = false,
onHold,
onTap,
className = ''
}) {
const [phase, setPhase] = useState('idle');
const [input, setInput] = useState(null);
const phaseRef = useRef('idle');
const inputRef = useRef(null);
const buttonRef = useRef(null);
const gesture = useRef({ pointerId: null, start: 0, rect: null });
const timers = useRef({ complete: 0, reset: 0 });
const hintId = useId();
const go = (next, kind = null) => {
phaseRef.current = next;
inputRef.current = kind;
setPhase(next);
setInput(kind);
};
const clearTimers = () => {
clearTimeout(timers.current.complete);
clearTimeout(timers.current.reset);
};
const motion = useRef({ raf: 0, p: 0, from: 0, to: 0, start: 0 });
const drive = (to, duration, ease) => {
const m = motion.current;
cancelAnimationFrame(m.raf);
m.from = m.p;
m.to = to;
m.start = performance.now();
const step = now => {
const t = duration > 0 ? Math.min(1, (now - m.start) / duration) : 1;
m.p = m.from + (m.to - m.from) * ease(t);
buttonRef.current?.style.setProperty('--hb-p', m.p.toFixed(4));
if (t < 1) {
m.raf = requestAnimationFrame(step);
return;
}
m.raf = 0;
if (m.to === 1) complete();
};
m.raf = requestAnimationFrame(step);
};
const complete = () => {
if (phaseRef.current !== 'holding') return;
if (performance.now() - gesture.current.start < holdTime - 50) return;
clearTimers();
go('done', inputRef.current);
onHold?.();
if (resetAfter > 0) {
timers.current.reset = setTimeout(() => {
go('idle');
drive(0, releaseTime, EASE_OUT);
}, resetAfter);
}
};
const begin = kind => {
if (disabled || phaseRef.current !== 'idle') return false;
const button = buttonRef.current;
if (!button) return false;
gesture.current.start = performance.now();
gesture.current.rect = button.getBoundingClientRect();
go('holding', kind);
drive(1, holdTime, LINEAR);
timers.current.complete = setTimeout(complete, holdTime + 100);
return true;
};
const release = ({ drifted = false } = {}) => {
if (phaseRef.current !== 'holding') return;
clearTimers();
const held = performance.now() - gesture.current.start;
go('idle');
drive(0, releaseTime, EASE_OUT);
if (!drifted && held < TAP_MS) onTap?.();
};
const releaseRef = useRef(release);
releaseRef.current = release;
const handlePointerDown = e => {
if (e.button !== 0 || !e.isPrimary || gesture.current.pointerId !== null) return;
if (!begin('pointer')) return;
gesture.current.pointerId = e.pointerId;
try {
e.currentTarget.setPointerCapture(e.pointerId);
} catch {}
};
const endPointer = (e, options) => {
if (e.pointerId !== gesture.current.pointerId) return;
gesture.current.pointerId = null;
try {
if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
} catch {}
release(options);
};
const handlePointerMove = e => {
if (e.pointerId !== gesture.current.pointerId) return;
const r = gesture.current.rect;
if (!r) return;
const out =
e.clientX < r.left - HIT_PAD ||
e.clientX > r.right + HIT_PAD ||
e.clientY < r.top - HIT_PAD ||
e.clientY > r.bottom + HIT_PAD;
if (out) endPointer(e, { drifted: true });
};
const handlePointerLeave = e => {
if (e.pointerType !== 'touch') endPointer(e, { drifted: true });
};
const handleKeyDown = e => {
if (e.key === 'Escape') {
if (inputRef.current === 'key') release({ drifted: true });
return;
}
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
if (!e.repeat) begin('key');
}
};
const handleKeyUp = e => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
if (inputRef.current === 'key') release();
}
};
useLayoutEffect(() => {
const button = buttonRef.current;
if (!button) return undefined;
const measure = () => {
button.style.setProperty('--hb-w', `${button.offsetWidth}px`);
button.style.setProperty('--hb-h', `${button.offsetHeight}px`);
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(button);
return () => ro.disconnect();
}, []);
useEffect(() => {
if (phase !== 'holding') return undefined;
const cancel = () => releaseRef.current({ drifted: true });
const onVisibility = () => {
if (document.hidden) cancel();
};
window.addEventListener('blur', cancel);
document.addEventListener('visibilitychange', onVisibility);
return () => {
window.removeEventListener('blur', cancel);
document.removeEventListener('visibilitychange', onVisibility);
};
}, [phase]);
useEffect(() => {
const t = timers.current;
const m = motion.current;
return () => {
clearTimeout(t.complete);
clearTimeout(t.reset);
cancelAnimationFrame(m.raf);
};
}, []);
const direction = fillDirection === 'up' ? 'up' : 'right';
const labels = (
<>
<span className="hold-button__idle" aria-hidden={phase === 'done'}>
{icon ? <span className="hold-button__icon">{icon}</span> : null}
{children}
</span>
<span className="hold-button__done" aria-hidden={phase !== 'done'}>
{doneIcon ? <span className="hold-button__icon">{doneIcon}</span> : null}
{doneLabel}
</span>
</>
);
return (
<button
ref={buttonRef}
type="button"
disabled={disabled}
className={`hold-button hold-button--${size}${className ? ` ${className}` : ''}`}
data-phase={phase}
data-input={input ?? undefined}
data-direction={direction}
data-glow={glow ? 'true' : undefined}
aria-describedby={hintId}
style={{
'--hb-radius': `${radius}px`,
'--hb-bg': backgroundColor,
'--hb-fill': fillColor,
'--hb-text': textColor,
'--hb-fill-text': fillTextColor,
'--hb-hold': `${holdTime}ms`,
'--hb-cycles': holdTime / 1100,
'--hb-release': `${releaseTime}ms`,
'--hb-press': pressScale,
'--hb-wave': `${wave ? waveAmplitude : 0}px`
}}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={e => endPointer(e)}
onPointerCancel={e => endPointer(e, { drifted: true })}
onLostPointerCapture={e => endPointer(e, { drifted: true })}
onPointerLeave={handlePointerLeave}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onContextMenu={e => e.preventDefault()}
>
<span className="hold-button__pulse" aria-hidden="true" />
<span className="hold-button__label">{labels}</span>
<span className="hold-button__clip" aria-hidden="true">
<span className="hold-button__fill">
<span className="hold-button__label hold-button__label--fill">{labels}</span>
</span>
<span className="hold-button__crest" aria-hidden="true">
<span className="hold-button__label hold-button__label--fill">{labels}</span>
</span>
</span>
<span id={hintId} className="hold-button__sr">
Press and hold for {Math.round(holdTime / 100) / 10} seconds to confirm
</span>
</button>
);
}