Hold to Ignite Button
A press-and-hold confirm: a flame-edged fill rises through the button while you hold, drains if you let go early, and lands with a burst of sparks.
Canvas 2D0 deps1 file · 14 KB TSXreduced-motion aware
Props
"use client";
import { useCallback, useEffect, useId, useRef, useState, useSyncExternalStore } from "react";
import type { CSSProperties } from "react";
/** Safety orange as a field colour. Black on it reads at 6.3:1. */
const HV_ORANGE = "#FF5B14";
/** The hot highlight, for orange marks on black. */
const HV_HOT = "#FF7A1A";
const HV_BLACK = "#0B0A09";
/**
* The display face: Archivo's width axis does the shouting (wdth 62..125).
* Load it as a variable font with the width axis and expose it as --font-hv,
* e.g. in Next: Archivo({ subsets: ["latin"], axes: ["wdth"], variable: "--font-hv" }).
*/
const HV_DISPLAY = 'var(--font-hv, "Archivo"), "Archivo", "Arial Narrow", ui-sans-serif, system-ui, sans-serif';
function hvReducedSub(fn: () => void) {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return () => {};
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", fn);
return () => mq.removeEventListener("change", fn);
}
const hvReducedSnap = () => typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
/** True when the reader asked for reduced motion. Live: follows the system setting as it changes. */
function useHvReduced(): boolean {
return useSyncExternalStore(hvReducedSub, hvReducedSnap, () => false);
}
/**
* The kit's scoped CSS: one focus ring (2px, offset 3px, in the ink of the
* ground it sits on), tabular figures, the press, and transitions off for
* anything a frame loop writes.
*/
const HV_CSS =
".hv-scope{--hv-ring:" + HV_HOT + "}" +
".hv-scope[data-hv-ground=orange],.hv-scope [data-hv-ground=orange]{--hv-ring:" + HV_BLACK + "}" +
".hv-scope[data-hv-ground=bone],.hv-scope [data-hv-ground=bone]{--hv-ring:" + HV_BLACK + "}" +
".hv-scope :focus-visible{outline:2px solid var(--hv-ring)!important;outline-offset:3px!important}" +
".hv-scope .hv-num{font-variant-numeric:tabular-nums}" +
".hv-scope [data-hv-live]{transition:none!important}" +
".hv-scope .hv-press{transition:transform 160ms cubic-bezier(.2,.8,.2,1),background-color 160ms,color 160ms}" +
".hv-scope .hv-press:active{transform:scale(.97)}" +
".hv-scope .hv-arr{transition:transform 220ms cubic-bezier(.2,.8,.2,1)}.hv-scope a:hover .hv-arr,.hv-scope button:hover .hv-arr{transform:translateX(3px)}" +
"@media (prefers-reduced-motion:reduce){.hv-scope .hv-press,.hv-scope .hv-arr{transition:none}}";
/** Ardent's mark: a shock diamond, the bright knot in a rocket plume. Takes the current colour. */
function HvMark({ size = 22 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" aria-hidden>
<path d="M12 1.5 L19 12 L12 22.5 L5 12 Z" fill="currentColor" />
<path d="M12 7.2 L15.2 12 L12 16.8 L8.8 12 Z" fill="var(--hv-mark-cut, #0B0A09)" />
</svg>
);
}
/** The flame-edged fill as a path in a w x h box: a wavy top edge at progress p. */
function hbFlame(w: number, h: number, p: number, t: number): string {
const s = Math.sin(Math.PI * Math.min(1, p));
const top = h * (1 - p) + 3;
let d = "M0 " + (h + 1) + " L0 " + top.toFixed(2);
for (let i = 1; i <= 16; i++) {
const x = (i / 16) * w;
const y = top - s * (3.2 * Math.sin(x * 0.11 + t * 9) + 2 * Math.sin(x * 0.27 - t * 13) + 1.1 * Math.sin(x * 0.53 + t * 21)) - 3 * p;
d += " L" + x.toFixed(2) + " " + y.toFixed(2);
}
return d + " L" + w + " " + (h + 1) + " Z";
}
interface HbSpark {
x: number;
y: number;
vx: number;
vy: number;
life: number;
hot: boolean;
}
const HB_CSS =
"@keyframes hv-hb-nudge{0%,100%{transform:translateX(0)}25%{transform:translateX(-4px)}75%{transform:translateX(4px)}}" +
".hv-hb[data-nudge]{animation:hv-hb-nudge 260ms cubic-bezier(.3,.7,.4,1)}" +
".hv-hb{transition:transform 180ms cubic-bezier(.2,.8,.2,1),box-shadow 220ms}" +
".hv-hb[data-phase=holding]{transform:scale(.975)}" +
".hv-hb:hover{box-shadow:0 0 0 2px var(--hv-hb-ring)}" +
"@media (prefers-reduced-motion:reduce){.hv-hb,.hv-hb[data-nudge]{animation:none;transition:none}}";
export interface HoldToIgniteButtonProps {
label?: string;
/** What the button says while it is being held. */
holdingLabel?: string;
/** What it says once the hold completed. */
doneLabel?: string;
/** How long a full hold takes, in ms. */
holdMs?: number;
/** Called once when a hold completes. */
onComplete?: () => void;
/** Return to the idle state this long after completing (ms). 0 keeps it done. */
resetAfter?: number;
/** "md" 56px tall, "lg" 72px for a page's one big call. */
size?: "md" | "lg";
/** The ground the button sits on: "orange" draws it black with an orange fill, "black" draws it orange with a black fill. */
ground?: "orange" | "black";
className?: string;
style?: CSSProperties;
}
/**
* HoldToIgniteButton — a press-and-hold confirm for the one action that
* should not happen by accident.
*/
export function HoldToIgniteButton({
label = "Hold to reserve",
holdingLabel = "Keep holding",
doneLabel = "Reserved",
holdMs = 1100,
onComplete,
resetAfter = 0,
ground = "orange",
size = "md",
className = "",
style,
}: HoldToIgniteButtonProps) {
const btnRef = useRef<HTMLButtonElement>(null);
const fillRef = useRef<SVGPathElement>(null);
const inkRef = useRef<HTMLSpanElement>(null);
const sparkRef = useRef<HTMLCanvasElement>(null);
const [phase, setPhase] = useState<"idle" | "holding" | "done">("idle");
const [nudge, setNudge] = useState(0);
const hintId = useId();
const reduced = useHvReduced();
const st = useRef({ p: 0, down: false, raf: 0, last: 0, done: false, downAt: 0, loop: ((now: number) => void now) as (now: number) => void });
const cbRef = useRef(onComplete);
const holdRef = useRef(holdMs);
useEffect(() => {
cbRef.current = onComplete;
holdRef.current = holdMs;
});
const base = ground === "orange" ? HV_BLACK : HV_ORANGE;
const ink = ground === "orange" ? HV_ORANGE : HV_BLACK;
const fill = ground === "orange" ? HV_ORANGE : HV_BLACK;
const fillInk = ground === "orange" ? HV_BLACK : HV_ORANGE;
const paint = useCallback((now: number) => {
const b = btnRef.current;
const path = fillRef.current;
const top = inkRef.current;
if (!b || !path || !top) return;
const w = b.offsetWidth;
const h = b.offsetHeight;
const p = st.current.p;
const d = p <= 0 ? "" : hbFlame(w, h, p, hvReducedSnap() ? 0 : now / 1000);
path.setAttribute("d", d);
top.style.clipPath = p <= 0 ? "inset(100% 0 0 0)" : "path('" + d + "')";
}, []);
const burst = useCallback(() => {
const cv = sparkRef.current;
const b = btnRef.current;
if (!cv || !b || hvReducedSnap()) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
const dpr = Math.min(2, window.devicePixelRatio || 1);
const W = cv.offsetWidth;
const H = cv.offsetHeight;
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
const bw = b.offsetWidth;
const ox = (W - bw) / 2;
const oy = (H - b.offsetHeight) / 2;
const sparks: HbSpark[] = [];
for (let i = 0; i < 34; i++) {
const a = -Math.PI / 2 + (Math.random() - 0.5) * 2.4;
const v = 140 + Math.random() * 320;
sparks.push({ x: ox + Math.random() * bw, y: oy + 2, vx: Math.cos(a) * v, vy: Math.sin(a) * v, life: 0.45 + Math.random() * 0.5, hot: Math.random() < 0.35 });
}
let last = performance.now();
const tick = (now: number) => {
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, W, H);
let alive = 0;
for (const s of sparks) {
if (s.life <= 0) continue;
alive++;
s.life -= dt;
s.vy += 620 * dt;
s.vx *= 1 - 1.6 * dt;
const x0 = s.x;
const y0 = s.y;
s.x += s.vx * dt;
s.y += s.vy * dt;
ctx.strokeStyle = s.hot ? "rgba(255,238,200," + Math.min(1, s.life * 2.2).toFixed(3) + ")" : "rgba(255,122,26," + Math.min(1, s.life * 2.2).toFixed(3) + ")";
ctx.lineWidth = s.hot ? 1.6 : 2.2;
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(s.x, s.y);
ctx.stroke();
}
if (alive) requestAnimationFrame(tick);
else ctx.clearRect(0, 0, W, H);
};
requestAnimationFrame(tick);
}, []);
// The hold's frame loop: fills while held, drains when let go, lands at 1.
useEffect(() => {
const s = st.current;
const loop = (now: number) => {
s.raf = 0;
const dt = s.last ? Math.min(0.05, (now - s.last) / 1000) : 0;
s.last = now;
if (s.done) return;
const ms = Math.max(200, holdRef.current);
if (s.down) s.p = Math.min(1, s.p + (dt * 1000) / ms);
else s.p = Math.max(0, s.p - (dt * 1000 * 2.6) / ms);
paint(now);
if (s.p >= 1) {
s.done = true;
s.down = false;
setPhase("done");
burst();
if (typeof navigator !== "undefined" && "vibrate" in navigator) navigator.vibrate?.(18);
cbRef.current?.();
return;
}
if (s.down || s.p > 0) s.raf = requestAnimationFrame(loop);
else {
s.last = 0;
setPhase("idle");
}
};
s.loop = loop;
return () => {
if (s.raf) cancelAnimationFrame(s.raf);
s.raf = 0;
};
}, [paint, burst]);
const press = () => {
const s = st.current;
if (s.done || s.down) return;
s.down = true;
s.downAt = performance.now();
setPhase("holding");
if (!s.raf) s.raf = requestAnimationFrame(s.loop);
};
const release = () => {
const s = st.current;
if (!s.down) return;
s.down = false;
if (!s.done) setPhase("idle");
// A tap is not a hold: say so with a small shake.
if (performance.now() - s.downAt < 220) setNudge((n) => n + 1);
if (!s.raf) s.raf = requestAnimationFrame(s.loop);
};
useEffect(() => {
if (phase !== "done" || !resetAfter) return;
const id = window.setTimeout(() => {
const s = st.current;
s.done = false;
s.p = 0;
s.last = 0;
paint(performance.now());
setPhase("idle");
}, resetAfter);
return () => window.clearTimeout(id);
}, [phase, resetAfter, paint]);
useEffect(() => {
if (!nudge) return;
const b = btnRef.current;
if (!b) return;
b.removeAttribute("data-nudge");
void b.offsetWidth;
b.setAttribute("data-nudge", "");
const id = window.setTimeout(() => b.removeAttribute("data-nudge"), 300);
return () => window.clearTimeout(id);
}, [nudge]);
// All three labels share one grid cell, so the button is as wide as the longest and never reflows mid-hold.
const labels: [typeof phase, string][] = [
["idle", label],
["holding", holdingLabel],
["done", doneLabel],
];
const content = (
<>
{phase === "done" ? (
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden>
<path d="M3 8.5l3.2 3L13 4.5" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="square" />
</svg>
) : (
<HvMark size={16} />
)}
<span className="grid">
{labels.map(([k, t]) => (
<span key={k} aria-hidden={k !== phase ? true : undefined} style={{ gridArea: "1 / 1", visibility: k === phase ? "visible" : "hidden" }}>
{t}
</span>
))}
</span>
</>
);
return (
<span className={"hv-scope relative inline-flex " + className} style={style}>
<style>{HV_CSS + HB_CSS}</style>
<canvas ref={sparkRef} aria-hidden className="pointer-events-none absolute left-1/2 top-1/2 z-20 h-[300%] w-[160%] -translate-x-1/2 -translate-y-1/2" />
<button
ref={btnRef}
type="button"
data-phase={phase}
aria-describedby={hintId}
onPointerDown={(e) => {
if (e.button !== 0) return;
e.currentTarget.setPointerCapture(e.pointerId);
press();
}}
onPointerUp={release}
onPointerCancel={release}
onLostPointerCapture={release}
onKeyDown={(e) => {
if ((e.key === " " || e.key === "Enter") && !e.repeat) {
e.preventDefault();
press();
} else if (e.key === " " || e.key === "Enter") e.preventDefault();
}}
onKeyUp={(e) => {
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
release();
}
}}
onBlur={release}
onContextMenu={(e) => e.preventDefault()}
onClick={(e) => {
// A click with no pointer or key behind it (a screen reader, voice control) cannot hold: it confirms at once.
const s = st.current;
if (e.detail !== 0 || s.done || s.down) return;
s.p = 1;
s.last = 0;
if (!s.raf) s.raf = requestAnimationFrame(s.loop);
}}
className={"hv-hb relative inline-flex select-none items-center overflow-hidden border-0 font-bold uppercase tracking-[0.06em] " + (size === "lg" ? "h-[72px] px-9 text-[19px]" : "h-14 px-7 text-[15px]")}
style={{ background: base, color: ink, fontFamily: HV_DISPLAY, touchAction: "none", WebkitTouchCallout: "none", "--hv-hb-ring": fill, cursor: phase === "done" ? "default" : "pointer" } as CSSProperties}
>
<svg aria-hidden className="pointer-events-none absolute inset-0 h-full w-full overflow-visible">
<path ref={fillRef} fill={fill} />
</svg>
<span className="relative inline-flex items-center gap-2.5" style={{ "--hv-mark-cut": base } as CSSProperties}>
{content}
</span>
<span ref={inkRef} aria-hidden data-hv-live="" className={"pointer-events-none absolute inset-0 flex items-center " + (size === "lg" ? "px-9" : "px-7")} style={{ color: fillInk, clipPath: "inset(100% 0 0 0)" }}>
<span className="inline-flex items-center gap-2.5" style={{ "--hv-mark-cut": fill } as CSSProperties}>
{content}
</span>
</span>
</button>
<span id={hintId} className="sr-only">
{reduced ? "Press and hold to confirm." : "Press and hold for about a second to confirm."}
</span>
<span aria-live="polite" className="sr-only">
{phase === "done" ? doneLabel : ""}
</span>
</span>
);
}
Free. Pro unlocks everything, from $27/month.
Details
About this component
Some actions should not happen on a stray click: booking, deleting, launching. This button asks for a deliberate hold. Press it and a flame-edged fill rises from the bottom while the label says keep holding; the type inverts exactly where the fill has reached. Let go early and it drains back. Hold for about a second and it lands: a burst of sparks flies off the top, the device buzzes where it can, the label turns to your done text with a tick, and your callback runs. A quick tap gets a little shake, so nobody wonders why nothing happened. Works on black or on safety orange.
Curator’s note
Free. The fill is an SVG path with a wavy top edge redrawn each frame while held, and the label's second colour is the same path as a CSS clip-path, so the type inverts exactly under the flame. Sparks are a short canvas burst. Pointer capture, Space and Enter holds, a shake for a tap, vibration where supported. Every Hi-Vis entry is cut byte for byte from one site (Ardent, a made-up engine maker) and shares its fixed palette: safety orange #FF5B14 as a field, warm black, bone, Archivo's width axis for the shouting.

