Skip to main content

ns-ui

Confirm Slide Shatter

Frosted-glass confirm slider where drag distance drives Voronoi crack density. Release early and the cracks heal on a spring; complete the travel and the pane explodes into tumbling glass shards revealing the confirmed state.

Use when a frosted-glass CONFIRM slider — drag distance grows Voronoi cracks that heal if you stop early, and shatter into glass shards on full travel; use for a destructive confirm action, not a plain value-reading input; pick confirm-hold-ink instead when the gate should be time held rather than distance dragged, confirm-dial-align when it should be rotational accuracy, or slider-loupe when the drag is setting a value rather than gating an action.

Install

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

Source
registry/core/confirm-slide-shatter/component.tsx
"use client";

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

type Pt = { x: number; y: number };
type Edge = { ax: number; ay: number; bx: number; by: number; t: number };
type Shard = {
  poly: Pt[];
  cx: number;
  cy: number;
  ox: number;
  oy: number;
  vx: number;
  vy: number;
  rot: number;
  vr: number;
};

const INSET = 4;
const RADIUS = 12;
// canvas bleeds past the track so shards can tumble out of frame
const PAD_X = 160;
const PAD_TOP = 120;
const PAD_BOTTOM = 320;
const COMMIT_AT = 0.98;
const SHATTER_MS = 900;
const FADE_MS = 700;
const KEY_STEP = 0.08;

/** dart-throwing Poisson disc — relaxes min distance if the pane is crowded */
function poissonPoints(w: number, h: number, count: number, minDist: number): Pt[] {
  const pts: Pt[] = [];
  let d = minDist;
  let attempts = 0;
  while (pts.length < count && attempts < 6000) {
    attempts++;
    const c = { x: Math.random() * w, y: Math.random() * h };
    let ok = true;
    for (const q of pts) {
      const dx = q.x - c.x;
      const dy = q.y - c.y;
      if (dx * dx + dy * dy < d * d) {
        ok = false;
        break;
      }
    }
    if (ok) pts.push(c);
    if (attempts % 1500 === 0) d *= 0.85;
  }
  return pts;
}

/** keep the part of `poly` on the seed's side of the bisector (dot(p-m, n) <= 0) */
function clipHalfPlane(poly: Pt[], mx: number, my: number, nx: number, ny: number): Pt[] {
  const out: Pt[] = [];
  for (let i = 0; i < poly.length; i++) {
    const a = poly[i];
    const b = poly[(i + 1) % poly.length];
    const da = (a.x - mx) * nx + (a.y - my) * ny;
    const db = (b.x - mx) * nx + (b.y - my) * ny;
    if (da <= 0) out.push(a);
    if (da <= 0 !== db <= 0) {
      const f = da / (da - db);
      out.push({ x: a.x + (b.x - a.x) * f, y: a.y + (b.y - a.y) * f });
    }
  }
  return out;
}

/** exact Voronoi cells via half-plane clipping — O(n²), computed once at mount */
function voronoiCells(seeds: Pt[], w: number, h: number): Pt[][] {
  return seeds
    .map((s) => {
      let poly: Pt[] = [
        { x: 0, y: 0 },
        { x: w, y: 0 },
        { x: w, y: h },
        { x: 0, y: h },
      ];
      for (const o of seeds) {
        if (o === s) continue;
        poly = clipHalfPlane(poly, (s.x + o.x) / 2, (s.y + o.y) / 2, o.x - s.x, o.y - s.y);
        if (poly.length === 0) break;
      }
      return poly;
    })
    .filter((poly) => poly.length >= 3);
}

/** dedupe shared cell walls into crack polylines, each with a reveal threshold */
function crackEdges(cells: Pt[][], w: number, h: number, ox: number, oy: number): Edge[] {
  const eps = 0.5;
  const maxD = Math.hypot(w - ox, Math.max(oy, h - oy));
  const seen = new Set<string>();
  const edges: Edge[] = [];
  for (const poly of cells) {
    for (let i = 0; i < poly.length; i++) {
      const a = poly[i];
      const b = poly[(i + 1) % poly.length];
      // the pane's own border is a frame, not a crack
      if (
        (a.x < eps && b.x < eps) ||
        (a.x > w - eps && b.x > w - eps) ||
        (a.y < eps && b.y < eps) ||
        (a.y > h - eps && b.y > h - eps)
      )
        continue;
      const ka = `${a.x.toFixed(1)},${a.y.toFixed(1)}`;
      const kb = `${b.x.toFixed(1)},${b.y.toFixed(1)}`;
      const key = ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`;
      if (seen.has(key)) continue;
      seen.add(key);
      const da = Math.hypot(a.x - ox, a.y - oy);
      const db = Math.hypot(b.x - ox, b.y - oy);
      const near = da <= db ? a : b;
      const far = da <= db ? b : a;
      // map raw distance into [0.04, 0.86] so every crack finishes growing by p≈0.98
      const t = 0.04 + 0.82 * (Math.min(da, db) / maxD);
      edges.push({ ax: near.x, ay: near.y, bx: far.x, by: far.y, t });
    }
  }
  return edges;
}

function roundedRectPath(
  ctx: CanvasRenderingContext2D,
  x: number,
  y: number,
  w: number,
  h: number,
  r: number
) {
  ctx.beginPath();
  ctx.moveTo(x + r, y);
  ctx.arcTo(x + w, y, x + w, y + h, r);
  ctx.arcTo(x + w, y + h, x, y + h, r);
  ctx.arcTo(x, y + h, x, y, r);
  ctx.arcTo(x, y, x + w, y, r);
  ctx.closePath();
}

// Frosted-glass confirm slider: drag distance IS crack density. Voronoi
// fractures spider from the thumb, heal on a spring if released early, and at
// full travel the pane explodes into tumbling shards revealing the confirmed
// state. Progress lives in refs; all drawing is direct-DOM/canvas in one rAF
// loop that sleeps when settled.
export function SlideToShatter({
  label = "SLIDE TO CONFIRM",
  confirmedLabel = "CONFIRMED",
  width = 320,
  height = 56,
  shardCount = 48,
  onConfirm,
  resetKey = 0,
  className = "",
}: {
  /** mono label etched on the glass */
  label?: string;
  /** label revealed once the pane shatters */
  confirmedLabel?: string;
  /** px width of the glass pane */
  width?: number;
  /** px height of the glass pane */
  height?: number;
  /** approximate Voronoi cell count — cracks while dragging, shards on commit */
  shardCount?: number;
  /** called once the pane fully shatters */
  onConfirm?: () => void;
  /** bump to restore the pane and replay */
  resetKey?: number;
  /** extra classes merged onto the rendered root element */
  className?: string;
}) {
  const rootRef = useRef<HTMLDivElement>(null);
  const glassRef = useRef<HTMLDivElement>(null);
  const thumbRef = useRef<HTMLButtonElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const [confirmed, setConfirmed] = useState(false);
  const onConfirmRef = useRef(onConfirm);
  onConfirmRef.current = onConfirm;

  useEffect(() => {
    const root = rootRef.current;
    const glass = glassRef.current;
    const thumb = thumbRef.current;
    const canvas = canvasRef.current;
    if (!root || !glass || !thumb || !canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;
    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    // reset pane state (also runs on resetKey bump)
    setConfirmed(false);
    glass.style.visibility = "";
    thumb.style.visibility = "";
    thumb.style.transform = "translateX(0px)";
    thumb.setAttribute("aria-valuenow", "0");
    root.style.transform = "";

    const thumbSize = height - INSET * 2;
    const maxTravel = width - INSET * 2 - thumbSize;
    const cw = width + PAD_X * 2;
    const ch = height + PAD_TOP + PAD_BOTTOM;

    // geometry: one Poisson + Voronoi pass, reused as cracks AND shards
    const originX = INSET + thumbSize / 2;
    const originY = height / 2;
    const seeds = reduced
      ? []
      : poissonPoints(
          width,
          height,
          shardCount,
          Math.max(8, Math.sqrt((width * height) / shardCount) * 0.72)
        );
    const cells = voronoiCells(seeds, width, height);
    const edges = crackEdges(cells, width, height, originX, originY);

    if (!reduced) {
      const dpr = Math.min(window.devicePixelRatio || 1, 2);
      canvas.width = Math.round(cw * dpr);
      canvas.height = Math.round(ch * dpr);
      ctx.setTransform(dpr, 0, 0, dpr, PAD_X * dpr, PAD_TOP * dpr);
      ctx.lineCap = "round";
      ctx.lineJoin = "round";
      ctx.clearRect(-PAD_X, -PAD_TOP, cw, ch);
    }

    const S = {
      p: 0,
      v: 0,
      dragging: false,
      mode: "idle" as "idle" | "drag" | "spring" | "shatter",
      raf: 0,
      last: 0,
      startX: 0,
      startP: 0,
      shatterStart: 0,
      shards: [] as Shard[],
    };

    const setThumb = (p: number) => {
      thumb.style.transform = `translateX(${p * maxTravel}px)`;
    };

    const drawCracks = (p: number) => {
      ctx.clearRect(-PAD_X, -PAD_TOP, cw, ch);
      if (p <= 0) return;
      ctx.save();
      roundedRectPath(ctx, 0.5, 0.5, width - 1, height - 1, RADIUS);
      ctx.clip();
      // two passes: hairline + offset ghost stroke for glass depth
      const passes = [
        { dx: 0.75, dy: 0.75, w: 0.5, c: "rgba(255,255,255,0.12)" },
        { dx: 0, dy: 0, w: 1, c: "rgba(255,255,255,0.35)" },
      ];
      for (const pass of passes) {
        ctx.beginPath();
        for (const e of edges) {
          if (p <= e.t) continue;
          // cracks grow segment-by-segment from the near end outward
          const f = Math.min(1, (p - e.t) / 0.12);
          ctx.moveTo(e.ax + pass.dx, e.ay + pass.dy);
          ctx.lineTo(e.ax + (e.bx - e.ax) * f + pass.dx, e.ay + (e.by - e.ay) * f + pass.dy);
        }
        ctx.lineWidth = pass.w;
        ctx.strokeStyle = pass.c;
        ctx.stroke();
      }
      ctx.restore();
    };

    const buildShards = (): Shard[] => {
      const tx = width - INSET - thumbSize / 2;
      const ty = height / 2;
      return cells.map((poly) => {
        let cx = 0;
        let cy = 0;
        for (const pt of poly) {
          cx += pt.x;
          cy += pt.y;
        }
        cx /= poly.length;
        cy /= poly.length;
        let dx = cx - tx;
        let dy = cy - ty;
        const d = Math.hypot(dx, dy);
        if (d < 1) {
          const a = Math.random() * Math.PI * 2;
          dx = Math.cos(a);
          dy = Math.sin(a);
        } else {
          dx /= d;
          dy /= d;
        }
        const speed = 120 + Math.random() * 300;
        return {
          poly: poly.map((pt) => ({ x: pt.x - cx, y: pt.y - cy })),
          cx,
          cy,
          ox: 0,
          oy: 0,
          vx: dx * speed,
          vy: dy * speed,
          rot: 0,
          vr: (Math.random() * 2 - 1) * 3,
        };
      });
    };

    const stepShards = (dt: number) => {
      for (const s of S.shards) {
        s.vy += 1800 * dt;
        s.ox += s.vx * dt;
        s.oy += s.vy * dt;
        s.rot += s.vr * dt;
      }
    };

    const drawShards = (elapsed: number) => {
      ctx.clearRect(-PAD_X, -PAD_TOP, cw, ch);
      const alpha = Math.max(0, 1 - elapsed / FADE_MS);
      if (alpha <= 0) return;
      for (const s of S.shards) {
        ctx.save();
        ctx.translate(s.cx + s.ox, s.cy + s.oy);
        ctx.rotate(s.rot);
        ctx.globalAlpha = alpha;
        ctx.beginPath();
        for (let i = 0; i < s.poly.length; i++) {
          const pt = s.poly[i];
          if (i === 0) ctx.moveTo(pt.x, pt.y);
          else ctx.lineTo(pt.x, pt.y);
        }
        ctx.closePath();
        ctx.fillStyle = "rgba(255,255,255,0.10)";
        ctx.fill();
        ctx.lineWidth = 1;
        ctx.strokeStyle = "rgba(255,255,255,0.4)";
        ctx.stroke();
        ctx.restore();
      }
    };

    const wake = () => {
      if (!S.raf) {
        S.last = performance.now();
        S.raf = requestAnimationFrame(loop);
      }
    };

    const commit = () => {
      S.dragging = false;
      S.p = 1;
      setThumb(1);
      thumb.setAttribute("aria-valuenow", "100");
      // the pane is gone the instant it breaks
      glass.style.visibility = "hidden";
      thumb.style.visibility = "hidden";
      root.style.transform = "";
      setConfirmed(true);
      if (!reduced) {
        S.mode = "shatter";
        S.shatterStart = performance.now();
        S.shards = buildShards();
        wake();
      } else {
        S.mode = "idle";
      }
      onConfirmRef.current?.();
    };

    const loop = (now: number) => {
      const dt = Math.min((now - S.last) / 1000, 1 / 30);
      S.last = now;
      let alive = false;
      if (S.mode === "drag") {
        if (S.p >= COMMIT_AT) {
          commit();
          alive = true;
        } else {
          const p = S.p;
          setThumb(p);
          drawCracks(p);
          // structural shudder once the pane is badly cracked
          root.style.transform =
            p > 0.6
              ? `translate(${Math.random() - 0.5}px, ${Math.random() - 0.5}px)`
              : "";
          alive = true;
        }
      } else if (S.mode === "spring") {
        // stiffness 220 / damping 26 — slightly underdamped, tiny overshoot
        S.v += (-220 * S.p - 26 * S.v) * dt;
        S.p += S.v * dt;
        if (Math.abs(S.p) < 0.001 && Math.abs(S.v) < 0.01) {
          S.p = 0;
          S.v = 0;
          S.mode = "idle";
          setThumb(0);
          drawCracks(0);
          root.style.transform = "";
        } else {
          const p = Math.max(0, S.p);
          setThumb(p);
          drawCracks(p); // same t-mapping in reverse — the heal is free
          root.style.transform = "";
          alive = true;
        }
      } else if (S.mode === "shatter") {
        const elapsed = now - S.shatterStart;
        if (elapsed >= SHATTER_MS) {
          ctx.clearRect(-PAD_X, -PAD_TOP, cw, ch);
          S.mode = "idle";
        } else {
          stepShards(dt);
          drawShards(elapsed);
          alive = true;
        }
      }
      S.raf = alive ? requestAnimationFrame(loop) : 0;
    };

    const onDown = (e: PointerEvent) => {
      if (S.mode === "shatter" || glass.style.visibility === "hidden") return;
      thumb.setPointerCapture(e.pointerId);
      S.dragging = true;
      S.mode = "drag";
      S.v = 0;
      S.startX = e.clientX;
      S.startP = S.p;
      if (!reduced) wake();
    };

    const onMove = (e: PointerEvent) => {
      if (!S.dragging) return;
      S.p = Math.min(1, Math.max(0, S.startP + (e.clientX - S.startX) / maxTravel));
      if (reduced) {
        // no canvas at all — plain slide, instant swap at full travel
        setThumb(S.p);
        if (S.p >= COMMIT_AT) commit();
      }
    };

    const onUp = () => {
      if (!S.dragging) return;
      S.dragging = false;
      if (S.p >= COMMIT_AT) {
        if (S.mode !== "shatter") commit();
        return;
      }
      thumb.setAttribute("aria-valuenow", "0");
      if (reduced) {
        S.p = 0;
        S.mode = "idle";
        setThumb(0);
      } else {
        S.mode = "spring";
        wake();
      }
    };

    // Arrow keys nudge p by KEY_STEP; Home/End jump to the ends; Enter/Space
    // confirms outright — mirrors the pointer path without engaging the rAF
    // loop for a static value (loop only wakes once travel actually commits).
    const onKeyDown = (e: KeyboardEvent) => {
      if (S.mode === "shatter" || S.mode === "spring" || glass.style.visibility === "hidden") return;
      let next: number;
      switch (e.key) {
        case "ArrowRight":
        case "ArrowUp":
          next = S.p + KEY_STEP;
          break;
        case "ArrowLeft":
        case "ArrowDown":
          next = S.p - KEY_STEP;
          break;
        case "Home":
          next = 0;
          break;
        case "End":
        case "Enter":
        case " ":
          next = 1;
          break;
        default:
          return;
      }
      e.preventDefault();
      next = Math.min(1, Math.max(0, next));
      S.p = next;
      S.startP = next;
      thumb.setAttribute("aria-valuenow", String(Math.round(next * 100)));
      if (S.p >= COMMIT_AT) {
        commit();
      } else {
        S.mode = "idle";
        setThumb(S.p);
        if (!reduced) drawCracks(S.p);
      }
    };

    thumb.addEventListener("pointerdown", onDown);
    thumb.addEventListener("pointermove", onMove);
    thumb.addEventListener("pointerup", onUp);
    thumb.addEventListener("pointercancel", onUp);
    thumb.addEventListener("keydown", onKeyDown);
    return () => {
      cancelAnimationFrame(S.raf);
      thumb.removeEventListener("pointerdown", onDown);
      thumb.removeEventListener("pointermove", onMove);
      thumb.removeEventListener("pointerup", onUp);
      thumb.removeEventListener("pointercancel", onUp);
      thumb.removeEventListener("keydown", onKeyDown);
      root.style.transform = "";
    };
  }, [width, height, shardCount, resetKey]);

  const thumbSize = height - INSET * 2;

  return (
    <div
      ref={rootRef}
      className={`relative select-none ${className}`}
      style={{ width, height }}
    >
      {/* revealed state beneath the glass */}
      <div
        aria-hidden={!confirmed}
        className={`absolute inset-0 flex items-center justify-center gap-2 rounded-md border border-border bg-surface ${
          confirmed ? "opacity-100" : "opacity-0"
        }`}
      >
        <svg
          width="16"
          height="16"
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          strokeWidth="2"
          strokeLinecap="round"
          strokeLinejoin="round"
          aria-hidden
          className="text-foreground"
        >
          <path d="M20 6 9 17l-5-5" />
        </svg>
        <span className="font-mono text-xs tracking-[0.2em] text-foreground">
          {confirmedLabel}
        </span>
      </div>

      {/* frosted pane — house glass recipe */}
      <div
        ref={glassRef}
        className={`absolute inset-0 overflow-hidden rounded-md border border-black/15 bg-white/60 shadow-[inset_0_1px_0_0_rgba(255,255,255,0.7),0_8px_24px_-8px_rgba(0,0,0,0.28)] backdrop-blur-xl backdrop-saturate-150 dark:border-white/10 dark:bg-white/[0.06] dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.12),0_8px_24px_-8px_rgba(0,0,0,0.5)] ${
          confirmed ? "invisible" : ""
        }`}
      >
        <span className="absolute inset-0 flex items-center justify-center pl-8 font-mono text-[11px] tracking-[0.25em] text-ns-muted">
          {label}
        </span>
      </div>

      {/* crack + shard overlay, bleeds past the track so shards can fly */}
      <canvas
        ref={canvasRef}
        aria-hidden
        className="pointer-events-none absolute"
        style={{
          left: -PAD_X,
          top: -PAD_TOP,
          width: width + PAD_X * 2,
          height: height + PAD_TOP + PAD_BOTTOM,
        }}
      />

      {/* thumb */}
      <button
        ref={thumbRef}
        type="button"
        role="slider"
        aria-label={label}
        aria-valuemin={0}
        aria-valuemax={100}
        aria-valuenow={0}
        className={`absolute flex cursor-grab touch-none items-center justify-center rounded-sm border border-black/15 bg-black/[0.04] text-ns-muted transition-colors duration-150 hover:border-black/25 hover:bg-black/[0.08] hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ns-accent focus-visible:ring-offset-2 focus-visible:ring-offset-background shadow-[inset_0_1px_0_0_rgba(255,255,255,0.6),0_2px_8px_-2px_rgba(0,0,0,0.25)] will-change-transform active:cursor-grabbing dark:border-white/15 dark:bg-white/10 dark:hover:border-white/30 dark:hover:bg-white/15 dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.18),0_2px_8px_-2px_rgba(0,0,0,0.5)] ${
          confirmed ? "invisible" : ""
        }`}
        style={{ left: INSET, top: INSET, width: thumbSize, height: thumbSize }}
      >
        <svg
          width="16"
          height="16"
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          strokeWidth="2"
          strokeLinecap="round"
          strokeLinejoin="round"
          aria-hidden
        >
          <path d="m6 17 5-5-5-5" />
          <path d="m13 17 5-5-5-5" />
        </svg>
      </button>
    </div>
  );
}
Build spec

A frosted-glass confirm slider where destruction IS the progress indicator: a 320×56 DOM track in the house glass recipe (light: bg-white/60 with a black/15 border; dark: bg-white/[0.06] with a white/10 border; backdrop-blur-xl, inset top specular, rounded-md) carries a DPR-aware canvas overlay and a 48px grabbable thumb, itself theme-split (black-tinted border/fill on light, white-tinted on dark) so it reads against the pane in both modes. On mount, seed ~48 Poisson-disc points across the track and compute exact Voronoi cells once via half-plane bisector clipping; deduped cell walls become crack polylines, each assigned a reveal threshold t from its distance to the thumb origin, and the same cells are reused later as shard polygons. Dragging (pointer capture, progress in a ref, thumb transform set in a rAF loop, zero React state on the hot path) strokes every polyline with t<p segment-by-segment from its near end — 1px rgba(255,255,255,0.35) hairline plus a 0.75px-offset 0.5px rgba(255,255,255,0.12) ghost for glass depth — and past p=0.6 the whole track shudders ±0.5px per frame. Release early and p springs back to 0 (stiffness 220, damping 26) so the cracks retract along the same t-mapping, healing for free. The thumb is a full role="slider": Arrow/Up/Down keys nudge progress by 0.08, Home/End jump to the ends, and Enter/Space confirm outright, each keyboard step rendering once without waking the rAF loop. At p≥0.98 (by drag or key) the DOM glass hides instantly and the canvas flips to shard mode: each Voronoi cell tumbles outward from the thumb at 120–420px/s under 1800px/s² gravity with ±3rad/s spin, fading over 700ms; after ~900ms the canvas clears, revealing a bg-surface row with a check icon and mono CONFIRMED. The rAF loop sleeps whenever settled, and prefers-reduced-motion drops the canvas entirely for a plain slide with an instant confirmed swap.

Props

PropTypeDefaultDescription
label?string"SLIDE TO CONFIRM"mono label etched on the glass
confirmedLabel?string"CONFIRMED"label revealed once the pane shatters
width?number320px width of the glass pane
height?number56px height of the glass pane
shardCount?number48approximate Voronoi cell count — cracks while dragging, shards on commit
onConfirm?() => voidcalled once the pane fully shatters
resetKey?number0bump to restore the pane and replay
className?stringextra classes merged onto the rendered root element