Hold to Confirm
A destructive button you have to hold: the fill sweeps across, letting go early springs it back.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/hold-confirm.json'use client'
import { useEffect, useId, useRef, useState } from 'react'
import './hold-confirm.css'
export interface HoldConfirmProps {
label?: string
doneLabel?: string
/** How long to hold, in ms. */
duration?: number
tone?: 'danger' | 'neutral' | 'brand'
onConfirm?: () => void
fill?: 'dark' | 'light'
/** Demo hook: drive the hold from outside. */
holding?: boolean
className?: string
}
/**
* A destructive action that needs a deliberate press: hold with the pointer, Space or Enter until
* the fill completes. Letting go early springs it back.
*/
export function HoldConfirm({ label = 'Hold to delete', doneLabel = 'Deleted', duration = 1400, tone = 'danger', onConfirm, fill = 'dark', holding, className }: HoldConfirmProps) {
const [down, setDown] = useState(false)
const [done, setDone] = useState(false)
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
const id = useId()
const active = (down || Boolean(holding)) && !done
useEffect(() => {
if (!active) return
timer.current = setTimeout(() => {
setDone(true)
onConfirm?.()
setTimeout(() => setDone(false), 1800)
}, duration)
return () => {
if (timer.current) clearTimeout(timer.current)
}
}, [active, duration, onConfirm])
const start = () => !done && setDown(true)
const stop = () => setDown(false)
return (
<div className={['sd-hc', className].filter(Boolean).join(' ')} data-fill={fill} data-tone={tone}>
<button
type="button"
className="sd-hc__btn"
data-active={active || undefined}
data-done={done || undefined}
aria-describedby={`${id}-h`}
style={{ '--hc-ms': `${duration}ms` } as React.CSSProperties}
onPointerDown={start}
onPointerUp={stop}
onPointerLeave={stop}
onPointerCancel={stop}
onKeyDown={(e) => {
if ((e.key === ' ' || e.key === 'Enter') && !e.repeat) (e.preventDefault(), start())
}}
onKeyUp={(e) => (e.key === ' ' || e.key === 'Enter') && stop()}
onContextMenu={(e) => e.preventDefault()}
>
<span className="sd-hc__fill" aria-hidden />
<span className="sd-hc__label">
<svg viewBox="0 0 16 16" aria-hidden className="sd-hc__icon">
{done ? <path d="m3.5 8.5 3 3 6-7" /> : <path d="M3 4.5h10M6.5 4.5V3h3v1.5M4.5 4.5l.6 8.2c0 .4.4.8.8.8h4.2c.4 0 .8-.4.8-.8l.6-8.2" />}
</svg>
{done ? doneLabel : label}
</span>
</button>
<p id={`${id}-h`} className="sd-hc__hint" aria-live="polite">
{done ? `${doneLabel}.` : active ? 'Keep holding…' : 'Press and hold to confirm'}
</p>
</div>
)
}.sd-hc { --btn: #262626; --fg: #fafafa; --mute: #8e8e8e; --tone: #ef4444; display: grid; justify-items: center; gap: 12px; color: var(--fg); }
.sd-hc[data-fill='light'] { --btn: #fff; --fg: #111; --mute: #6f6f6f; }
.sd-hc[data-tone='neutral'] { --tone: #a3a3a3; }
.sd-hc[data-tone='brand'] { --tone: #6366f1; }
.sd-hc__btn { position: relative; overflow: hidden; height: 56px; padding: 0 28px; border-radius: 999px; background: var(--btn); color: var(--fg); font-size: 16px; font-weight: 500;
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--tone) 45%, transparent), 0 12px 30px -14px rgb(0 0 0 / 0.6); transition: transform 300ms cubic-bezier(0.3, 1.5, 0.4, 1); touch-action: none; user-select: none; }
.sd-hc__btn[data-active] { transform: scale(0.97); }
.sd-hc__fill { position: absolute; inset: 0; background: var(--tone); transform-origin: left; transform: scaleX(0); transition: transform 420ms cubic-bezier(0.3, 1.3, 0.4, 1); }
.sd-hc__btn[data-active] .sd-hc__fill { transform: scaleX(1); transition: transform var(--hc-ms) linear; }
.sd-hc__btn[data-done] .sd-hc__fill { transform: scaleX(1); }
.sd-hc__label { position: relative; display: inline-flex; align-items: center; gap: 9px; }
.sd-hc__btn[data-active] .sd-hc__label, .sd-hc__btn[data-done] .sd-hc__label { color: #fff; }
.sd-hc__icon { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
.sd-hc__btn[data-done] .sd-hc__icon { animation: sd-hc-pop 450ms cubic-bezier(0.3, 1.8, 0.4, 1); }
@keyframes sd-hc-pop { from { transform: scale(0.3); } }
.sd-hc__btn:focus-visible { outline: 2px solid var(--fg); outline-offset: 3px; }
.sd-hc__hint { margin: 0; font-size: 13px; color: var(--mute); }
@media (prefers-reduced-motion: reduce) { .sd-hc__btn, .sd-hc__icon { transition: none !important; animation: none !important; } .sd-hc__btn[data-active] .sd-hc__fill { transition: transform var(--hc-ms) linear; } }Overview
Replaces a confirm dialog for one dangerous action. Hold with the pointer, Space or Enter; the fill runs for the set duration, then the icon pops to a tick.
It resets itself after a moment, so the same button can be used again.
When to use
- Deleting, revoking or publishing where one accidental click is costly.
- Touch interfaces where a modal confirm is heavy.
When not to use
- Routine actions: the hold slows people down.
- Users who may have motor impairments with sustained presses; offer a confirm dialog alternative.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/hold-confirm.json$
npx shadcn@latest add https://sundaeui.dev/r/hold-confirm.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/hold-confirm.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/hold-confirm.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy hold-confirm.tsx and hold-confirm.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { HoldConfirm } from '@/components/sundae/hold-confirm'
<HoldConfirm label="Hold to delete" duration={1400} onConfirm={deleteProject} />Props
| Prop | Type | Default |
|---|---|---|
label / doneLabelLabels before and after. | string | 'Hold to delete' / 'Deleted' |
durationHold time in ms. | number | 1400 |
toneFill colour. | 'danger' | 'neutral' | 'brand' | 'danger' |
onConfirmCalled when the hold completes. | () => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- A native button; the hold works with Space and Enter as well as the pointer.
- Instructions are linked with aria-describedby and the status ("Keep holding…", "Deleted.") is announced.
- Reduced motion keeps the linear fill (it is the information) and drops the bounce.
Customization
- Every colour is a CSS custom property on the root class; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- Hold Space / Enter
- Confirm
- Release early
- Cancel
FAQ
- Is Hold to Confirm free?
- Yes. Hold to Confirm is free for personal and commercial projects, with full source on this page.
- How do I install Hold to Confirm?
- Run npx shadcn@latest add https://sundaeui.dev/r/hold-confirm.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Hold to Confirm support keyboard and screen readers?
- A native button; the hold works with Space and Enter as well as the pointer. Instructions are linked with aria-describedby and the status ("Keep holding…", "Deleted.") is announced.
- Does Hold to Confirm respect reduced motion?
- Reduced motion keeps the linear fill (it is the information) and drops the bounce.
More like this
All components →Inline Confirm
Components · UnderstatedToast Stack
Components · Refined- Pro
Swipe Actions
Components · Refined Follow Button
Components · RefinedDownload Button
Components · RefinedBookmark Button
Components · Refined