Signature Consent

Signature

Signature capture as consent — a canvas ink strip with pen-pressure feel that retraces itself in a clean witness replay before Confirm embosses it as Authorized.

Install
npx shadcn add https://design.helpmarq.com/r/signature-consent.json
Source
registry/core/signature-consent/component.tsx
"use client";

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

// ---------------------------------------------------------------------------
// DeedStroke — signature capture as consent, not a checkbox tick (see
// checkbox-ink-stroke for that). A canvas ink layer renders pointer strokes with
// pen-pressure feel (velocity maps inversely to line width, a pooled dab at
// every stroke start), backed by a baseline guide div. On release, a
// "witness" replay retraces the captured points once at a constant pace so
// the signer sees their mark rendered cleanly, then Confirm unlocks —
// confirming embosses the strip (inset shadow + caption swap to
// "Authorized"). Clear resets. A keyboard/no-pointer path lets the signer
// type their name instead, styled as an italic mark, which skips the replay
// (there is nothing to smooth) and unlocks Confirm as soon as it is non-empty.
//
// All ink drawing is direct 2D-canvas calls driven by refs — no React state
// on the draw or replay hot path. Hover reveals a two-tone nib-dot CSS
// cursor (visible on any theme, no JS pointer tracking needed) and brightens
// the baseline guide via a plain CSS :hover rule.
// ---------------------------------------------------------------------------

export interface DeedStrokeValue {
  mode: "draw" | "type";
  dataUrl?: string;
  name?: string;
}

export interface DeedStrokeProps {
  /** Caption shown before signing. Default "Sign to authorize". */
  prompt?: string;
  /** Called once, when the signer confirms. */
  onConfirm?: (value: DeedStrokeValue) => void;
  className?: string;
}

type Point = { x: number; y: number; t: number };
type Stroke = Point[];

const MIN_WIDTH = 1.1;
const MAX_WIDTH = 4.4;
const VELOCITY_SCALE = 0.09; // px/ms -> width falloff
const POOL_RADIUS = MAX_WIDTH * 0.95;
const REPLAY_MS = 650;
const REPLAY_WIDTH = 1.8;
const MIN_POINTS_TO_SIGN = 2;

// Two-tone dot (dark center, light ring) reads on any theme without CSS
// variables — cursor images can't resolve custom properties.
const NIB_CURSOR = `url("data:image/svg+xml,${encodeURIComponent(
  '<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><circle cx="5" cy="5" r="4" fill="%23f5f5f5" stroke="%23171717" stroke-width="1"/><circle cx="5" cy="5" r="1.5" fill="%23171717"/></svg>'
)}") 5 5, crosshair`;

function resolveInk(canvas: HTMLCanvasElement): string {
  return getComputedStyle(canvas).color || "#ededed";
}

function distance(a: Point, b: Point) {
  return Math.hypot(b.x - a.x, b.y - a.y);
}

function strokeSegment(
  ctx: CanvasRenderingContext2D,
  a: Point,
  b: Point,
  width: number,
  color: string
) {
  ctx.strokeStyle = color;
  ctx.lineWidth = width;
  ctx.lineCap = "round";
  ctx.lineJoin = "round";
  ctx.beginPath();
  ctx.moveTo(a.x, a.y);
  ctx.lineTo(b.x, b.y);
  ctx.stroke();
}

function drawPool(ctx: CanvasRenderingContext2D, p: Point, color: string) {
  ctx.fillStyle = color;
  ctx.beginPath();
  ctx.arc(p.x, p.y, POOL_RADIUS, 0, Math.PI * 2);
  ctx.fill();
}

export function DeedStroke({ prompt = "Sign to authorize", onConfirm, className = "" }: DeedStrokeProps) {
  const autoId = useId().replace(/:/g, "");
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  const strokesRef = useRef<Stroke[]>([]);
  const drawingRef = useRef(false);
  const lastPointRef = useRef<Point | null>(null);
  const inkColorRef = useRef("#ededed");
  const reducedRef = useRef(false);
  const replayFrameRef = useRef<number | undefined>(undefined);

  const [mode, setMode] = useState<"draw" | "type">("draw");
  const [typedName, setTypedName] = useState("");
  const [hasStroke, setHasStroke] = useState(false);
  const [replaying, setReplaying] = useState(false);
  const [canConfirm, setCanConfirm] = useState(false);
  const [confirmed, setConfirmed] = useState(false);
  const [announce, setAnnounce] = useState("");

  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    reducedRef.current = mq.matches;
    const onChange = () => {
      reducedRef.current = mq.matches;
    };
    mq.addEventListener("change", onChange);
    return () => mq.removeEventListener("change", onChange);
  }, []);

  useEffect(() => () => {
    if (replayFrameRef.current) cancelAnimationFrame(replayFrameRef.current);
  }, []);

  const clearCanvas = useCallback(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext("2d");
    ctx?.clearRect(0, 0, canvas.width, canvas.height);
  }, []);

  const reset = useCallback(() => {
    if (replayFrameRef.current) cancelAnimationFrame(replayFrameRef.current);
    strokesRef.current = [];
    lastPointRef.current = null;
    drawingRef.current = false;
    clearCanvas();
    setHasStroke(false);
    setReplaying(false);
    setCanConfirm(false);
    setConfirmed(false);
    setTypedName("");
    setAnnounce("Signature cleared.");
  }, [clearCanvas]);

  // Retrace every captured point once at a constant pace, redrawing the
  // whole path-so-far each frame (a signature is a few hundred points at
  // most — cheap to redraw wholesale, and it sidesteps incremental-segment
  // bookkeeping across the pen-up gaps between strokes).
  const runWitnessReplay = useCallback(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;
    const strokes = strokesRef.current;
    const color = inkColorRef.current;

    type Seg = { a: Point; b: Point; len: number };
    const segments: Seg[] = [];
    let total = 0;
    for (const stroke of strokes) {
      for (let i = 1; i < stroke.length; i++) {
        const len = distance(stroke[i - 1]!, stroke[i]!);
        segments.push({ a: stroke[i - 1]!, b: stroke[i]!, len });
        total += len;
      }
    }
    if (total === 0) {
      setCanConfirm(true);
      setReplaying(false);
      return;
    }

    setReplaying(true);
    const start = performance.now();

    const frame = (now: number) => {
      const fraction = Math.min(1, (now - start) / REPLAY_MS);
      clearCanvas();
      let budget = total * fraction;
      for (const seg of segments) {
        if (budget <= 0) break;
        if (seg.len <= budget) {
          strokeSegment(ctx, seg.a, seg.b, REPLAY_WIDTH, color);
          budget -= seg.len;
        } else {
          const t = seg.len === 0 ? 0 : budget / seg.len;
          const bx = seg.a.x + (seg.b.x - seg.a.x) * t;
          const by = seg.a.y + (seg.b.y - seg.a.y) * t;
          strokeSegment(ctx, seg.a, { x: bx, y: by, t: seg.a.t }, REPLAY_WIDTH, color);
          budget = 0;
        }
      }
      if (fraction < 1) {
        replayFrameRef.current = requestAnimationFrame(frame);
      } else {
        setReplaying(false);
        setCanConfirm(true);
        setAnnounce("Signature captured. Ready to confirm.");
      }
    };
    replayFrameRef.current = requestAnimationFrame(frame);
  }, [clearCanvas]);

  const onPointerDown = useCallback((e: React.PointerEvent<HTMLCanvasElement>) => {
    if (confirmed || replaying) return;
    const canvas = canvasRef.current;
    if (!canvas) return;
    canvas.setPointerCapture(e.pointerId);
    inkColorRef.current = resolveInk(canvas);
    const rect = canvas.getBoundingClientRect();
    const p: Point = { x: e.clientX - rect.left, y: e.clientY - rect.top, t: performance.now() };
    drawingRef.current = true;
    strokesRef.current.push([p]);
    lastPointRef.current = p;
    const ctx = canvas.getContext("2d");
    if (ctx) drawPool(ctx, p, inkColorRef.current);
  }, [confirmed, replaying]);

  const onPointerMove = useCallback((e: React.PointerEvent<HTMLCanvasElement>) => {
    if (!drawingRef.current) return;
    const canvas = canvasRef.current;
    const last = lastPointRef.current;
    if (!canvas || !last) return;
    const rect = canvas.getBoundingClientRect();
    const p: Point = { x: e.clientX - rect.left, y: e.clientY - rect.top, t: performance.now() };
    const dt = Math.max(1, p.t - last.t);
    const dist = distance(last, p);
    const velocity = dist / dt;
    const width = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, MAX_WIDTH - velocity / VELOCITY_SCALE));
    const ctx = canvas.getContext("2d");
    if (ctx) strokeSegment(ctx, last, p, width, inkColorRef.current);
    strokesRef.current[strokesRef.current.length - 1]?.push(p);
    lastPointRef.current = p;
  }, []);

  const finishStroke = useCallback(() => {
    if (!drawingRef.current) return;
    drawingRef.current = false;
    lastPointRef.current = null;
    const totalPoints = strokesRef.current.reduce((n, s) => n + s.length, 0);
    const signed = totalPoints >= MIN_POINTS_TO_SIGN;
    setHasStroke(signed);
    if (!signed) return;
    if (reducedRef.current) {
      setCanConfirm(true);
      setAnnounce("Signature captured. Ready to confirm.");
    } else {
      runWitnessReplay();
    }
  }, [runWitnessReplay]);

  const handleConfirm = useCallback(() => {
    if (!canConfirm || confirmed) return;
    setConfirmed(true);
    setAnnounce("Authorized.");
    if (mode === "draw") {
      onConfirm?.({ mode, dataUrl: canvasRef.current?.toDataURL() });
    } else {
      onConfirm?.({ mode, name: typedName.trim() });
    }
  }, [canConfirm, confirmed, mode, onConfirm, typedName]);

  const toggleMode = useCallback(() => {
    if (confirmed) return;
    setMode((m) => (m === "draw" ? "type" : "draw"));
    reset();
  }, [confirmed, reset]);

  const nameId = `signature-consent-name-${autoId}`;
  const captionText = confirmed ? "Authorized" : prompt;
  const drawReady = mode === "draw" && canConfirm && !confirmed;
  const typeReady = mode === "type" && typedName.trim().length > 0 && !confirmed;
  const confirmEnabled = drawReady || typeReady;

  return (
    <div className={`w-full max-w-md rounded-[12px] border border-border bg-background ${className}`}>
      <style>{CSS}</style>
      <span role="status" aria-live="polite" aria-atomic="true" className="sr-only">
        {announce}
      </span>

      <div className="flex items-center justify-between gap-3 border-b border-border px-4 py-3">
        <p className="font-mono text-xs uppercase tracking-[0.2em] text-muted">{captionText}</p>
        <button
          type="button"
          onClick={toggleMode}
          disabled={confirmed}
          className="ns-ds-link font-mono text-[11px] text-muted underline-offset-4 hover:text-foreground hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent disabled:cursor-not-allowed disabled:opacity-40"
        >
          {mode === "draw" ? "Type your name instead" : "Draw signature instead"}
        </button>
      </div>

      <div className="px-5 py-6">
        {mode === "draw" ? (
          <div
            className={`ns-ds-area relative h-32 w-full overflow-hidden rounded-[6px] border border-border transition-shadow duration-200 ${
              confirmed ? "ns-ds-embossed" : ""
            }`}
          >
            <div aria-hidden="true" className="ns-ds-baseline absolute inset-x-4 bottom-9 h-px bg-muted" />
            <canvas
              ref={canvasRef}
              width={352}
              height={128}
              role="img"
              aria-label={confirmed ? "Authorized signature" : "Signature pad — draw with your pointer"}
              className="ns-ds-canvas relative h-32 w-full touch-none text-foreground"
              onPointerDown={onPointerDown}
              onPointerMove={onPointerMove}
              onPointerUp={finishStroke}
              onPointerLeave={finishStroke}
              onPointerCancel={finishStroke}
              style={{ cursor: confirmed || replaying ? "default" : NIB_CURSOR }}
            />
            {!hasStroke && !replaying && (
              <p
                aria-hidden="true"
                className="pointer-events-none absolute inset-x-0 bottom-2 text-center font-mono text-[10px] uppercase tracking-widest text-muted"
              >
                Draw here
              </p>
            )}
          </div>
        ) : (
          <div>
            <label htmlFor={nameId} className="sr-only">
              Type your name
            </label>
            <input
              id={nameId}
              type="text"
              value={typedName}
              disabled={confirmed}
              onChange={(e) => setTypedName(e.target.value)}
              placeholder="Type your name"
              autoComplete="name"
              className={`ns-ds-name-input w-full rounded-[6px] border border-border bg-transparent px-3 py-4 text-2xl text-foreground placeholder:text-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent ${
                confirmed ? "ns-ds-embossed" : ""
              }`}
            />
          </div>
        )}
      </div>

      <div className="flex items-center justify-between gap-3 border-t border-border px-4 py-3">
        <button
          type="button"
          onClick={reset}
          disabled={!hasStroke && typedName.length === 0 && !confirmed}
          className="font-mono text-xs text-muted underline-offset-4 hover:text-foreground hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent disabled:cursor-not-allowed disabled:opacity-40"
        >
          Clear
        </button>
        <button
          type="button"
          onClick={handleConfirm}
          disabled={!confirmEnabled}
          className="rounded-[6px] border border-border bg-foreground px-4 py-1.5 font-mono text-xs text-background hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent disabled:cursor-not-allowed disabled:border-border disabled:bg-transparent disabled:text-muted disabled:opacity-60"
        >
          {confirmed ? "Authorized" : "Confirm"}
        </button>
      </div>
    </div>
  );
}

const CSS = `
.ns-ds-area:hover .ns-ds-baseline{ background: var(--foreground); }
.ns-ds-embossed{
  box-shadow: inset 0 1px 3px rgba(0,0,0,0.45), inset 0 -1px 0 rgba(255,255,255,0.04);
  transition: box-shadow 200ms ease-out;
}
@media (prefers-reduced-motion: reduce){
  .ns-ds-embossed{ transition: none; }
}
`;

export default DeedStroke;
Build spec

Build a sign-to-confirm signature strip, distinct from a checkbox-style consent tick (that's checkbox-ink-stroke) — this is a physical-feeling capture of an actual signature. Structure: a bordered rounded-[12px] card. Header row: a Geist Mono uppercase caption reading 'Sign to authorize' (or the `prompt` prop) on the left, and on the right a real <button> reading 'Type your name instead' — this button must be the FIRST focusable/clickable element in the DOM so that any generic hover/press/focus check exercises a control that is always enabled and always shows a genuine visual change (underline + color shift to --foreground on hover, focus-visible outline in --accent) — never place a possibly-disabled Confirm button first. Below the header: either the draw surface (default mode) or a type-your-name input (toggled mode), never both. Footer row: a 'Clear' text-button on the left (disabled only when there is nothing to clear) and a 'Confirm' button on the right (disabled until a signature exists), which after confirming reads 'Authorized' and disables permanently until Clear. Draw mode: a canvas (backed by a getBoundingClientRect()-relative coordinate space) sits over a baseline guide — a plain absolutely-positioned div with a 1px --muted background line near the bottom of the strip, NOT an SVG line (per the project's known trap: SVG pathLength + vectorEffect=non-scaling-stroke miscomputes dashes in screen space; a straight guide line is simplest as a div regardless). On pointerdown, capture the pointer, resolve the ink color once by reading getComputedStyle(canvas).color (set `color: var(--foreground)` in the canvas's className so this resolves correctly in both themes — canvas 2D context strokeStyle cannot parse a raw CSS variable string), and draw a filled 'ink pooling' dot at the first point (radius ~4.2px) to read as a heavier pen-down moment. On pointermove while drawing, compute velocity as distance/time between the previous and current point and map it INVERSELY to line width — slow movement produces thick ink (up to ~4.4px), fast movement thins toward ~1.1px — then stroke a round-capped/joined segment at that width from the last point to the current one; do this as direct canvas draw calls, never through React state per frame. On pointerup/leave/cancel, if the stroke has at least 2 points captured, it counts as signed. Witness replay: unless prefers-reduced-motion is active, clear the canvas and replay ALL captured points (across every stroke drawn, preserving pen-up gaps between separate strokes) at a constant pace over a fixed ~650ms using requestAnimationFrame — compute total arc length across every segment, and each frame redraw every segment whose cumulative length falls under `totalLength * (elapsed / duration)`, using a constant witness stroke width (~1.8px) rather than the original pressure-varying widths, so the retrace reads as a clean 'this is what you signed' confirmation pass rather than a pressure-sensitive scribble. Redrawing the whole path-so-far from scratch every frame is fine at signature-sized point counts (a few hundred points) — no incremental-segment bookkeeping needed. Once the replay reaches 100%, enable the Confirm button and announce via the live region. Under reduced motion, skip the replay outright: leave the original ink as drawn and enable Confirm immediately with no animation. Hover affordance on the draw surface: instead of tracking the pointer with a moving DOM element, set a custom two-tone CSS cursor (a small light-ringed dark dot, or its inverse — must read on both themes since a cursor image can't use CSS custom properties) via a data-URI inline SVG on the canvas's `cursor` style, so the nib-dot hover cue costs zero JS. The baseline guide brightens (--muted -> --foreground) on hover of the draw-surface wrapper via a plain CSS `:hover` rule, no JS needed there either. Confirm: applies an embossed look — an inset box-shadow press (dark inset shadow top-ish, faint light inset at the opposite edge) on the signed surface (canvas wrapper in draw mode, or the input itself in type mode) with a short CSS transition, and swaps the header caption text to 'Authorized'. This transition is skipped (instant, no `transition`) under prefers-reduced-motion, per the brief's 'instant emboss'. Clear resets everything — captured strokes, typed name, confirmed/replay/canConfirm state — back to the initial caption and an empty, re-drawable surface, and works whether or not a confirm has already happened. Type-mode fallback (the keyboard/no-pointer path — canvas drawing has no keyboard equivalent, so this IS the accessible alternative, not a nice-to-have): clicking 'Type your name instead' swaps to a labelled text `<input>` (visually-hidden `<label>`, `autoComplete="name"`) styled with an italic, slightly skewed treatment (font-style italic, a small skewX transform, larger size) using Geist Sans — never a separate script-font dependency. Confirm enables as soon as the trimmed value is non-empty; there is no replay concept for typed text (nothing to smooth), so it unlocks immediately on input. The toggle button is disabled once confirmed (can't switch modes on an authorized signature) and switching modes always resets state via the same Clear logic. Accessibility: a dedicated sr-only `role=status aria-live=polite aria-atomic=true` span announces 'Signature cleared.', 'Signature captured. Ready to confirm.', and 'Authorized.' at the relevant transitions — kept separate from any button label so atomic re-reading never duplicates surrounding text. The canvas carries `role="img"` with an `aria-label` describing its current state ('Signature pad — draw with your pointer' / 'Authorized signature') since it is not itself keyboard-operable — the typed-name input is the real keyboard path, not a decorative extra. Clear and Confirm are real `<button>` elements with plain text content (always named). No dependencies, no gradient backgrounds, no color outside the repo's CSS variables.

Tags
signatureconsentcanvasinkformconfirmationaccessibility