Component
Hold to confirm
A button that only fires after being held down, with a fill progress.
- #arc
- #component
- #button
- #confirm
- #hold
- #destructive
- #gesture
1280px
Install
One command installs the files and packages this item needs.
TerminalTerminalTerminalbash
npx shadcn@latest add https://dsui.vercel.app/r/hold-to-confirm.jsonFiles
components/arc/hold-to-confirm/hold-to-confirm.module.csscomponents/arc/hold-to-confirm/hold-to-confirm.tsx
npm packages
motion@^14.2.0
Registry items it pulls in
Source
View as Markdownhold-to-confirm.module.csshold-to-confirm.module.csshold-to-confirm.module.csscss
/* The button hugs its label; the fill is a second, inverted copy of the face revealed by clip-path, so the text flips colour exactly at the edge. */
/* The default tone follows the selected accent; danger stays available for a truly irreversible action. */
.button { --hold-ink: var(--foreground); --hold-fill: var(--accent); --hold-on-fill: var(--accent-foreground); --hold-border: var(--border-strong); position: relative; display: inline-flex; min-height: var(--control-height-md); align-items: center; justify-content: center; padding: 0 var(--space-5); border: 1px solid var(--hold-border); border-radius: var(--radius-control); background: var(--surface); color: var(--hold-ink); font: inherit; font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); cursor: pointer; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; touch-action: manipulation; -webkit-tap-highlight-color: transparent; transition: background-color var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard), opacity var(--duration-fast) var(--ease-standard); }
.button[data-tone="danger"] { --hold-ink: var(--danger); --hold-fill: var(--danger); --hold-on-fill: var(--background); --hold-border: color-mix(in oklab, var(--danger) 32%, var(--border)); }
.button[data-tone="neutral"] { --hold-ink: var(--foreground); --hold-fill: var(--foreground); --hold-on-fill: var(--background); --hold-border: var(--border-strong); }
@media (hover: hover) and (pointer: fine) { .button:not(:disabled):not([aria-disabled="true"]):hover { background: color-mix(in oklab, var(--hold-fill) 6%, var(--surface)); } }
.button[aria-disabled="true"] { cursor: default; }
.button:disabled { cursor: not-allowed; opacity: .5; }
.face { display: inline-flex; align-items: center; gap: var(--space-2); white-space: nowrap; }
/* Covers the border too, and keeps its own rounded corners; the clip only ever cuts a straight leading edge. */
.fill { position: absolute; inset: -1px; display: flex; align-items: center; justify-content: center; border-radius: var(--radius-control); background: var(--hold-fill); color: var(--hold-on-fill); pointer-events: none; }
.iconSlot { display: grid; width: 18px; height: 18px; flex: none; place-items: center; }
.iconPhase { display: grid; grid-area: 1 / 1; place-items: center; }
.iconPhase svg { width: 18px; height: 18px; }
/* Clips the outgoing label while the frame narrows, with room for the rise and the soft blur. */
.labelFrame { position: relative; display: inline-flex; min-width: 0; clip-path: inset(-.6em -3px); }
.label { display: block; white-space: nowrap; }
.measure { position: absolute; top: 0; left: 0; visibility: hidden; white-space: nowrap; pointer-events: none; }
.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) { .button { transition: none; } }
hold-to-confirm.tsxhold-to-confirm.tsxhold-to-confirm.tsxtsx
"use client";
import { useEffect, useEffectEvent, useId, useRef, useState } from "react";
import type { ButtonHTMLAttributes, KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent, ReactNode } from "react";
import { AnimatePresence, animate, motion, useMotionValue, useTransform } from "motion/react";
import { useReducedMotion } from "../lib/use-reduced-motion";
import type { AnimationPlaybackControls, MotionValue, TargetAndTransition } from "motion/react";
import { motionTokens } from "../lib/motion-tokens";
import styles from "./hold-to-confirm.module.css";
import { UiIcon } from "../theme/ui-icon";
import { playUiSound } from "../sound/play";
/**
* A button that commits only after it is held, for destructive or hard to undo actions where a stray tap must not count. A fill tracks the hold on a
* linear timeline; letting go early rewinds it on a spring, and finishing morphs the label and icon into a done state. Space and Enter can be held too.
* Prefer a regular confirmation dialog when people need to read consequences first; use this when the consequence is already on screen.
*/
export interface HoldToConfirmProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children" | "onClick" | "onDrag" | "onDragEnd" | "onDragStart" | "onAnimationStart" | "onAnimationEnd"> {
/** The instruction and the action, for example “길게 눌러 프로젝트 삭제하기”. */
label: string;
/** Shown once the hold completes, for example “삭제했어요”. */
confirmedLabel?: string;
/** Called once when the hold completes. */
onConfirm: () => void;
/** Hold length in milliseconds. */
duration?: number;
icon?: ReactNode;
/** `accent` (default) fills with the selected accent; `danger` is for an irreversible action; `neutral` fills with the foreground. */
tone?: "accent" | "danger" | "neutral";
/** Controls the done state. Set it back to false to reset the button; leave it undefined to let the button keep its own state. */
confirmed?: boolean;
/** Reports when a hold starts and ends, for surrounding hints such as “계속 누르고 있어요”. */
onHoldChange?: (holding: boolean) => void;
}
const enter = [...motionTokens.ease.enter] as [number, number, number, number];
const standard = [...motionTokens.ease.standard] as [number, number, number, number];
const rest: TargetAndTransition = { opacity: 1, y: 0, scale: 1, filter: "blur(0px)" };
const textIn: TargetAndTransition = { opacity: 0, y: "0.3em", filter: `blur(${motionTokens.blur.soft}px)` };
const textOut: TargetAndTransition = { opacity: 0, y: "-0.3em", filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.fast, ease: standard } };
const iconIn: TargetAndTransition = { opacity: 0, scale: .6, filter: `blur(${motionTokens.blur.subtle}px)` };
const iconOut: TargetAndTransition = { ...iconIn, transition: { duration: motionTokens.duration.fast, ease: standard } };
const fadeIn: TargetAndTransition = { opacity: 0 };
const fadeOut: TargetAndTransition = { opacity: 0, transition: { duration: motionTokens.duration.instant } };
/** Scale rides the spring; opacity and blur tween so the blur never overshoots below zero. */
const iconEnter = { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast, ease: enter }, filter: { duration: motionTokens.duration.fast, ease: enter } };
/** The tick draws itself from its short stroke, the way a hand would write it. */
function DrawnCheck({ reduced }: { reduced: boolean }) {
return <svg width={18} height={18} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<motion.path d="M4 12.5l5 5L20 6.5" initial={reduced ? false : { pathLength: 0, opacity: 0 }} animate={{ pathLength: 1, opacity: 1 }} transition={{ pathLength: { duration: motionTokens.duration.moderate, ease: enter, delay: motionTokens.delay.short }, opacity: { duration: motionTokens.duration.micro, delay: motionTokens.delay.short } }} />
</svg>;
}
type FaceProps = { icon: ReactNode; text: string; done: boolean; width: MotionValue<number | "auto">; reduced: boolean; measure?: (node: HTMLSpanElement | null) => void };
/** Icon and label. The button renders it twice: once on the surface and once inside the fill, so the text changes colour exactly at the fill edge. */
function Face({ icon, text, done, width, reduced, measure }: FaceProps) {
return <span className={styles.face}>
<span className={styles.iconSlot}>
<AnimatePresence initial={false}>
<motion.span key={done ? "done" : "idle"} className={styles.iconPhase} initial={reduced ? fadeIn : iconIn} animate={rest} exit={reduced ? fadeOut : iconOut} transition={reduced ? { duration: motionTokens.duration.fast } : iconEnter}>
{done ? <DrawnCheck reduced={reduced} /> : icon}
</motion.span>
</AnimatePresence>
</span>
<motion.span className={styles.labelFrame} style={{ width }}>
{measure && <span ref={measure} className={styles.measure}>{text}</span>}
<AnimatePresence mode="popLayout" initial={false}>
<motion.span key={text} className={styles.label} initial={reduced ? fadeIn : textIn} animate={rest} exit={reduced ? fadeOut : textOut} transition={{ duration: reduced ? motionTokens.duration.fast : motionTokens.duration.standard, ease: enter }}>{text}</motion.span>
</AnimatePresence>
</motion.span>
</span>;
}
/** Springs the label frame to the width of new text, so the button morphs instead of snapping. A late web font or a reflow follows instantly. */
function useLabelWidth(reduced: boolean) {
const width = useMotionValue<number | "auto">("auto");
const [node, setNode] = useState<HTMLSpanElement | null>(null);
useEffect(() => {
if (!node || typeof ResizeObserver === "undefined") return;
let lastText: string | null = null;
let sizing: AnimationPlaybackControls | null = null;
const observer = new ResizeObserver(([entry]) => {
// Layout width, not the painted box: the done label is measured while the pressed button is still scaled down.
const next = entry?.borderBoxSize?.[0]?.inlineSize ?? node.offsetWidth;
const text = node.textContent;
const morph = lastText !== null && lastText !== text && !reduced && typeof width.get() === "number";
lastText = text;
sizing?.stop();
if (morph) sizing = animate(width, next, motionTokens.spring.morph);
else width.jump(next);
});
observer.observe(node);
return () => { observer.disconnect(); sizing?.stop(); };
}, [node, reduced, width]);
return [width, setNode] as const;
}
export function HoldToConfirm({ label, confirmedLabel = "완료", onConfirm, duration = 1200, icon = <UiIcon iconRole="delete" strokeWidth={1.75} />, tone = "accent", confirmed, onHoldChange, className, disabled, ...props }: HoldToConfirmProps) {
const reduced = useReducedMotion() ?? false;
const hintId = useId();
const [ownDone, setOwnDone] = useState(false);
const [completions, setCompletions] = useState(0);
const done = confirmed ?? ownDone;
const [holding, setHolding] = useState(false);
const progress = useMotionValue(0);
const scale = useMotionValue(1);
const clipPath = useTransform(progress, value => `inset(0 ${((1 - Math.min(1, Math.max(0, value))) * 100).toFixed(3)}% 0 0)`);
const [width, measure] = useLabelWidth(reduced);
const source = useRef<"pointer" | "key" | null>(null);
const pointerType = useRef("mouse");
const fill = useRef<AnimationPlaybackControls | null>(null);
const press = useRef<AnimationPlaybackControls | null>(null);
const button = useRef<HTMLButtonElement>(null);
// Assistive tech and voice control can only send a plain click, never a long press: for them a second click within a few seconds confirms.
const [armed, setArmed] = useState(false);
const armTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const text = done ? confirmedLabel : label;
const seconds = (duration / 1000).toLocaleString("ko-KR", { maximumFractionDigits: 1 });
function pressTo(pressed: boolean) {
press.current?.stop();
if (reduced) { scale.jump(1); return; }
const depth = (button.current?.offsetWidth ?? 0) > 220 ? .985 : .97;
press.current = animate(scale, pressed ? depth : 1, motionTokens.spring.snappy);
}
function rewind() {
fill.current?.stop();
if (reduced) progress.jump(0);
else fill.current = animate(progress, 0, { ...motionTokens.spring.smooth, velocity: 0 });
}
function stopHolding() {
source.current = null;
setHolding(false);
onHoldChange?.(false);
pressTo(false);
}
function complete() {
if (!source.current) return;
stopHolding();
if (pointerType.current === "touch") navigator.vibrate?.(12);
setOwnDone(true);
setCompletions(count => count + 1);
playUiSound("confirm");
onConfirm();
}
/** Starts or resumes the fill from wherever it is, so a quick re-press after letting go carries on instead of starting over. */
function begin(from: "pointer" | "key") {
if (done || disabled || source.current) return;
source.current = from;
setHolding(true);
onHoldChange?.(true);
pressTo(true);
fill.current?.stop();
fill.current = animate(progress, 1, { duration: (1 - progress.get()) * duration / 1000, ease: "linear", onComplete: complete });
}
function release() {
if (!source.current) return;
stopHolding();
rewind();
}
// A reset rewinds the fill as the label morphs back; a confirmation the parent declined rewinds too.
const syncFill = useEffectEvent(() => {
if (done && progress.get() < 1 && !source.current) { fill.current?.stop(); if (reduced) progress.jump(1); else fill.current = animate(progress, 1, motionTokens.spring.smooth); }
if (!done && progress.get() > 0 && !source.current) rewind();
});
useEffect(() => { syncFill(); }, [done, completions]);
useEffect(() => () => { fill.current?.stop(); press.current?.stop(); clearTimeout(armTimer.current); }, []);
function onPointerDown(event: ReactPointerEvent<HTMLButtonElement>) {
if (!event.isPrimary || event.button !== 0) return;
pointerType.current = event.pointerType;
event.currentTarget.setPointerCapture(event.pointerId);
begin("pointer");
}
function onPointerMove(event: ReactPointerEvent<HTMLButtonElement>) {
if (source.current !== "pointer") return;
// Sliding well off the button cancels, the way a native press does.
const box = event.currentTarget.getBoundingClientRect(), slack = 24;
if (event.clientX < box.left - slack || event.clientX > box.right + slack || event.clientY < box.top - slack || event.clientY > box.bottom + slack) release();
}
function onClick(event: ReactMouseEvent<HTMLButtonElement>) {
// A real mouse, touch or pen click has detail >= 1 and went through the hold above; detail 0 is a synthetic click.
if (event.detail !== 0 || done || disabled || source.current) return;
clearTimeout(armTimer.current);
if (!armed) {
setArmed(true);
armTimer.current = setTimeout(() => setArmed(false), 5000);
return;
}
setArmed(false);
source.current = "key";
complete();
}
function onKeyDown(event: ReactKeyboardEvent<HTMLButtonElement>) {
if (event.key !== " " && event.key !== "Enter") return;
event.preventDefault();
if (!event.repeat) begin("key");
}
function onKeyUp(event: ReactKeyboardEvent<HTMLButtonElement>) {
if (event.key !== " " && event.key !== "Enter") return;
event.preventDefault();
if (source.current === "key") release();
}
return <>
<motion.button {...props} ref={button} type="button" className={[styles.button, className].filter(Boolean).join(" ")} data-tone={tone} data-state={done ? "done" : holding ? "holding" : "idle"}
disabled={disabled} aria-disabled={done || undefined} aria-label={text} aria-describedby={done ? undefined : hintId} style={{ scale }}
onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={release} onPointerCancel={release} onLostPointerCapture={release}
onClick={onClick} onKeyDown={onKeyDown} onKeyUp={onKeyUp} onBlur={event => { release(); if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setArmed(false); }} onContextMenu={event => event.preventDefault()}>
<Face icon={icon} text={text} done={done} width={width} reduced={reduced} measure={measure} />
<motion.span className={styles.fill} style={{ clipPath }} aria-hidden="true">
<Face icon={icon} text={text} done={done} width={width} reduced={reduced} />
</motion.span>
</motion.button>
<span id={hintId} className={styles.srOnly}>{`${seconds}초 동안 길게 눌러 확인해 주세요. 키보드는 Space나 Enter를 길게 눌러 주세요. 길게 누를 수 없다면 두 번 눌러도 확인돼요.`}</span>
<span className={styles.srOnly} role="status">{done ? confirmedLabel : armed ? "한 번 더 누르면 확인돼요." : ""}</span>
</>;
}
export default HoldToConfirm;