Skip to main content

ns-ui / loud

Confirm Hold Wax

Press-and-hold confirm as a molten wax seal: holding pours a wobbling gooey blob of deep crimson wax onto the document line, hold-complete drops a signet stamp that squashes it into a scalloped, monogrammed seal that cools, darkens and micro-cracks over 2s, while early release slumps the blob and drains it back.

Usage

registry/loud/confirm-hold-wax/demo.tsx
"use client";

import { SignetDrop } from "./component";

// A letter being closed out. The component's demo prop drives itself through
// the same internal hold/release functions the real handlers use: two short
// early-release pours (the blob slumps and drains back) while the button sits
// in its normal idle state, then one full hold that completes — stamp drop,
// squish ring, 2s cool-down with crack reveal — and stays sealed for good.
export default function SignetDropDemo() {
  return (
    <div className="flex min-h-screen flex-col items-center justify-center gap-8 px-6 py-16">
      <p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">
        ns-ui / confirm-hold-wax
      </p>

      <div className="w-full max-w-md rounded-[12px] border border-border bg-background px-8 pb-6 pt-8">
        <div className="flex flex-col gap-2.5" aria-hidden="true">
          <span className="h-3 w-1/3 rounded-full bg-border" />
          <span className="mt-3 h-3 w-full rounded-full bg-border" />
          <span className="h-3 w-11/12 rounded-full bg-border" />
          <span className="h-3 w-full rounded-full bg-border" />
          <span className="h-3 w-3/5 rounded-full bg-border" />
        </div>

        <div className="mt-2 flex flex-col items-center">
          <SignetDrop demo />
        </div>
      </div>

      <p className="max-w-md text-center text-xs text-ns-muted">
        Press and hold Seal to pour wax. Release early and the blob slumps
        back; hold to the end and the signet stamps it, the wax cools, and the
        seal is permanent.
      </p>
    </div>
  );
}

Component

registry/loud/confirm-hold-wax/component.tsx
"use client";

import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";

// ---------------------------------------------------------------------------
// SignetDrop — press-and-hold confirm rendered as a molten wax seal, not a
// progress bar. Holding the "Seal" button pours molten crimson wax onto a
// document line beneath: an SVG gooey filter (feGaussianBlur -> feColorMatrix
// alpha-threshold) fuses three growing circles into one liquid blob that
// wobbles on sin/cos jitter while it is still molten. Reaching the hold
// threshold triggers the signet: a stamp drops fast from above (ease-in),
// squash-and-rebounds on impact, presses the blob into a static scalloped
// seal disc with an embossed monogram (two offset copies faking a bevel) and
// a squish ring of displaced wax puffing outward, then retracts. The seal
// then COOLS for exactly 2s — the specular sheen dies, the fill interpolates
// from hot to deep crimson, and three hairline micro-cracks etch in on
// staggered stroke-dashoffset reveals measured in RAW SVG user units (no
// pathLength normalization, no vector-effect="non-scaling-stroke" — the two
// must never be combined). Early release before the threshold is the cancel
// path: the blob slumps, flattening against the line and draining away, and
// the button re-arms. Once sealed, state is terminal: the disc stays, the
// button dims under aria-disabled with its accessible name intact, and a
// visible "Sealed" caption appears — no reset, no replay.
//
// Hot path is refs + rAF only: hold progress, wobble jitter, stamp drop,
// puff ring, cooling color lerp and crack reveals are all direct
// setAttribute writes on SVG refs. React state carries only the discrete
// phase and the sr-only announcement text.
//
// Accessibility: the hold works from the keyboard (Space/Enter down starts,
// up before threshold cancels); a dedicated sr-only span
// (role=status/aria-live=polite/aria-atomic=true) announces "Poured",
// "Sealed" and "Cancelled", with a zero-width-space parity toggle so a
// repeated identical message (two cancels in a row) still re-announces.
// prefers-reduced-motion keeps the hold requirement (intent is preserved)
// but skips every intermediate visual — the moment progress reaches 1.0 the
// fully cooled, cracked seal appears in a single discrete step.
// ---------------------------------------------------------------------------

export type SignetDropPhase =
  | "idle"
  | "pouring"
  | "stamping"
  | "cooling"
  | "sealed"
  | "cancelling";

export interface SignetDropProps {
  /** Hold duration required to complete the seal, in ms. Default 1100. */
  holdMs?: number;
  /**
   * Self-driving mode for automated previews: runs a scripted timeline
   * through the same internal hold/release functions the real pointer and
   * keyboard handlers use — two short early-release holds (slump) in the
   * first seconds, then one full hold that seals permanently. Scripted steps
   * yield to any real interaction in progress.
   */
  demo?: boolean;
  /** Called once when the terminal sealed state is reached. */
  onSealed?: () => void;
  /** extra classes merged onto the rendered root element */
  className?: string;
}

type HoldSource = "real" | "synthetic";

const HOLD_DEFAULT_MS = 1100;
const CANCEL_MS = 520;
const DROP_MS = 190;
const SQUASH_MS = 260;
const RETRACT_MS = 240;
const PUFF_MS = 380;
const COOL_MS = 2000;
const CRACK_STAGGER_MS = 420;
const CRACK_MS = 700;

// Stage geometry (SVG user units, viewBox 0 0 280 170).
const CX = 140;
const CY = 112;
const SEAL_R = 29;
const LINE_Y = 120;
const STAMP_ANCHOR_Y = 86;
const STAMP_RAISE = -92;

// Deep crimson wax — component-local by design; the repo has no crimson
// token and the loud collection is color-exempt. Chrome around the wax
// stays on the standard tokens.
const WAX_MOLTEN = "#b31b30";
const WAX_HOT = "#9c1526";
const WAX_COOL = "#570d18";
const WAX_HIGHLIGHT = "#d96475";
const WAX_SHADOW = "#33060d";

const GOO_BLOBS = [
  { x: 140, y: 110, r: 22 },
  { x: 127, y: 116, r: 14 },
  { x: 153, y: 115, r: 13 },
] as const;

const PUFF_COUNT = 8;
const PUFFS = Array.from({ length: PUFF_COUNT }, (_, i) => {
  const a = (i / PUFF_COUNT) * Math.PI * 2 + 0.35;
  return { cos: Math.cos(a), sin: Math.sin(a) };
});

// Scalloped seal perimeter: a circle whose radius is modulated by a
// low-frequency cosine — 10 scallops around the rim.
function scallopPath(
  cx: number,
  cy: number,
  r: number,
  scallops: number,
  amp: number
): string {
  const steps = 140;
  const parts: string[] = [];
  for (let i = 0; i <= steps; i++) {
    const th = (i / steps) * Math.PI * 2;
    const rr = r + amp * Math.cos(th * scallops);
    parts.push(
      `${i === 0 ? "M" : "L"} ${(cx + rr * Math.cos(th)).toFixed(2)} ${(cy + rr * Math.sin(th)).toFixed(2)}`
    );
  }
  return parts.join(" ") + " Z";
}

const SEAL_PATH = scallopPath(CX, CY, SEAL_R, 10, 2.6);
const SEAL_RIM = scallopPath(CX, CY, SEAL_R - 4.5, 10, 1.6);

// Hairline micro-cracks, authored directly in raw user units inside the
// disc (center 140,112, r 29). Revealed via stroke-dasharray/dashoffset
// measured with getTotalLength() — never pathLength-normalized.
const CRACK_PATHS = [
  "M 120 102 l 9 4 l 6 -3 l 10 5 l 7 -2",
  "M 158 126 l -8 -3 l -5 4 l -9 -2",
  "M 133 92 l 4 7 l 6 2 l 3 6",
] as const;

function hexToRgb(hex: string): [number, number, number] {
  const n = parseInt(hex.slice(1), 16);
  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
const [HOT_R, HOT_G, HOT_B] = hexToRgb(WAX_HOT);
const [COOL_R, COOL_G, COOL_B] = hexToRgb(WAX_COOL);

function lerpWax(u: number): string {
  const r = Math.round(HOT_R + (COOL_R - HOT_R) * u);
  const g = Math.round(HOT_G + (COOL_G - HOT_G) * u);
  const b = Math.round(HOT_B + (COOL_B - HOT_B) * u);
  return `rgb(${r}, ${g}, ${b})`;
}

const easeOutCubic = (t: number) => 1 - (1 - t) ** 3;
const easeInCubic = (t: number) => t * t * t;

// Squash-and-rebound: instant compression to `deep` at impact, overshoot to
// `over`, settle at 1.
function squashScale(u: number, deep: number, over: number): number {
  if (u >= 1) return 1;
  if (u < 0.5) return deep + (over - deep) * easeOutCubic(u / 0.5);
  return over + (1 - over) * easeOutCubic((u - 0.5) / 0.5);
}

const anchoredScale = (cx: number, cy: number, sx: number, sy: number) =>
  `translate(${cx} ${cy}) scale(${sx.toFixed(4)} ${sy.toFixed(4)}) translate(${-cx} ${-cy})`;

export function SignetDrop({
  holdMs = HOLD_DEFAULT_MS,
  demo = false,
  onSealed,
  className = "",
}: SignetDropProps) {
  const rawId = useId();
  const gooId = `signet-goo-${rawId.replace(/:/g, "")}`;

  const btnRef = useRef<HTMLButtonElement | null>(null);
  const gooGroupRef = useRef<SVGGElement | null>(null);
  const gooRefs = useRef<(SVGCircleElement | null)[]>([]);
  const sealGroupRef = useRef<SVGGElement | null>(null);
  const discRef = useRef<SVGPathElement | null>(null);
  const sheenRef = useRef<SVGEllipseElement | null>(null);
  const crackRefs = useRef<(SVGPathElement | null)[]>([]);
  const puffRefs = useRef<(SVGCircleElement | null)[]>([]);
  const stampOuterRef = useRef<SVGGElement | null>(null);
  const stampSquashRef = useRef<SVGGElement | null>(null);

  const [phase, setPhase] = useState<SignetDropPhase>("idle");
  const [announceText, setAnnounceText] = useState("");
  const parityRef = useRef(false);

  const holdMsRef = useRef(holdMs);
  const onSealedRef = useRef(onSealed);
  useLayoutEffect(() => {
    holdMsRef.current = holdMs;
    onSealedRef.current = onSealed;
  }, [holdMs, onSealed]);

  const stateRef = useRef({
    phase: "idle" as SignetDropPhase,
    holdSource: null as HoldSource | null,
    progress: 0,
    t: 0, // wobble clock (ms)
    last: 0,
    raf: 0,
    reduced: false,
    stampStart: 0,
    impactDone: false,
    coolStart: 0,
    cancelStart: 0,
    cancelP: 0,
    crackLens: [0, 0, 0] as number[],
  });

  const apiRef = useRef<{
    start: () => void;
    release: () => void;
  } | null>(null);

  const announce = useCallback((message: "Poured" | "Sealed" | "Cancelled") => {
    // Zero-width-space parity toggle: forces a real text-node change even
    // when the same message repeats (two "Cancelled" in a row), which is
    // what actually re-triggers an aria-live announcement.
    parityRef.current = !parityRef.current;
    setAnnounceText(message + (parityRef.current ? "​" : ""));
  }, []);

  useEffect(() => {
    const s = stateRef.current;
    const btn = btnRef.current;
    if (!btn) return;

    const setPhaseBoth = (p: SignetDropPhase) => {
      s.phase = p;
      setPhase(p);
    };

    const gooCircle = (i: number) => gooRefs.current[i] ?? null;

    const hideGoo = () => {
      const g = gooGroupRef.current;
      if (g) {
        g.setAttribute("opacity", "0");
        g.removeAttribute("transform");
      }
      GOO_BLOBS.forEach((b, i) => {
        const c = gooCircle(i);
        if (!c) return;
        c.setAttribute("r", "0");
        c.setAttribute("cx", String(b.x));
        c.setAttribute("cy", String(b.y));
      });
    };

    const hidePuffs = () => {
      for (const p of puffRefs.current) {
        if (p) p.setAttribute("opacity", "0");
      }
    };

    const writeGooFrame = (p: number) => {
      const grow = easeOutCubic(p);
      GOO_BLOBS.forEach((b, i) => {
        const c = gooCircle(i);
        if (!c) return;
        const wob = 2.4 * p;
        c.setAttribute("r", (b.r * grow).toFixed(2));
        c.setAttribute(
          "cx",
          (b.x + Math.sin(s.t * 0.006 + i * 2.1) * wob).toFixed(2)
        );
        c.setAttribute(
          "cy",
          (b.y + Math.cos(s.t * 0.0048 + i * 1.4) * wob * 0.7).toFixed(2)
        );
      });
    };

    const revealCracksFully = () => {
      crackRefs.current.forEach((c) => {
        if (c) c.setAttribute("stroke-dashoffset", "0");
      });
    };

    const finalizeSealed = () => {
      const disc = discRef.current;
      if (disc) disc.setAttribute("fill", WAX_COOL);
      if (sheenRef.current) sheenRef.current.setAttribute("opacity", "0");
      revealCracksFully();
      sealGroupRef.current?.removeAttribute("transform");
      hidePuffs();
      setPhaseBoth("sealed");
      announce("Sealed");
      onSealedRef.current?.();
    };

    // Reduced motion: the hold was still required, but every intermediate
    // visual is skipped — one discrete jump to the cooled, cracked seal.
    const sealInstant = () => {
      hideGoo();
      const stamp = stampOuterRef.current;
      if (stamp) stamp.setAttribute("opacity", "0");
      sealGroupRef.current?.setAttribute("opacity", "1");
      finalizeSealed();
    };

    const impact = () => {
      s.impactDone = true;
      hideGoo();
      sealGroupRef.current?.setAttribute("opacity", "1");
      discRef.current?.setAttribute("fill", WAX_HOT);
      sheenRef.current?.setAttribute("opacity", "0.4");
    };

    const beginStamp = (now: number) => {
      s.holdSource = null; // completion is committed; release can no longer cancel
      if (s.reduced) {
        sealInstant();
        return;
      }
      setPhaseBoth("stamping");
      s.stampStart = now;
      s.impactDone = false;
      stampOuterRef.current?.setAttribute("opacity", "1");
    };

    const tick = (now: number) => {
      // Clamped to 0: a rAF timestamp can read marginally behind the
      // performance.now() sample wake() took to seed s.last (first frame
      // after (re)starting the loop), which without the clamp drives
      // progress slightly negative and easeOutCubic negative in turn —
      // a negative wax-blob radius the browser rejects outright.
      const raw = Math.max(0, now - s.last); // wall time keeps the hold duration honest
      const dt = Math.min(64, raw); // clamped for the wobble clock
      s.last = now;
      s.t += dt;

      if (s.phase === "pouring") {
        s.progress = Math.min(1, s.progress + raw / holdMsRef.current);
        if (!s.reduced) writeGooFrame(s.progress);
        if (s.progress >= 1) beginStamp(now);
      } else if (s.phase === "cancelling") {
        const k = Math.min(1, (now - s.cancelStart) / CANCEL_MS);
        const sy = 1 - 0.85 * easeOutCubic(k);
        const sx = 1 + 0.3 * easeOutCubic(k);
        const g = gooGroupRef.current;
        if (g) {
          // Slump anchored at the document line: widen while flattening,
          // losing surface tension as it drains.
          g.setAttribute("transform", anchoredScale(CX, LINE_Y, sx, sy));
          g.setAttribute("opacity", (1 - easeInCubic(k)).toFixed(3));
        }
        const base = easeOutCubic(s.cancelP);
        GOO_BLOBS.forEach((b, i) => {
          const c = gooCircle(i);
          if (c) c.setAttribute("r", (b.r * base * (1 - 0.55 * k)).toFixed(2));
        });
        if (k >= 1) {
          hideGoo();
          setPhaseBoth("idle");
        }
      } else if (s.phase === "stamping") {
        const e = now - s.stampStart;
        const stamp = stampOuterRef.current;
        if (e < DROP_MS) {
          // Fast ease-in drop; the still-molten blob keeps wobbling beneath.
          const y = STAMP_RAISE * (1 - easeInCubic(e / DROP_MS));
          stamp?.setAttribute("transform", `translate(0 ${y.toFixed(2)})`);
          writeGooFrame(1);
        } else {
          if (!s.impactDone) impact();
          const u = Math.min(1, (e - DROP_MS) / SQUASH_MS);
          const stampSy = squashScale(u, 0.76, 1.05);
          const stampSx = 1 + (1 - stampSy) * 0.4;
          stampSquashRef.current?.setAttribute(
            "transform",
            anchoredScale(CX, STAMP_ANCHOR_Y, stampSx, stampSy)
          );
          stamp?.setAttribute("transform", "translate(0 0)");
          const discSy = squashScale(u, 0.84, 1.02);
          const discSx = 1 + (1 - discSy) * 0.5;
          sealGroupRef.current?.setAttribute(
            "transform",
            anchoredScale(CX, CY + SEAL_R, discSx, discSy)
          );
          // Squish ring: displaced wax puffs outward from the rim and fades.
          const pu = Math.min(1, (e - DROP_MS) / PUFF_MS);
          PUFFS.forEach((p, i) => {
            const c = puffRefs.current[i];
            if (!c) return;
            const d = SEAL_R + 17 * easeOutCubic(pu);
            c.setAttribute("cx", (CX + p.cos * d).toFixed(2));
            c.setAttribute("cy", (CY + p.sin * d * 0.8).toFixed(2));
            c.setAttribute("r", (3.4 * (1 - pu)).toFixed(2));
            c.setAttribute("opacity", (0.85 * (1 - pu)).toFixed(3));
          });
          if (e >= DROP_MS + SQUASH_MS) {
            const ru = Math.min(1, (e - DROP_MS - SQUASH_MS) / RETRACT_MS);
            stamp?.setAttribute(
              "transform",
              `translate(0 ${(STAMP_RAISE * easeOutCubic(ru)).toFixed(2)})`
            );
            stamp?.setAttribute("opacity", (1 - ru).toFixed(3));
            if (ru >= 1) {
              stamp?.setAttribute("opacity", "0");
              stampSquashRef.current?.removeAttribute("transform");
              sealGroupRef.current?.removeAttribute("transform");
              hidePuffs();
              setPhaseBoth("cooling");
              s.coolStart = now;
            }
          }
        }
      } else if (s.phase === "cooling") {
        const e = now - s.coolStart;
        const u = Math.min(1, e / COOL_MS);
        discRef.current?.setAttribute("fill", lerpWax(u));
        sheenRef.current?.setAttribute("opacity", (0.4 * (1 - u)).toFixed(3));
        crackRefs.current.forEach((c, i) => {
          if (!c) return;
          const L = s.crackLens[i] ?? 0;
          const lu = Math.min(
            1,
            Math.max(0, (e - i * CRACK_STAGGER_MS) / CRACK_MS)
          );
          c.setAttribute(
            "stroke-dashoffset",
            (L * (1 - easeOutCubic(lu))).toFixed(2)
          );
        });
        if (u >= 1) {
          finalizeSealed();
        }
      }

      if (
        s.phase === "pouring" ||
        s.phase === "cancelling" ||
        s.phase === "stamping" ||
        s.phase === "cooling"
      ) {
        s.raf = requestAnimationFrame(tick);
      } else {
        s.raf = 0;
      }
    };

    const wake = () => {
      if (s.raf) return;
      s.last = performance.now();
      s.raf = requestAnimationFrame(tick);
    };

    const startHold = (source: HoldSource) => {
      if (s.phase !== "idle" || s.holdSource !== null) return;
      s.holdSource = source;
      s.progress = 0;
      s.t = 0;
      setPhaseBoth("pouring");
      announce("Poured");
      if (!s.reduced) {
        const g = gooGroupRef.current;
        if (g) {
          g.setAttribute("opacity", "1");
          g.removeAttribute("transform");
        }
      }
      wake();
    };

    const release = (source: HoldSource) => {
      if (s.holdSource !== source) return;
      s.holdSource = null;
      if (s.phase !== "pouring") return;
      announce("Cancelled");
      if (s.reduced) {
        hideGoo();
        setPhaseBoth("idle");
        return;
      }
      s.cancelStart = performance.now();
      s.cancelP = s.progress;
      setPhaseBoth("cancelling");
      wake();
    };

    // Measure crack lengths once, in RAW user units (getTotalLength on the
    // authored geometry — no pathLength attribute anywhere), and park each
    // crack fully hidden behind its own dash offset.
    crackRefs.current.forEach((c, i) => {
      if (!c) return;
      const L = c.getTotalLength();
      s.crackLens[i] = L;
      c.setAttribute("stroke-dasharray", L.toFixed(2));
      c.setAttribute("stroke-dashoffset", L.toFixed(2));
    });

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

    const onPointerDown = (e: PointerEvent) => {
      if (s.phase === "sealed") return;
      btn.setPointerCapture(e.pointerId);
      startHold("real");
    };
    const onPointerEnd = () => release("real");
    const onKeyDown = (e: KeyboardEvent) => {
      if ((e.key === " " || e.key === "Enter") && !e.repeat) {
        e.preventDefault();
        if (s.phase === "sealed") return;
        startHold("real");
      }
    };
    const onKeyUp = (e: KeyboardEvent) => {
      if (e.key === " " || e.key === "Enter") release("real");
    };
    const onBlur = () => release("real");

    btn.addEventListener("pointerdown", onPointerDown);
    btn.addEventListener("pointerup", onPointerEnd);
    btn.addEventListener("pointercancel", onPointerEnd);
    btn.addEventListener("pointerleave", onPointerEnd);
    btn.addEventListener("lostpointercapture", onPointerEnd);
    btn.addEventListener("keydown", onKeyDown);
    btn.addEventListener("keyup", onKeyUp);
    btn.addEventListener("blur", onBlur);

    // Scripted access for the self-driving demo — the exact same internal
    // hold/release functions the real handlers use. Synthetic starts no-op
    // while a real hold is active (startHold requires idle + no source) and
    // a synthetic release can never cancel a real hold (source mismatch).
    apiRef.current = {
      start: () => startHold("synthetic"),
      release: () => release("synthetic"),
    };

    return () => {
      cancelAnimationFrame(s.raf);
      s.raf = 0;
      mq.removeEventListener("change", onMq);
      btn.removeEventListener("pointerdown", onPointerDown);
      btn.removeEventListener("pointerup", onPointerEnd);
      btn.removeEventListener("pointercancel", onPointerEnd);
      btn.removeEventListener("pointerleave", onPointerEnd);
      btn.removeEventListener("lostpointercapture", onPointerEnd);
      btn.removeEventListener("keydown", onKeyDown);
      btn.removeEventListener("keyup", onKeyUp);
      btn.removeEventListener("blur", onBlur);
      apiRef.current = null;
    };
  }, [announce]);

  // Demo timeline: two short holds that release early (slump) while the
  // automated gate screenshots a normal idle button, then one full hold
  // (~5.7s in) that completes, stamps, cools and stays sealed forever.
  useEffect(() => {
    if (!demo) return;
    const timers: number[] = [];
    const at = (ms: number, fn: () => void) => {
      timers.push(window.setTimeout(fn, ms));
    };
    at(900, () => apiRef.current?.start());
    at(1520, () => apiRef.current?.release());
    at(2800, () => apiRef.current?.start());
    at(3360, () => apiRef.current?.release());
    at(5700, () => apiRef.current?.start());
    at(5700 + holdMs + 250, () => apiRef.current?.release());
    return () => timers.forEach((t) => window.clearTimeout(t));
  }, [demo, holdMs]);

  const sealed = phase === "sealed";

  return (
    <div className={`flex flex-col items-center gap-4 ${className}`}>
      {/* Dedicated announcer: only the throttle-free phase messages live
          here, never the button label. */}
      <span role="status" aria-live="polite" aria-atomic="true" className="sr-only">
        {announceText}
      </span>

      <svg
        width={280}
        height={170}
        viewBox="0 0 280 170"
        aria-hidden="true"
        focusable="false"
        className="pointer-events-none select-none"
      >
        <defs>
          {/* Classic goo: heavy blur, then an alpha threshold that fuses
              overlapping circles into one liquid silhouette. */}
          <filter id={gooId} x="-40%" y="-40%" width="180%" height="180%">
            <feGaussianBlur in="SourceGraphic" stdDeviation="5" result="blur" />
            <feColorMatrix
              in="blur"
              type="matrix"
              values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 18 -7"
            />
          </filter>
        </defs>

        {/* Document edge: the tail of a letter, wax lands on the last rule. */}
        <line x1={18} y1={100} x2={96} y2={100} stroke="var(--border)" strokeWidth={2} />
        <line x1={18} y1={LINE_Y} x2={262} y2={LINE_Y} stroke="var(--border)" strokeWidth={2} />
        <line x1={18} y1={140} x2={110} y2={140} stroke="var(--border)" strokeWidth={2} />

        {/* Molten pour: gooey-fused circles, radii driven by hold progress,
            positions jittered by the wobble clock. */}
        <g ref={gooGroupRef} opacity={0} filter={`url(#${gooId})`}>
          {GOO_BLOBS.map((b, i) => (
            <circle
              key={i}
              ref={(el) => {
                gooRefs.current[i] = el;
              }}
              cx={b.x}
              cy={b.y}
              r={0}
              fill={WAX_MOLTEN}
            />
          ))}
        </g>

        {/* Squish ring: displaced wax at the moment of impact. */}
        <g>
          {PUFFS.map((_, i) => (
            <circle
              key={i}
              ref={(el) => {
                puffRefs.current[i] = el;
              }}
              cx={CX}
              cy={CY}
              r={0}
              fill={WAX_MOLTEN}
              opacity={0}
            />
          ))}
        </g>

        {/* The pressed seal: scalloped disc, embossed monogram, sheen,
            micro-cracks. Hidden until the stamp lands. */}
        <g ref={sealGroupRef} opacity={0}>
          <path ref={discRef} d={SEAL_PATH} fill={WAX_HOT} />
          <path
            d={SEAL_RIM}
            fill="none"
            stroke={WAX_SHADOW}
            strokeOpacity={0.4}
            strokeWidth={1}
          />
          <circle
            cx={CX}
            cy={CY}
            r={20}
            fill="none"
            stroke={WAX_HIGHLIGHT}
            strokeOpacity={0.28}
            strokeWidth={1}
          />
          {/* Emboss: two offset copies — dark shadow down-right, light
              highlight up-left — faking a bevel-carved monogram. */}
          <text
            x={CX + 0.9}
            y={CY + 0.9}
            textAnchor="middle"
            dominantBaseline="central"
            className="font-mono"
            fontSize={24}
            fontWeight={700}
            fill={WAX_SHADOW}
          >
            S
          </text>
          <text
            x={CX - 0.9}
            y={CY - 0.9}
            textAnchor="middle"
            dominantBaseline="central"
            className="font-mono"
            fontSize={24}
            fontWeight={700}
            fill={WAX_HIGHLIGHT}
            fillOpacity={0.9}
          >
            S
          </text>
          {/* Hairline micro-cracks — raw-unit dash reveals, staggered. */}
          {CRACK_PATHS.map((d, i) => (
            <path
              key={i}
              ref={(el) => {
                crackRefs.current[i] = el;
              }}
              d={d}
              fill="none"
              stroke={WAX_SHADOW}
              strokeOpacity={0.85}
              strokeWidth={0.9}
              strokeLinecap="round"
            />
          ))}
          {/* Specular sheen — dies as the wax cools. */}
          <ellipse
            ref={sheenRef}
            cx={130}
            cy={102}
            rx={9.5}
            ry={4.5}
            transform="rotate(-28 130 102)"
            fill="#ffffff"
            opacity={0}
          />
        </g>

        {/* The signet stamp: parked above the stage, drops on completion. */}
        <g ref={stampOuterRef} opacity={0} transform={`translate(0 ${STAMP_RAISE})`}>
          <g ref={stampSquashRef}>
            <rect
              x={133}
              y={40}
              width={14}
              height={38}
              rx={6}
              fill="var(--surface)"
              stroke="var(--ns-muted)"
              strokeWidth={1.3}
            />
            <rect
              x={116}
              y={74}
              width={48}
              height={12}
              rx={6}
              fill="var(--surface)"
              stroke="var(--ns-muted)"
              strokeWidth={1.3}
            />
            <line x1={124} y1={80} x2={156} y2={80} stroke="var(--border)" strokeWidth={1} />
          </g>
        </g>
      </svg>

      <div className="flex h-9 items-center gap-3">
        <button
          ref={btnRef}
          type="button"
          aria-disabled={sealed ? true : undefined}
          className="select-none touch-none rounded-[6px] border border-border bg-surface px-5 py-2 text-sm font-medium text-foreground transition-[background-color,border-color,transform] duration-150 hover:border-ns-muted hover:bg-border/60 active:scale-[0.98] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent aria-disabled:cursor-default aria-disabled:opacity-45 aria-disabled:hover:border-border aria-disabled:hover:bg-surface"
        >
          Seal
        </button>
        {sealed && (
          <span className="font-mono text-[11px] uppercase tracking-[0.2em] text-ns-muted">
            Sealed
          </span>
        )}
      </div>
    </div>
  );
}

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/confirm-hold-wax.tsx, so point the import there.

Use when a press-and-hold confirm that should read as signing rather than deleting, staged on a document rule and leaving a cooling, micro-cracking seal behind as a persistent artifact of the commitment; it runs confirm-hold-ink's exact hold grammar, so pick confirm-hold-ink instead whenever the confirm is a bare inline button with no document context and no artifact worth leaving behind, and signature-consent when an actual drawn signature is required rather than a hold.

Install

npx shadcn add https://design.helpmarq.com/r/confirm-hold-wax.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.

Build spec

Build a press-and-hold confirmation as a molten wax seal on a document. The stage is a fixed 280x170 SVG: a --border document rule runs across at y=120 (plus two shorter rules above and below suggesting the tail of a letter), with the empty seal spot centered on it and a 'Seal' button below (standard chrome: --surface fill, --border border, --foreground text, an always-present plain CSS :hover border/background change independent of the wax visuals, and a visible focus-visible outline in --ns-accent). HOLD GRAMMAR (confirm-hold-ink's exact grammar, molten visuals): pointerdown on the button (with setPointerCapture) or non-repeat keydown Space/Enter while focused starts a tracked hold; a rAF loop advances progress 0-1 over ~1100ms of unclamped wall time written to a ref, never React state; pointerup/pointercancel/pointerleave/lostpointercapture/keyup/blur before 1.0 is the cancel path; the instant progress reaches 1.0 the completion sequence fires regardless of any subsequent release. POURING: an SVG gooey filter (feGaussianBlur stdDeviation 5 piped into an feColorMatrix alpha threshold '... 18 -7') fuses three overlapping circles sitting on the rule into one liquid silhouette in molten crimson (component-local constants around #b31b30 — the repo has no crimson token and the loud collection is color-exempt; everything that is not wax stays on the theme tokens). Circle radii grow from ~0 to target (22/14/13u) on an ease-out of hold progress, and while liquid their cx/cy jitter a few px on sin/cos of an internal ms clock (per-circle phase offsets, amplitude scaled by progress) — all direct setAttribute writes on circle refs each frame. STAMPING (~690ms total): a signet stamp (handle + base rects in --surface/--ns-muted, parked 92u above, opacity 0) drops with a fast ease-in translateY over 190ms onto the blob; at impact the goo hides and a STATIC scalloped seal disc appears — a precomputed path whose perimeter is a circle radius-modulated by a low-frequency cosine (10 scallops, amp 2.6 on r=29) — while both stamp and disc play a squash-and-rebound (stamp scaleY 0.76 -> 1.05 -> 1, disc 0.84 -> 1.02 -> 1, each anchored at its base via translate-scale-translate transform attributes, x widening inversely) and a squish ring of 8 small wax circles puffs outward from the rim (distance +17u ease-out, radius and opacity to 0 over 380ms). The disc carries an embossed monogram faked with exactly two overlapping copies of the glyph offset ~0.9u in opposite directions — a lighter highlight up-left, a darker shadow down-right — plus a scalloped inner rim stroke and a faint highlight ring for bevel depth. The stamp then retracts upward and fades over 240ms. COOLING (exactly 2000ms, JS-interpolated in the same rAF loop): a white specular sheen ellipse fades from 0.4 to 0, the disc fill lerps per-channel from hot crimson (#9c1526) to deep cooled crimson (#570d18), and three hairline micro-crack paths etch in via stroke-dasharray/stroke-dashoffset animating from fully hidden to revealed, staggered 420ms apart over 700ms each. CRITICAL: the crack lengths come from getTotalLength() on the authored geometry and the dash values are written in RAW SVG user units — never combine an SVG pathLength attribute with vector-effect non-scaling-stroke, and use neither here. EARLY RELEASE: the blob slumps — the goo group scales anchored at the document line (scaleY toward 0.15, scaleX widening ~1.3, radii shrinking, opacity easing to 0 over ~520ms, like losing surface tension) — then the stage returns fully to idle and the button re-arms. TERMINAL STATE: sealed is permanent — no reset, no replay. The disc stays exactly as cooled, the button keeps its visible 'Seal' text (accessible name intact throughout the lifecycle) but becomes aria-disabled='true', visually dimmed, with all handlers guarded, and a visible mono 'Sealed' caption appears beside it. ACCESSIBILITY: a dedicated sr-only span (role=status, aria-live=polite, aria-atomic=true), separate from the button, announces 'Poured' when a hold starts, 'Cancelled' on early release and 'Sealed' at the terminal state, appending a zero-width-space parity toggle so a repeated identical message (two cancels in a row) still forces a text-node change and re-announces. The SVG stage is aria-hidden and pointer-events-none; the button is the only interactive element. REDUCED MOTION (matchMedia prefers-reduced-motion): the hold requirement is preserved — intent still takes the full hold — but every intermediate visual is skipped: no goo growth or wobble, no stamp drop, no cooling transition; the moment progress reaches 1.0 the fully cooled, cracked seal appears in one discrete step, and a cancel snaps straight back to idle. DEMO MODE: a demo prop runs a scripted setTimeout timeline through the exact same internal hold/release functions the real handlers use — two short early-release holds (~620ms and ~560ms of a 1100ms threshold) inside the first ~3.5s so automated hover/focus screenshots see a normal idle button while still showing the molten wobble and slump, then one full hold at ~5.7s that completes, stamps, cools and stays sealed permanently; synthetic starts no-op unless the component is idle with no active hold, and a synthetic release can never cancel a real hold (hold-source tagging), so scripted playback never fights real input. Zero dependencies, no canvas.

Props

PropTypeDefaultDescription
holdMs?numberHOLD_DEFAULT_MSHold duration required to complete the seal, in ms. Default 1100.
demo?booleanfalseSelf-driving mode for automated previews: runs a scripted timeline through the same internal hold/release functions the real pointer and keyboard handlers use — two short early-release holds (slump) in the first seconds, then one full hold that seals permanently. Scripted steps yield to any real interaction in progress.
onSealed?() => void—Called once when the terminal sealed state is reached.
className?string—extra classes merged onto the rendered root element