LAB/ Tanishk Interface Lab
InputstableReactPointer EventsKeyboard

Hold to Confirm

Deliberate friction for actions that deserve a second of intent.

Live preview

Kestrel · release console

Ship release v2.4.0

v2.4.0
7 changes since v2.3.1Preflight checks green

Ships the current build to everyone on the stable channel.

Press and hold for about 0.9 seconds to confirm. Release early to cancel.

Nothing has shipped yet.

Press and hold to commit — release early to cancel. Space works too.

ActionProgressReset after confirm

Press and hold the button — Space works too

01What it does

Publishing, deploying, overwriting and accepting-all are the actions where a click is too cheap. Press and hold for a moment — with visible progress, a cancel path on release, and keyboard and touch equivalents — and the action commits. No modal, no checkbox theatre, and the friction is exactly as long as the host says it should be.

In this previewThree consequential actions with a visible hold. Release early to see the cancel, or hold to the end — the control reports what it did in a live region.

02Install

Registry install

Works with the shadcn CLI in any project that has a components.json. The component lands in components/lab/hold-to-confirm/ with its stylesheet beside it.

npx shadcn@latest add <lab-origin>/r/hold-to-confirm.json

No npm packages are installed — the component is self-contained. Prefer manual copying? Every file is below under Source.

  • components/lab/hold-to-confirm/hold-to-confirm.tsxcomponent
  • components/lab/hold-to-confirm/hold-to-confirm.cssstyles

Machine-readable item: /r/hold-to-confirm.json ↗

03Usage

hold-to-confirm.usage.tsx
import { HoldToConfirm } from "@/components/lab/hold-to-confirm/hold-to-confirm";

export function Publish() {
return (
<HoldToConfirm
label="Publish release"
duration={900}
onConfirm={() => publish()}
/>
);
}

04Props

Props accepted by Hold to Confirm
PropTypeDefaultNotes
labelstring—Idle label, e.g. “Publish release”.
durationnumber900Hold time in milliseconds before commit.
onConfirm() => void—Fired once the hold completes.
variant"accent" | "danger""accent"Tone of the control; danger is reserved for destructive actions.
progress"fill" | "ring""fill"How the hold is drawn.
resetAfterConfirmbooleantrueReturn to idle a moment after confirming.

05Accessibility

  • Keyboard: hold Space or Enter; the progress bar is a real progressbar with aria-valuenow.
  • Releasing early cancels with a visible retreat, announced as “Cancelled”.
  • Touch: press-and-hold works natively; the context menu is suppressed only on the control itself.
  • The hold length is generous and documented in the accessible description; hosts should keep destructive alternatives for assistive tech.

06Source

2 files, 522 lines. Nothing private, no configuration required.

components/lab/hold-to-confirm/hold-to-confirm.tsx— press enter to show or hide the source
tsx
"use client";

import {
useCallback,
useEffect,
useId,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
type PointerEvent as ReactPointerEvent,
} from "react";
import "./hold-to-confirm.css";

export type HoldToConfirmVariant = "accent" | "danger";
export type HoldToConfirmProgress = "fill" | "ring";

export type HoldToConfirmProps = {
/** Idle label, e.g. "Publish release". */
label: string;
/** Hold time in milliseconds before the action commits. */
duration?: number;
/** Fired exactly once per completed hold. */
onConfirm?: () => void;
/** Tone of the control; danger is reserved for destructive actions. */
variant?: HoldToConfirmVariant;
/** How the hold is drawn: a background fill or a ring next to the label. */
progress?: HoldToConfirmProgress;
/** Return to idle a moment after confirming. */
resetAfterConfirm?: boolean;
disabled?: boolean;
className?: string;
};

type HoldPhase = "idle" | "holding" | "done";

/** How long a released, incomplete hold takes to ease back to zero. */
const CANCEL_EASE_MS = 180;
/** How long the "Done" state stays before returning to idle. */
const DONE_RESET_MS = 1200;

function isHoldKey(key: string) {
return key === " " || key === "Enter";
}

function prefersReducedMotion() {
return (
typeof window !== "undefined" &&
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches
);
}

/**
* HOLD TO CONFIRM — deliberate friction for consequential actions.
*
* Press and hold (pointer, touch or a held Space / Enter) and a real
* progress reading advances over `duration`; releasing early eases the
* progress back and announces the cancel, while a completed hold fires
* `onConfirm` exactly once and shows a done state. Every loop is
* frame-rate independent (real elapsed time), and cancels on release,
* on blur, on hidden tabs and on unmount.
*/
export function HoldToConfirm({
label,
duration = 900,
onConfirm,
variant = "accent",
progress = "fill",
resetAfterConfirm = true,
disabled = false,
className,
}: HoldToConfirmProps) {
const [phase, setPhase] = useState<HoldPhase>("idle");
const [announcement, setAnnouncement] = useState("");

const rootRef = useRef<HTMLSpanElement>(null);
const barRef = useRef<HTMLSpanElement>(null);
const rafRef = useRef<number | null>(null);
const doneTimerRef = useRef<number | null>(null);
const holdStartRef = useRef(0);
const holdFromRef = useRef(0);
const progressRef = useRef(0);
const lastPctRef = useRef(-1);
const phaseRef = useRef<HoldPhase>("idle");
const durationRef = useRef(duration);
const confirmRef = useRef(onConfirm);
const resetRef = useRef(resetAfterConfirm);
const disabledRef = useRef(disabled);
const descId = useId();

useEffect(() => {
durationRef.current = duration;
confirmRef.current = onConfirm;
resetRef.current = resetAfterConfirm;
disabledRef.current = disabled;
});

/** Writes the live progress to the CSS variable and the ARIA bar. */
const writeProgress = useCallback((value: number) => {
const p = Math.min(1, Math.max(0, value));
progressRef.current = p;
rootRef.current?.style.setProperty("--hold-progress", p.toFixed(4));
const bar = barRef.current;
if (!bar) return;
const pct = Math.round(p * 100);
if (pct !== lastPctRef.current) {
lastPctRef.current = pct;
bar.setAttribute("aria-valuenow", String(pct));
bar.setAttribute("aria-valuetext", `${pct}%`);
}
}, []);

const stopLoop = useCallback(() => {
if (rafRef.current !== null) {
window.cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
}, []);

const clearDoneTimer = useCallback(() => {
if (doneTimerRef.current !== null) {
window.clearTimeout(doneTimerRef.current);
doneTimerRef.current = null;
}
}, []);

/* Re-announces a repeated message by alternating an invisible char. */
const announce = useCallback((text: string) => {
setAnnouncement((current) => (current === text ? `${text}\u200B` : text));
}, []);

const complete = useCallback(() => {
stopLoop();
writeProgress(1);
phaseRef.current = "done";
setPhase("done");
announce("Done");
confirmRef.current?.();
if (resetRef.current) {
doneTimerRef.current = window.setTimeout(() => {
doneTimerRef.current = null;
phaseRef.current = "idle";
setPhase("idle");
writeProgress(0);
}, DONE_RESET_MS);
}
}, [announce, stopLoop, writeProgress]);

const startHold = useCallback(() => {
if (disabledRef.current || phaseRef.current === "holding") return;
stopLoop();
clearDoneTimer();
if (phaseRef.current === "done") {
lastPctRef.current = -1;
writeProgress(0);
}
phaseRef.current = "holding";
setPhase("holding");

holdFromRef.current = progressRef.current;
holdStartRef.current =
performance.now() - holdFromRef.current * Math.max(1, durationRef.current);

const step = (now: number) => {
const ms = Math.max(1, durationRef.current);
const value = holdFromRef.current + (now - holdStartRef.current) / ms;
if (value >= 1) {
rafRef.current = null;
complete();
return;
}
writeProgress(value);
rafRef.current = window.requestAnimationFrame(step);
};
rafRef.current = window.requestAnimationFrame(step);
}, [clearDoneTimer, complete, stopLoop, writeProgress]);

const release = useCallback(() => {
if (phaseRef.current !== "holding") return;
stopLoop();
phaseRef.current = "idle";
setPhase("idle");
announce("Cancelled");

const from = progressRef.current;
if (from <= 0) return;
if (prefersReducedMotion()) {
writeProgress(0);
return;
}

const started = performance.now();
const step = (now: number) => {
const t = Math.min(1, (now - started) / CANCEL_EASE_MS);
if (t >= 1) {
rafRef.current = null;
writeProgress(0);
return;
}
const eased = 1 - Math.pow(1 - t, 3);
writeProgress(from * (1 - eased));
rafRef.current = window.requestAnimationFrame(step);
};
rafRef.current = window.requestAnimationFrame(step);
}, [announce, stopLoop, writeProgress]);

/* Cancel when the window loses focus or the tab is hidden. */
useEffect(() => {
const onBlur = () => release();
const onVisibility = () => {
if (document.hidden) release();
};
window.addEventListener("blur", onBlur);
document.addEventListener("visibilitychange", onVisibility);
return () => {
window.removeEventListener("blur", onBlur);
document.removeEventListener("visibilitychange", onVisibility);
};
}, [release]);

/* Cancel if the host disables the control mid-hold. */
useEffect(() => {
if (disabled) release();
}, [disabled, release]);

/* Never leave a frame loop or timer behind. */
useEffect(() => {
return () => {
if (rafRef.current !== null) window.cancelAnimationFrame(rafRef.current);
if (doneTimerRef.current !== null) window.clearTimeout(doneTimerRef.current);
};
}, []);

const onPointerDown = (event: ReactPointerEvent<HTMLButtonElement>) => {
if (disabledRef.current) return;
if (event.pointerType === "mouse" && event.button !== 0) return;
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
/* Capture is an enhancement; the pointerdown still starts the hold. */
}
startHold();
};

const endPointer = (event: ReactPointerEvent<HTMLButtonElement>) => {
try {
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
} catch {
/* The capture may already be gone. */
}
release();
};

const onKeyDown = (event: ReactKeyboardEvent<HTMLButtonElement>) => {
if (disabledRef.current || !isHoldKey(event.key)) return;
/* Space would scroll the page; Enter would synthesise a click early. */
if (event.key !== "Enter") event.preventDefault();
if (event.repeat) return;
startHold();
};

const onKeyUp = (event: ReactKeyboardEvent<HTMLButtonElement>) => {
if (!isHoldKey(event.key)) return;
release();
};

const seconds = (Math.max(1, duration) / 1000).toFixed(1);

return (
<span
ref={rootRef}
className={`hc-root${className ? ` ${className}` : ""}`}
data-phase={phase}
data-variant={variant}
data-progress={progress}
>
<button
type="button"
className="hc-button"
disabled={disabled}
aria-describedby={descId}
aria-label={phase === "done" ? `${label} — done` : undefined}
onPointerDown={onPointerDown}
onPointerUp={endPointer}
onPointerCancel={endPointer}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
onContextMenu={(event) => event.preventDefault()}
onDragStart={(event) => event.preventDefault()}
>
<span className="hc-fill" aria-hidden="true" />
<span className="hc-face">
{phase === "done" ? (
<>
<svg
className="hc-check"
viewBox="0 0 16 16"
width="15"
height="15"
aria-hidden="true"
focusable="false"
>
<path
d="M3 8.5 6.2 11.6 13 4.7"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<span className="hc-label">Done</span>
</>
) : (
<>
{progress === "ring" ? (
<svg
className="hc-ring"
viewBox="0 0 24 24"
width="16"
height="16"
aria-hidden="true"
focusable="false"
>
<circle className="hc-ring-track" cx="12" cy="12" r="9" />
<circle className="hc-ring-arc" cx="12" cy="12" r="9" />
</svg>
) : null}
<span className="hc-label">{label}</span>
</>
)}
</span>
</button>

<span
ref={barRef}
className="hc-sr"
role="progressbar"
aria-label={`Hold progress for ${label}`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={0}
aria-valuetext="0%"
/>
<span className="hc-sr" id={descId}>
Press and hold for about {seconds} seconds to confirm. Release early to
cancel.
</span>
<span className="hc-sr" role="status" aria-live="polite">
{announcement}
</span>
</span>
);
}
components/lab/hold-to-confirm/hold-to-confirm.css— press enter to show or hide the source
css
/* ============================================================
HOLD TO CONFIRM — self-contained styles.
Tokens are scoped on the root with fallbacks, so the control
carries its own theme into any codebase.
============================================================ */

.hc-root {
--hc-accent: #4f27e0;
--hc-accent-deep: #32119c;
--hc-danger: #b3261e;
--hc-danger-deep: #7c1a15;
--hc-base: var(--hc-accent);
--hc-fill: var(--hc-accent-deep);
--hc-focus: #4f27e0;
--hc-on-base: #ffffff;
--hc-ring-track: rgba(255, 255, 255, 0.34);
--hc-ring-arc: #ffffff;

position: relative;
display: inline-flex;
vertical-align: middle;
}

.hc-root[data-variant="danger"] {
--hc-base: var(--hc-danger);
--hc-fill: var(--hc-danger-deep);
--hc-focus: #b3261e;
}

.hc-button {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 9px;
min-height: 42px;
padding: 0 20px;
border: 0;
border-radius: 999px;
background: var(--hc-base);
color: var(--hc-on-base);
font: inherit;
font-size: 0.9375rem;
font-weight: 600;
letter-spacing: -0.005em;
line-height: 1;
cursor: pointer;
overflow: hidden;
isolation: isolate;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
touch-action: none;
box-shadow:
0 1px 2px rgba(20, 20, 24, 0.14),
0 10px 22px -14px rgba(20, 20, 24, 0.4);
transition:
box-shadow 160ms ease,
transform 120ms ease,
opacity 160ms ease;
}

.hc-button:hover:not(:disabled) {
box-shadow:
0 2px 4px rgba(20, 20, 24, 0.16),
0 14px 26px -14px rgba(20, 20, 24, 0.45);
}

.hc-button:active:not(:disabled) {
transform: translateY(0.5px);
}

.hc-button:focus-visible {
outline: 2px solid var(--hc-focus);
outline-offset: 3px;
}

.hc-button:disabled {
cursor: not-allowed;
opacity: 0.48;
box-shadow: none;
}

/*
* The sweeping fill is clipped rather than resized, so the frame loop
* only ever writes one custom property: --hold-progress (0–1).
*/
.hc-fill {
position: absolute;
inset: 0;
z-index: 0;
border-radius: inherit;
background: var(--hc-fill);
clip-path: inset(0 calc(100% - var(--hold-progress, 0) * 100%) 0 0);
pointer-events: none;
}

.hc-root[data-progress="ring"] .hc-fill {
display: none;
}

.hc-face {
position: relative;
z-index: 1;
display: inline-flex;
align-items: center;
gap: 9px;
white-space: nowrap;
}

.hc-label {
display: block;
}

.hc-check {
flex: none;
}

/* Ring drawing: r = 9 => circumference ≈ 56.55. */
.hc-ring {
flex: none;
transform: rotate(-90deg);
}

.hc-ring-track,
.hc-ring-arc {
fill: none;
stroke-width: 3;
}

.hc-ring-track {
stroke: var(--hc-ring-track);
}

.hc-ring-arc {
stroke: var(--hc-ring-arc);
stroke-linecap: round;
stroke-dasharray: 56.55;
stroke-dashoffset: calc(56.55 - 56.55 * var(--hold-progress, 0));
}

/* Screen-reader-only carriers: progressbar, description, live region. */
.hc-sr {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}

@media (prefers-reduced-motion: reduce) {
.hc-button {
transition: none;
}

.hc-button:active:not(:disabled) {
transform: none;
}
}