Skip to content

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

FreeHi-Visplayful
Preview

Props

No runtime dependencies
"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.

Pairs well with

Trending
NewPro
Pro