Hold Button
A press-and-hold button whose accent fill wipes in over the label as proof of the hold.
Pressing is slow and deliberate, the fill wipes in linearly across the full hold duration. Letting go is snappy, the fill retreats in 200ms. The fill is not decoration, it is the confirmation mechanism itself, so it survives reduced motion. Built with Motion.
Installation
Complete the shared Setup first, then copy the component into
components/ui/hold-button.tsx.
bun add motion'use client';import * as React from 'react';import { motion, useReducedMotion } from 'motion/react';import { Check } from 'lucide-react';import { cn } from '@/lib/cn';const EASE_OUT = [0.23, 1, 0.32, 1] as const;const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;const SPRING = { type: 'spring', duration: 0.5, bounce: 0.16 } as const;const EASE_OUT_CSS = `cubic-bezier(${EASE_OUT.join(',')})`;const FOCUS = 'focus-visible:outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:[outline-color:var(--hold-accent)]';export type HoldButtonProps = { onConfirm?: () => void; holdMs?: number; accent?: string;} & React.ComponentProps<'button'>;export function HoldButton({ onConfirm, holdMs = 1500, accent = '#f0463a', children = 'Hold to confirm', className, style, disabled, ...props}: HoldButtonProps) { const reduce = useReducedMotion(); const [held, setHeld] = React.useState(false); const [confirmed, setConfirmed] = React.useState(false); const holdTimer = React.useRef<number | undefined>(undefined); const revertTimer = React.useRef<number | undefined>(undefined); const pointerId = React.useRef<number | null>(null); const confirmRef = React.useRef(onConfirm); confirmRef.current = onConfirm; React.useEffect( () => () => { window.clearTimeout(holdTimer.current); window.clearTimeout(revertTimer.current); }, [], ); const start = React.useCallback(() => { if (disabled || confirmed) return; setHeld((already) => { if (already) return already; window.clearTimeout(holdTimer.current); holdTimer.current = window.setTimeout(() => { setHeld(false); setConfirmed(true); confirmRef.current?.(); window.clearTimeout(revertTimer.current); revertTimer.current = window.setTimeout(() => setConfirmed(false), 1200); }, holdMs); return true; }); }, [disabled, confirmed, holdMs]); const cancel = React.useCallback(() => { window.clearTimeout(holdTimer.current); setHeld(false); }, []); const clip = held ? 'inset(0 0% 0 0)' : 'inset(0 100% 0 0)'; const clipTransition = held ? `clip-path ${holdMs}ms linear` : `clip-path 200ms ${EASE_OUT_CSS}`; const vars = { '--hold-accent': accent } as React.CSSProperties; const blurOn = reduce ? 'blur(0px)' : 'blur(2px)'; return ( <button type="button" disabled={disabled} style={{ ...vars, transform: held && !reduce ? 'scale(0.98)' : 'scale(1)', transition: `transform 120ms ${EASE_OUT_CSS}`, ...style, }} onPointerDown={(e) => { if (e.button !== 0) return; if (pointerId.current !== null) return; pointerId.current = e.pointerId; e.currentTarget.setPointerCapture(e.pointerId); start(); }} onPointerMove={(e) => { if (e.pointerId !== pointerId.current) return; const r = e.currentTarget.getBoundingClientRect(); if ( e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom ) { e.currentTarget.releasePointerCapture(e.pointerId); pointerId.current = null; cancel(); } }} onPointerUp={(e) => { if (e.pointerId !== pointerId.current) return; pointerId.current = null; cancel(); }} onPointerCancel={(e) => { if (e.pointerId !== pointerId.current) return; pointerId.current = null; cancel(); }} onKeyDown={(e) => { if (e.key !== ' ' && e.key !== 'Enter') return; e.preventDefault(); if (!e.repeat) start(); }} onKeyUp={(e) => { if (e.key !== ' ' && e.key !== 'Enter') return; e.preventDefault(); cancel(); }} onBlur={() => { pointerId.current = null; cancel(); }} className={cn( 'bloom-edge rounded-[14px]', 'bg-gradient-to-b from-white to-[#f4f4f5] text-neutral-900', 'dark:from-[#1d1d1d] dark:to-[#161716] dark:text-white', 'shadow-[0_1px_2px_0_rgba(0,0,0,0.05),0_5px_14px_-10px_rgba(0,0,0,0.1)]', 'dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.05),0_1px_2px_0_rgba(0,0,0,0.4),0_6px_16px_-10px_rgba(0,0,0,0.45)]', 'relative isolate cursor-pointer touch-none select-none overflow-hidden px-5 py-2.5', 'text-[13px] font-medium tracking-[-0.01em]', 'disabled:cursor-not-allowed disabled:opacity-60', FOCUS, className, )} {...props} > <span aria-hidden style={{ clipPath: clip, transition: clipTransition, backgroundColor: 'color-mix(in srgb, var(--hold-accent) 14%, transparent)', }} className="pointer-events-none absolute inset-0 rounded-[inherit]" /> <span className="relative grid place-items-center"> <motion.span initial={false} animate={{ opacity: confirmed ? 0 : 1, filter: confirmed ? blurOn : 'blur(0px)', }} transition={{ duration: confirmed ? 0.12 : 0.2, ease: EASE_OUT, }} className="relative col-start-1 row-start-1 whitespace-nowrap" > <span className="text-neutral-500 dark:text-white/50">{children}</span> <span aria-hidden style={{ clipPath: clip, transition: clipTransition }} className="absolute inset-0 whitespace-nowrap text-[color:var(--hold-accent)]" > {children} </span> </motion.span> <motion.span aria-hidden={!confirmed} initial={false} animate={{ opacity: confirmed ? 1 : 0, filter: confirmed ? 'blur(0px)' : blurOn, }} transition={{ duration: confirmed ? 0.2 : 0.12, ease: EASE_OUT, }} className="col-start-1 row-start-1 flex items-center gap-1.5 whitespace-nowrap text-[color:var(--hold-accent)]" > <Check size={16} strokeWidth={2} aria-hidden /> Done </motion.span> </span> </button> );}Usage
import { HoldButton } from '@/components/ui/hold-button';
export default function Example() {
return (
<HoldButton onConfirm={() => console.log('project deleted')}>
Hold to delete project
</HoldButton>
);
}Examples
A faster hold with a warm amber accent.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
onConfirm | () => void | - | Fired once when the hold completes. |
holdMs | number | 1500 | How long the button must be held, in milliseconds. |
accent | string | "#f0463a" | Accent used for the fill tint, label, and focus ring. |
className | string | - | Forwarded to the button. |
The button also accepts every native <button> prop.