Skip to main content

ns-ui

Confirm Hold Ink

Press-and-hold destructive action: monochrome ink pours up from the press point, release early and it recoils.

Use when a press-and-hold DESTRUCTIVE confirm — ink pours up from the press point and recoils if released early; use whenever an action needs deliberate confirmation, not a plain single-tap button; pick confirm-slide-shatter instead when the gate should be distance dragged rather than time held, confirm-dial-align when it should be fine-motor accuracy, or confirm-hold-wax for the same hold grammar staged as signing rather than deleting.

Install

npx shadcn add https://design.helpmarq.com/r/confirm-hold-ink.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

Source
registry/core/confirm-hold-ink/component.tsx
"use client";

import { useEffect, useRef, useState, type ReactNode } from "react";

// Press-and-hold destructive action, redesigned as an ink fill: holding pours
// monochrome ink up from the press point with a live meniscus edge and subtle
// grain; pressure microshake builds near the top; releasing early recoils the
// ink elastically; completion pops with one spring overshoot and swaps the
// label. Canvas colors derive from computed CSS tokens (re-derived on theme
// change), rAF sleeps when settled and pauses offscreen, reduced-motion gets a
// plain clean fill. Direct-DOM on the hot path — React state only at confirm.

type Mode = "idle" | "hold" | "recoil" | "pop";

export function HoldToConfirm({
  children,
  confirmedLabel = "Done",
  holdMs = 1200,
  onConfirm,
  className = "",
}: {
  /** button label shown before confirmation */
  children: ReactNode;
  /** label shown briefly after the hold completes */
  confirmedLabel?: ReactNode;
  /** ms the button must be held before it commits */
  holdMs?: number;
  /** called once the hold completes */
  onConfirm?: () => void;
  /** extra classes merged onto the rendered root element */
  className?: string;
}) {
  const btnRef = useRef<HTMLButtonElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const [confirmed, setConfirmed] = useState(false);
  const onConfirmRef = useRef(onConfirm);
  onConfirmRef.current = onConfirm;
  const holdMsRef = useRef(holdMs);
  holdMsRef.current = holdMs;

  const stateRef = useRef({
    mode: "idle" as Mode,
    p: 0, // fill progress 0..1
    v: 0, // recoil spring velocity (progress units / s)
    holding: false,
    done: false,
    pressX: 0.5, // normalized press point
    scale: 1,
    sv: 0, // pop spring velocity
    t: 0, // wave clock (ms)
    raf: 0,
    last: 0,
    w: 0,
    h: 0,
    dpr: 1,
    ink: "#ededed",
    grainColor: "#171717",
    reduced: false,
    visible: true,
    noise: null as HTMLCanvasElement | null,
    pattern: null as CanvasPattern | null,
  });

  useEffect(() => {
    const s = stateRef.current;
    const btn = btnRef.current;
    const canvas = canvasRef.current;
    if (!btn || !canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    const makeNoise = () => {
      const n = document.createElement("canvas");
      n.width = 128;
      n.height = 128;
      const nctx = n.getContext("2d");
      if (!nctx) return;
      nctx.fillStyle = s.grainColor;
      for (let i = 0; i < 2400; i++) {
        nctx.globalAlpha = Math.random() * 0.5;
        nctx.fillRect(Math.floor(Math.random() * 128), Math.floor(Math.random() * 128), 1, 1);
      }
      s.noise = n;
      s.pattern = null; // rebuilt lazily against the drawing ctx
    };

    // canvas colors from computed tokens: ink = button text color (foreground),
    // grain = button background (surface) so texture reads inside the ink
    const syncColors = () => {
      const cs = getComputedStyle(btn);
      s.ink = cs.color;
      s.grainColor = cs.backgroundColor;
      makeNoise();
      draw();
    };

    const edgeY = (x: number, level: number) => {
      let y = level;
      // mound welling up at the press point, flattens as the fill matures
      const px = s.pressX * s.w;
      const spread = s.w * (0.1 + 0.9 * s.p);
      const mound = s.h * 1.5 * s.p * (1 - s.p) ** 2;
      y -= mound * Math.exp(-((x - px) ** 2) / (2 * spread * spread));
      // rippling waterline + pressure swell near the top
      const press = Math.min(1, Math.max(0, (s.p - 0.6) / 0.4));
      const live = s.holding ? 1.6 : Math.min(3, Math.abs(s.v) * 2.5);
      const amp = 1.2 + 2.6 * press + live;
      y += amp * 0.6 * Math.sin(x * 0.085 + s.t * 0.012);
      y += amp * 0.4 * Math.sin(x * 0.041 - s.t * 0.0085);
      return y;
    };

    const draw = () => {
      const { w, h, dpr } = s;
      if (!w || !h) return;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx.clearRect(0, 0, w, h);
      if (s.p <= 0.001) return;
      ctx.fillStyle = s.ink;
      if (s.p >= 0.999 || s.reduced) {
        // reduced motion: simple clean fill, no meniscus, no grain
        ctx.fillRect(0, h * (1 - s.p), w, h * s.p + 1);
        if (s.reduced) return;
      } else {
        const level = h * (1 - s.p);
        ctx.beginPath();
        ctx.moveTo(0, h + 2);
        for (let x = 0; x <= w; x += 3) ctx.lineTo(x, edgeY(x, level));
        ctx.lineTo(w, edgeY(w, level));
        ctx.lineTo(w, h + 2);
        ctx.closePath();
        ctx.fill();
      }
      // grain, clipped to the ink via source-atop
      if (s.noise) {
        if (!s.pattern) s.pattern = ctx.createPattern(s.noise, "repeat");
        if (s.pattern) {
          ctx.globalCompositeOperation = "source-atop";
          ctx.globalAlpha = 0.12;
          ctx.fillStyle = s.pattern;
          ctx.fillRect(0, 0, w, h);
          ctx.globalAlpha = 1;
          ctx.globalCompositeOperation = "source-over";
        }
      }
    };

    const applyTransform = () => {
      if (s.mode === "hold") {
        const press = Math.min(1, Math.max(0, (s.p - 0.6) / 0.4));
        const j = s.reduced ? 0 : press * press * 1.6;
        const jx = (Math.random() - 0.5) * 2 * j;
        const jy = (Math.random() - 0.5) * 2 * j;
        btn.style.transform = `translate(${jx.toFixed(2)}px, ${jy.toFixed(2)}px) scale(0.985)`;
      } else if (s.mode === "recoil") {
        const squash = 1 - 0.015 * Math.min(1, s.p * 3);
        btn.style.transform = `scale(${squash.toFixed(4)})`;
      } else if (s.mode === "pop") {
        btn.style.transform = `scale(${s.scale.toFixed(4)})`;
      } else {
        btn.style.transform = "";
      }
    };

    const settle = () => {
      s.mode = "idle";
      s.raf = 0;
      applyTransform();
      draw();
    };

    const confirm = () => {
      s.p = 1;
      s.done = true;
      s.holding = false;
      if (s.reduced) {
        s.mode = "idle";
      } else {
        s.mode = "pop";
        s.scale = 0.97;
        s.sv = 0.6;
      }
      setConfirmed(true);
      onConfirmRef.current?.();
    };

    const tick = (now: number) => {
      const rawMs = now - s.last; // wall time, keeps hold duration honest across frame stalls
      const dtMs = Math.min(64, rawMs); // clamped for springs/waves so physics can't blow up
      s.last = now;
      const dt = dtMs / 1000;
      s.t += dtMs;

      if (s.mode === "hold") {
        s.p += rawMs / holdMsRef.current;
        if (s.p >= 1) confirm();
      } else if (s.mode === "recoil") {
        if (s.reduced) {
          s.p -= (dtMs / holdMsRef.current) * 2.5;
          if (s.p <= 0) {
            s.p = 0;
            draw();
            return settle();
          }
        } else {
          // elastic recoil: damped spring toward 0, bounce at the floor
          s.v += (-220 * s.p - 16 * s.v) * dt;
          s.p += s.v * dt;
          if (s.p <= 0) {
            s.p = 0;
            s.v = -s.v * 0.3;
          }
          if (s.p < 0.008 && Math.abs(s.v) < 0.2) {
            s.p = 0;
            draw();
            return settle();
          }
        }
      } else if (s.mode === "pop") {
        s.sv += ((1 - s.scale) * 300 - 12 * s.sv) * dt;
        s.scale += s.sv * dt;
        if (Math.abs(s.scale - 1) < 0.001 && Math.abs(s.sv) < 0.01) {
          s.scale = 1;
          draw();
          return settle();
        }
      } else {
        return settle();
      }

      draw();
      applyTransform();
      if (s.visible) {
        s.raf = requestAnimationFrame(tick);
      } else {
        s.raf = 0;
      }
    };

    const wake = () => {
      if (s.raf || s.mode === "idle" || !s.visible) return;
      s.last = performance.now();
      s.raf = requestAnimationFrame(tick);
    };

    const startHold = (pressX: number) => {
      if (s.done || s.mode === "pop") return;
      s.holding = true;
      s.mode = "hold";
      s.pressX = pressX;
      s.v = 0;
      wake();
    };

    const release = () => {
      if (!s.holding || s.done) return;
      s.holding = false;
      if (s.p > 0) {
        s.mode = "recoil";
        s.v = -1.2; // initial elastic kick downward
        wake();
      } else {
        s.mode = "idle";
        applyTransform();
      }
    };

    // wire input through the element so cleanup is guaranteed
    const onPointerDown = (e: PointerEvent) => {
      btn.setPointerCapture(e.pointerId);
      const r = btn.getBoundingClientRect();
      startHold(r.width ? (e.clientX - r.left) / r.width : 0.5);
    };
    const onPointerEnd = () => release();
    const onKeyDown = (e: KeyboardEvent) => {
      if ((e.key === " " || e.key === "Enter") && !e.repeat) {
        e.preventDefault();
        startHold(0.5);
      }
    };
    const onKeyUp = (e: KeyboardEvent) => {
      if (e.key === " " || e.key === "Enter") release();
    };
    btn.addEventListener("pointerdown", onPointerDown);
    btn.addEventListener("pointerup", onPointerEnd);
    btn.addEventListener("pointercancel", onPointerEnd);
    btn.addEventListener("lostpointercapture", onPointerEnd);
    btn.addEventListener("keydown", onKeyDown);
    btn.addEventListener("keyup", onKeyUp);
    btn.addEventListener("blur", onPointerEnd);

    const ro = new ResizeObserver(() => {
      const r = btn.getBoundingClientRect();
      s.w = r.width;
      s.h = r.height;
      s.dpr = Math.min(2, window.devicePixelRatio || 1);
      canvas.width = Math.max(1, Math.round(s.w * s.dpr));
      canvas.height = Math.max(1, Math.round(s.h * s.dpr));
      draw();
    });
    ro.observe(btn);

    const io = new IntersectionObserver(([entry]) => {
      s.visible = entry.isIntersecting;
      if (s.visible) wake();
      else if (s.raf) {
        cancelAnimationFrame(s.raf);
        s.raf = 0;
      }
    });
    io.observe(btn);

    const mo = new MutationObserver(syncColors);
    mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });

    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    const onMq = () => {
      s.reduced = mq.matches;
      draw();
    };
    onMq();
    mq.addEventListener("change", onMq);

    syncColors();

    return () => {
      cancelAnimationFrame(s.raf);
      s.raf = 0;
      ro.disconnect();
      io.disconnect();
      mo.disconnect();
      mq.removeEventListener("change", onMq);
      btn.removeEventListener("pointerdown", onPointerDown);
      btn.removeEventListener("pointerup", onPointerEnd);
      btn.removeEventListener("pointercancel", onPointerEnd);
      btn.removeEventListener("lostpointercapture", onPointerEnd);
      btn.removeEventListener("keydown", onKeyDown);
      btn.removeEventListener("keyup", onKeyUp);
      btn.removeEventListener("blur", onPointerEnd);
    };
  }, []);

  return (
    <button
      ref={btnRef}
      type="button"
      className={[
        "ns-chi-btn relative isolate inline-flex select-none touch-none items-center justify-center overflow-hidden",
        "rounded-sm border border-border bg-surface px-5 py-2.5 text-sm font-medium text-foreground",
        "hover:border-ns-muted hover:bg-border/60",
        "transition-[border-color,background-color] duration-150",
        "focus:border-ns-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent",
        className,
      ].join(" ")}
    >
      <canvas ref={canvasRef} aria-hidden className="pointer-events-none absolute inset-0 h-full w-full" />
      {/* difference blend inverts the label wherever the ink covers it */}
      <span className="relative z-10" style={{ mixBlendMode: "difference", color: "#fff" }}>
        {confirmed ? confirmedLabel : children}
      </span>
    </button>
  );
}
Build spec

A destructive-action button requiring press-and-hold, rendered as a canvas ink fill: holding pours monochrome ink (foreground token) up from the press point with a live rippling meniscus edge, subtle grain, and a pressure microshake as it nears the top; the label inverts over the ink via a difference blend. Releasing early (including pointercancel or blur) recoils the ink with an elastic damped spring; when the fill completes, the button pops with one spring overshoot and the label swaps. Works with pointer and keyboard (hold Space/Enter). Canvas colors derive from computed CSS tokens and re-derive on theme change; the rAF loop sleeps when settled and pauses offscreen; prefers-reduced-motion still holds-to-confirm but renders a plain clean fill with no waves, grain, shake, or pop.

Props

PropTypeDefaultDescription
childrenReactNodebutton label shown before confirmation
confirmedLabel?ReactNode"Done"label shown briefly after the hold completes
holdMs?number1200ms the button must be held before it commits
onConfirm?() => voidcalled once the hold completes
className?stringextra classes merged onto the rendered root element