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.
npx shadcn add https://design.helpmarq.com /r/confirm-hold-wax.jsonregistry/loud/confirm-hold-wax/component.tsx"use client";
import { useCallback, useEffect, useId, 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;
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);
holdMsRef.current = holdMs;
const onSealedRef = useRef(onSealed);
onSealedRef.current = 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(--muted)"
strokeWidth={1.3}
/>
<rect
x={116}
y={74}
width={48}
height={12}
rx={6}
fill="var(--surface)"
stroke="var(--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-muted hover:bg-border/60 active:scale-[0.98] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-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-muted">
Sealed
</span>
)}
</div>
</div>
);
}
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 --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/--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.