Hold to confirm
Press and hold. A fill sweeps across while a ring draws round the icon.
Code
1. Install
npm i framer-motion lucide-react
2. Files
"use client";
// HoldConfirm: press and hold. A fill sweeps across while a ring draws round the icon. Let go early and both drain
// back quickly; hold it through and the label flips, a thud plays and the button resets itself. Space and Enter hold too.
import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent, type ReactNode } from "react";
import { motion, useMotionValue, type MotionStyle } from "framer-motion";
import { Trash2 } from "lucide-react";
export type HoldConfirmCue = "tactile-detent" | "crumple" | "tactile-hold" | "tactile-back";
export type HoldConfirmProps = {
/** Idle label. */
label?: string;
/** Label shown once the hold completes. */
doneLabel?: string;
/** How long the hold takes, in ms. */
hold?: number;
/** Called once when the hold completes. */
onConfirm?: () => void;
/** Icon inside the ring, 16px. Defaults to a trash can. */
icon?: ReactNode;
/** Sound hook: "tactile-hold" on hold, "tactile-detent" ticks while filling (with `at`), "crumple" when done, "tactile-back" on early release. */
onSound?: (cue: HoldConfirmCue, opts?: { at?: number }) => void;
className?: string;
style?: CSSProperties;
};
const TICKS = 6; // ticks per full fill
const TICK_GAP = 60; // ms; never tick faster than this
const RESET_AFTER = 1600;
const DRAIN_SPEED = 2.4; // letting go drains this many times faster than the fill climbs
const clamp01 = (n: number) => Math.min(1, Math.max(0, n));
export function HoldConfirm({ label = "Hold to delete", doneLabel = "Deleted", hold = 1100, onConfirm, icon, onSound, className, style }: HoldConfirmProps) {
const fill = useMotionValue(0);
const frame = useRef(0);
const ticks = useRef({ count: 0, at: 0 });
const resetTimer = useRef(0);
const [phase, setPhase] = useState<"idle" | "held" | "done">("idle");
useEffect(() => {
return () => {
cancelAnimationFrame(frame.current);
clearTimeout(resetTimer.current);
};
}, []);
// a soft tick each sixth of the way up, never faster than TICK_GAP
const tickAt = (progress: number, now: number) => {
const count = Math.floor(progress * TICKS);
if (count <= ticks.current.count) return;
ticks.current.count = count;
if (now - ticks.current.at < TICK_GAP) return;
ticks.current.at = now;
onSound?.("tactile-detent", { at: progress });
};
const finish = () => {
onSound?.("crumple");
setPhase("done");
onConfirm?.();
resetTimer.current = window.setTimeout(() => {
setPhase("idle");
fill.set(0);
ticks.current.count = 0;
}, RESET_AFTER);
};
// Steps the fill from wherever it is. Climbing is steady; draining eases out on a cubic, so a release is quick
// at first and lands softly.
const sweep = (direction: "up" | "down") => {
cancelAnimationFrame(frame.current);
const origin = fill.get();
const began = performance.now();
const step = (now: number) => {
const progress = (now - began) / hold;
const next = direction === "up" ? clamp01(origin + progress) : origin * (1 - clamp01(progress * DRAIN_SPEED)) ** 3;
fill.set(next);
if (direction === "up") {
tickAt(next, now);
if (next >= 1) return finish();
} else if (next <= 0) {
ticks.current.count = 0;
return;
}
frame.current = requestAnimationFrame(step);
};
frame.current = requestAnimationFrame(step);
};
const grab = () => {
if (phase !== "idle") return;
setPhase("held");
onSound?.("tactile-hold");
sweep("up");
};
const letGo = () => {
if (phase !== "held") return;
setPhase("idle");
onSound?.("tactile-back");
sweep("down");
};
const down = (ev: PointerEvent<HTMLButtonElement>) => {
if (phase === "done") return;
try {
ev.currentTarget.setPointerCapture(ev.pointerId);
} catch {
// the pointer is already gone; the hold still starts
}
grab();
};
const spacebar = (ev: KeyboardEvent) => ev.key === " " || ev.key === "Enter";
return (
<motion.button
type="button"
className={["thc-root", className].filter(Boolean).join(" ")}
data-held={phase === "held" || undefined}
data-done={phase === "done" || undefined}
style={{ "--thc-p": fill, ...style } as MotionStyle}
onPointerDown={down}
onPointerUp={letGo}
onPointerCancel={letGo}
onPointerLeave={letGo}
onBlur={letGo}
onKeyDown={(ev) => {
if (!spacebar(ev)) return;
ev.preventDefault();
if (!ev.repeat) grab();
}}
onKeyUp={(ev) => spacebar(ev) && letGo()}
>
<span className="thc-fill" aria-hidden />
<span className="thc-ico">
<svg className="thc-ring" viewBox="0 0 36 36" aria-hidden>
<circle cx="18" cy="18" r="16" pathLength={1} />
</svg>
{icon ?? <Trash2 size={16} strokeWidth={2} />}
</span>
<span className="thc-label">{phase === "done" ? doneLabel : label}</span>
</motion.button>
);
}
.thc-root,
.thc-root * {
box-sizing: border-box;
}
.thc-root {
--thc-p: 0;
position: relative;
height: 48px;
padding: 0 20px 0 10px;
border: 0;
border-radius: 999px;
background: var(--ts-raised);
color: var(--ts-danger);
box-shadow: var(--ts-edge);
font: inherit;
font-family: var(--ts-font, system-ui, sans-serif);
font-size: 14px;
font-weight: 500;
letter-spacing: -0.01em;
display: inline-flex;
align-items: center;
gap: 10px;
overflow: hidden;
isolation: isolate;
cursor: pointer;
user-select: none;
touch-action: none;
-webkit-tap-highlight-color: transparent;
transition: scale 0.22s cubic-bezier(.22, 1, .36, 1), color 0.22s cubic-bezier(.22, 1, .36, 1);
}
.thc-root[data-held] {
scale: 0.97;
}
.thc-root[data-done] {
color: #fff;
background: var(--ts-danger);
}
.thc-root:focus-visible {
outline: 0;
box-shadow: 0 0 0 2px var(--ts-raised), 0 0 0 4px var(--ts-accent);
}
.thc-fill {
position: absolute;
inset: 0;
z-index: -1;
background: color-mix(in srgb, var(--ts-danger) 14%, transparent);
transform: scaleX(var(--thc-p));
transform-origin: 0 50%;
}
.thc-ico {
position: relative;
width: 32px;
height: 32px;
display: grid;
place-items: center;
}
.thc-ring {
position: absolute;
inset: 0;
fill: none;
stroke: currentColor;
stroke-width: 2.5;
stroke-linecap: round;
rotate: -90deg;
opacity: clamp(0, calc(var(--thc-p) * 30), 1);
}
.thc-ring circle {
stroke-dasharray: 1;
stroke-dashoffset: calc(1 - var(--thc-p));
}
.thc-root[data-done] .thc-ring circle {
stroke-dashoffset: 0;
}
.thc-label {
position: relative;
}
/*
* Teseria tokens — the only colours, fonts and corner radii a block reads.
*
* Every name sits under --ts- so it never collides with your app's own variables. Include this file once, or point
* the names at your theme (e.g. --ts-accent: var(--brand)). Light by default; dark follows [data-theme="dark"] on
* any ancestor, or the OS setting unless [data-theme="light"] is set.
*
* Surfaces page → raised (cards, popovers) · sunken (wells, tracks) · tint-1..3 (neutral fills, light to deep)
* Text fg → secondary → tertiary → muted → faint · fg-inverse sits on an fg-coloured fill
* Lines border · edge (the box-shadow a filled surface uses instead of an outline: soft in light, nothing in
* dark; composes with other shadows)
* Signals accent · focus · success · warning · danger
* Corners controls are always pills · r-panel (dialogs, sheets, cards) · r-pop (menus, popovers, toasts) · r-inner
* (thumbnails, inner cards); a row inside a panel uses the panel radius minus the panel padding
*/
:root {
--ts-font: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--ts-r-panel: 24px;
--ts-r-pop: 18px;
--ts-r-inner: 12px;
--ts-page: #ffffff;
--ts-raised: #ffffff;
--ts-sunken: #f3f2f0;
--ts-tint-1: #f5f4f2;
--ts-tint-2: #ecebe8;
--ts-tint-3: #e3e1dd;
--ts-fg: #161514;
--ts-fg-rgb: 22, 21, 20;
--ts-fg-secondary: #3a3836;
--ts-fg-tertiary: #585552;
--ts-fg-muted: #6d6a66;
--ts-fg-faint: #89857f;
--ts-fg-inverse: #ffffff;
--ts-border: rgba(22, 21, 20, 0.1);
--ts-edge: 0 0 0 1px rgba(22, 21, 20, 0.07), 0 1px 2px rgba(22, 21, 20, 0.05);
--ts-accent: #3446eb;
--ts-focus: #3446eb;
--ts-success: #13804a;
--ts-warning: #a9650b;
--ts-danger: #df474e;
}
:root[data-theme="dark"] {
--ts-page: #0b0a09;
--ts-raised: #24221f;
--ts-sunken: #0d0c0b;
--ts-tint-1: #131211;
--ts-tint-2: #1b1a18;
--ts-tint-3: #2b2926;
--ts-fg: #f6f5f3;
--ts-fg-rgb: 246, 245, 243;
--ts-fg-secondary: #bab8b4;
--ts-fg-tertiary: #9e9b97;
--ts-fg-muted: #8e8b86;
--ts-fg-faint: #72706c;
--ts-fg-inverse: #0b0a09;
--ts-border: rgba(246, 245, 243, 0.1);
--ts-edge: 0 0 #0000;
--ts-accent: #3446eb;
--ts-focus: #7f8cff;
--ts-success: #3ccf80;
--ts-warning: #efa130;
--ts-danger: #df474e;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ts-page: #0b0a09;
--ts-raised: #24221f;
--ts-sunken: #0d0c0b;
--ts-tint-1: #131211;
--ts-tint-2: #1b1a18;
--ts-tint-3: #2b2926;
--ts-fg: #f6f5f3;
--ts-fg-rgb: 246, 245, 243;
--ts-fg-secondary: #bab8b4;
--ts-fg-tertiary: #9e9b97;
--ts-fg-muted: #8e8b86;
--ts-fg-faint: #72706c;
--ts-fg-inverse: #0b0a09;
--ts-border: rgba(246, 245, 243, 0.1);
--ts-edge: 0 0 #0000;
--ts-accent: #3446eb;
--ts-focus: #7f8cff;
--ts-success: #3ccf80;
--ts-warning: #efa130;
--ts-danger: #df474e;
}
}
3. Usage
import { HoldConfirm } from "./HoldConfirm";
import "./HoldConfirm.css";
export default function Demo() {
return <HoldConfirm />;
}