Teseria
All elements

Hold to confirm

Press and hold. A fill sweeps across while a ring draws round the icon.

Code

1. Install

Terminal
npm i framer-motion lucide-react

2. Files

HoldConfirm.tsx143 lines
"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>
  );
}
HoldConfirm.css76 lines
.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;
}
tokens.cssshared · add once
/*
 * 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

Demo.tsx
import { HoldConfirm } from "./HoldConfirm";
import "./HoldConfirm.css";

export default function Demo() {
  return <HoldConfirm />;
}