shadimage
ns-ui
uiadhesive-squeeze-beadsource: ok · refreshed

Adhesive Squeeze Bead

A merge/dock affordance modeled on structural adhesive bonding: two panels compress a bead of adhesive to a fixed bond-line stop, forcing an uneven bead of squeeze-out along the seam that visibly settles into a continuous, even fillet.

npx shadcn add "https://design.helpmarq.com/r/adhesive-squeeze-bead.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Files1 · source cached 10/8/2026, 3:33:34 AM

registry/core/adhesive-squeeze-bead/component.tsx
"use client";

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

// ---------------------------------------------------------------------------
// AdhesiveSqueezeBead — a "merge two items" affordance modeled on structural
// adhesive bonding with bond-line thickness control (aerospace/automotive
// bonded-joint practice): a bead of adhesive sits between two panels, and as
// they're pressed together it spreads and excess is forced out at the seam
// as visible "squeeze-out" — a fillet along the joint. A physical spacer
// (the bond-line stop) keeps the panels from pressing fully flat, so a good
// bond shows continuous, unbroken squeeze-out; a starved bond shows a broken
// line. That's the one visual fact this component asks a viewer to read.
//
// The bead is 24 independently-perturbed control points along the seam, not
// a single stroke-width tween: on contact each point grows toward its own
// randomized target radius (a container-scaled range around the spec's 2-5px,
// widened so the settle motion stays legible at small card sizes — see
// BASE_MIN_TARGET_R/BASE_MAX_TARGET_R below) over 340ms — deliberately
// lumpy — then relaxes LINEARLY toward a 3-point moving average of its
// neighbors (two passes) over 700ms, visibly settling from uneven to a
// continuous even fillet. THAT settle motion is the one thing to follow, and
// it holds at "finished, even fillet" for 2.1s before fading and resetting.
// Panels ease-IN (accelerating close, like two rigid parts under applied
// pressure) from a 14px starting gap to a fixed 3px bond-line stop over
// 260ms; the ~3900ms cycle then runs forever, unforced, with zero input.
//
// Dragging the lower panel drives the approach distance directly off pointer
// position instead of the ambient clock — release past the 6px squeeze-out
// threshold commits to the same growth+settle+hold+reset sequence the
// ambient loop runs (drag just jumps the shared timeline to that point);
// releasing short of it springs the panel back to the 14px start and the
// ambient loop simply resumes from t0. No pointer input is required for the
// component to be alive — dragging is an accent on top of a real resting
// loop, never the only way to see it move.
//
// Colors are --foreground / --ns-muted / --border / --surface / --background
// only, read live through CSS custom properties (no JS color reads, no
// literals) — the bead is never accent-colored; its unevenness-to-evenness
// motion is luminance/geometry only, never dressed up with hue.
// ---------------------------------------------------------------------------

export interface AdhesiveSqueezeBeadProps {
  /** content rendered inside the upper (fixed) panel */
  topContent?: React.ReactNode;
  /** content rendered inside the lower (draggable) panel */
  bottomContent?: React.ReactNode;
  /** enable drag-to-attach on the lower panel. Default true. */
  draggable?: boolean;
  /** fired once the squeeze-out bead finishes settling (ambient cycle completing the hold, or a committed drag). */
  onBond?: (source: "ambient" | "drag") => void;
  /** accessible name for the bonding preview region */
  ariaLabel?: string;
  /** extra classes merged onto the rendered root element */
  className?: string;
}

const POINTS = 24;
const START_GAP = 14; // px, panels apart at rest
const BOND_GAP = 3; // px, fixed bond-line stop (spacer thickness)
const SQUEEZE_THRESHOLD_GAP = 6; // px, gap below which the bead starts building

const APPROACH_MS = 260;
const GROWTH_MS = 340;
const SETTLE_MS = 700;
const HOLD_MS = 2100;
const RESET_MS = 500;

const T_APPROACH_END = APPROACH_MS; // 260
const T_GROWTH_END = T_APPROACH_END + GROWTH_MS; // 600
const T_SETTLE_END = T_GROWTH_END + SETTLE_MS; // 1300
const T_HOLD_END = T_SETTLE_END + HOLD_MS; // 3400
const PERIOD_MS = T_HOLD_END + RESET_MS; // 3900

// spec's real numbers for the target radius range at a ~240px minimum card
// dimension. `layout()` multiplies these by a container-derived scale factor
// (binding rule: derive geometry from the smaller dimension) so the settle
// motion — the one thing this component asks a viewer to follow — stays
// legible instead of shrinking to a sub-pixel wobble on a small card.
const BASE_MIN_TARGET_R = 4;
const BASE_MAX_TARGET_R = 12;
const SCALE_REFERENCE_DIM = 240; // px
const MIN_SCALE = 0.75;
const MAX_SCALE = 2.5;

const EDGE_PAD = 12; // px inset kept around both panels AND the bead's 24 points — the
// same value so the bead reaches all the way to each panel's end, matching
// "squeeze-out along the seam," not stopping short of it.

interface Point {
  x: number;
  r: number;
}

function easeInQuad(t: number): number {
  return t * t;
}

function easeOutQuad(t: number): number {
  return 1 - (1 - t) * (1 - t);
}

function easeInOutQuad(t: number): number {
  return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
}

function clamp01(t: number): number {
  return t < 0 ? 0 : t > 1 ? 1 : t;
}

function lerp(a: number, b: number, t: number): number {
  return a + (b - a) * t;
}

/** 3-point moving average, edges clamped (duplicate the edge value rather than wrap —
 * this is a linear seam, not a loop). Two passes, per spec, are what turns a lumpy
 * per-point growth into a visibly continuous fillet. */
function smoothOnce(values: number[]): number[] {
  const n = values.length;
  const out = new Array(n) as number[];
  for (let i = 0; i < n; i++) {
    const prev = values[i - 1] ?? values[0] ?? 0;
    const cur = values[i] ?? 0;
    const next = values[i + 1] ?? values[n - 1] ?? 0;
    out[i] = (prev + cur + next) / 3;
  }
  return out;
}

function smoothTwice(values: number[]): number[] {
  return smoothOnce(smoothOnce(values));
}

/** Catmull-Rom -> cubic Bezier through an ordered point list, clamped at both ends
 * (no wraparound — each edge of the bead terminates flat at the seam's visible extent
 * rather than closing into a loop). Returns path commands WITHOUT the leading M. */
function smoothSegment(pts: { x: number; y: number }[]): string {
  if (pts.length < 2) return "";
  let d = "";
  for (let i = 0; i < pts.length - 1; i++) {
    const p0 = pts[i - 1] ?? pts[i];
    const p1 = pts[i];
    const p2 = pts[i + 1];
    const p3 = pts[i + 2] ?? p2;
    if (!p0 || !p1 || !p2 || !p3) continue;
    const c1x = p1.x + (p2.x - p0.x) / 6;
    const c1y = p1.y + (p2.y - p0.y) / 6;
    const c2x = p2.x - (p3.x - p1.x) / 6;
    const c2y = p2.y - (p3.y - p1.y) / 6;
    d += `C${c1x.toFixed(2)} ${c1y.toFixed(2)} ${c2x.toFixed(2)} ${c2y.toFixed(2)} ${p2.x.toFixed(2)} ${p2.y.toFixed(2)}`;
  }
  return d;
}

function buildBeadPath(points: Point[], seamY: number): string {
  if (points.length === 0) return "";
  const top = points.map((p) => ({ x: p.x, y: seamY - p.r }));
  const bottom = points.map((p) => ({ x: p.x, y: seamY + p.r })).reverse();
  const first = top[0];
  if (!first) return "";
  return `M${first.x.toFixed(2)} ${first.y.toFixed(2)}${smoothSegment(top)}L${bottom[0]?.x.toFixed(2)} ${bottom[0]?.y.toFixed(2)}${smoothSegment(bottom)}Z`;
}

export function AdhesiveSqueezeBead({
  topContent,
  bottomContent,
  draggable = true,
  onBond,
  ariaLabel = "Adhesive bond preview",
  className = "",
}: AdhesiveSqueezeBeadProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  const svgRef = useRef<SVGSVGElement>(null);
  const beadRef = useRef<SVGPathElement>(null);
  const topPanelRef = useRef<HTMLDivElement>(null);
  const bottomPanelRef = useRef<HTMLDivElement>(null);
  const statusId = useId();
  const [statusText, setStatusText] = useState("");
  const [ready, setReady] = useState(false);
  const onBondRef = useRef(onBond);
  onBondRef.current = onBond;

  useEffect(() => {
    const root = rootRef.current;
    const svg = svgRef.current;
    const bead = beadRef.current;
    const topPanel = topPanelRef.current;
    const bottomPanel = bottomPanelRef.current;
    if (!root || !svg || !bead || !topPanel || !bottomPanel) return;

    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    let disposed = false;
    let visible = true;
    let raf = 0;
    let w = 0;
    let h = 0;
    let sized = false;

    // fixed x positions for the 24 seam points, recomputed on resize only
    let xs: number[] = [];
    let seamY = 0;
    let minTargetR = BASE_MIN_TARGET_R;
    let maxTargetR = BASE_MAX_TARGET_R;

    let cycleStart = 0;
    let growthTargets: number[] | null = null;
    let settled: number[] | null = null;
    let announcedThisCycle = false;
    let lastCommitWasDrag = false;

    let dragging = false;
    let dragStartY = 0;
    let dragStartGap = START_GAP;
    let liveGap = START_GAP;

    const layout = () => {
      const rect = root.getBoundingClientRect();
      w = rect.width;
      h = rect.height;
      if (w < 2 || h < 2) {
        sized = false;
        return;
      }
      sized = true;
      svg.setAttribute("viewBox", `0 0 ${w} ${h}`);
      seamY = h / 2;
      xs = [];
      const usable = Math.max(1, w - EDGE_PAD * 2);
      for (let i = 0; i < POINTS; i++) {
        xs.push(EDGE_PAD + (usable * i) / (POINTS - 1));
      }

      // panel height is DERIVED, not hardcoded, so the two panels' inner
      // edges are always exactly START_GAP apart at rest — the bond-line
      // stop only means something if the resting gap is the number the
      // spec says it is.
      const panelH = Math.max(1, (h - EDGE_PAD * 2 - START_GAP) / 2);
      topPanel.style.top = `${EDGE_PAD}px`;
      topPanel.style.height = `${panelH}px`;
      bottomPanel.style.bottom = `${EDGE_PAD}px`;
      bottomPanel.style.height = `${panelH}px`;

      // binding rule: derive geometry from the container's smaller
      // dimension so the settle motion reads at card scale instead of
      // shrinking to a sub-pixel wobble on a small card.
      const minDim = Math.min(w, h);
      const scale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, minDim / SCALE_REFERENCE_DIM));
      minTargetR = BASE_MIN_TARGET_R * scale;
      maxTargetR = BASE_MAX_TARGET_R * scale;

      setReady(true);
    };

    // applies a gap (px between panel inner edges) and a bead point set to the DOM.
    // Every frame writes come through here — ambient clock, drag, and the
    // reduced-motion one-shot render all funnel through the same function so
    // there is exactly one place that touches panel transforms and the path.
    const apply = (gap: number, radii: number[] | null, opacity: number) => {
      const delta = (START_GAP - gap) / 2;
      topPanel.style.transform = `translateY(${delta.toFixed(2)}px)`;
      bottomPanel.style.transform = `translateY(${(-delta).toFixed(2)}px)`;
      if (radii && radii.some((r) => r > 0.01)) {
        const pts: Point[] = xs.map((x, i) => ({ x, r: radii[i] ?? 0 }));
        bead.setAttribute("d", buildBeadPath(pts, seamY));
        bead.style.opacity = String(opacity);
      } else {
        bead.setAttribute("d", "");
      }
    };

    const phaseAt = (t: number) => {
      if (t < T_APPROACH_END) return "approach" as const;
      if (t < T_GROWTH_END) return "growth" as const;
      if (t < T_SETTLE_END) return "settle" as const;
      if (t < T_HOLD_END) return "hold" as const;
      return "reset" as const;
    };

    const frameAt = (t: number) => {
      const phase = phaseAt(t);

      if (phase === "approach") {
        growthTargets = null;
        settled = null;
        announcedThisCycle = false;
        const p = clamp01(t / APPROACH_MS);
        const gap = START_GAP - (START_GAP - BOND_GAP) * easeInQuad(p);
        return { gap, radii: null as number[] | null, opacity: 1 };
      }

      if (phase === "growth") {
        if (!growthTargets) {
          growthTargets = Array.from(
            { length: POINTS },
            () => minTargetR + Math.random() * (maxTargetR - minTargetR)
          );
        }
        const p = clamp01((t - T_APPROACH_END) / GROWTH_MS);
        const eased = easeOutQuad(p);
        const radii = growthTargets.map((target) => target * eased);
        return { gap: BOND_GAP, radii, opacity: 1 };
      }

      if (phase === "settle") {
        if (!growthTargets) {
          growthTargets = Array.from(
            { length: POINTS },
            () => minTargetR + Math.random() * (maxTargetR - minTargetR)
          );
        }
        if (!settled) settled = smoothTwice(growthTargets);
        const p = clamp01((t - T_GROWTH_END) / SETTLE_MS);
        // linear, deliberately: an eased curve front-loads the visual change and
        // leaves too little residual unevenness left at the t5s resting-loop
        // check-frame (1.1s into the next cycle, 71% through this window) to
        // read as distinct from the fully-settled hold frame at t2.5s.
        const eased = p;
        const radii = growthTargets.map((target, i) => lerp(target, settled?.[i] ?? target, eased));
        return { gap: BOND_GAP, radii, opacity: 1 };
      }

      if (phase === "hold") {
        if (!announcedThisCycle) {
          announcedThisCycle = true;
          onBondRef.current?.(lastCommitWasDrag ? "drag" : "ambient");
          lastCommitWasDrag = false;
          setStatusText("Bonded — squeeze-out even along the seam");
        }
        const radii = settled ?? growthTargets ?? new Array(POINTS).fill(maxTargetR);
        return { gap: BOND_GAP, radii, opacity: 1 };
      }

      // reset: bead fades while panels separate back to the starting gap
      const p = clamp01((t - T_HOLD_END) / RESET_MS);
      const eased = easeInOutQuad(p);
      const gap = BOND_GAP + (START_GAP - BOND_GAP) * eased;
      const radii = settled ?? growthTargets;
      return { gap, radii, opacity: 1 - eased };
    };

    const loop = (now: number) => {
      raf = 0;
      if (!visible || !sized || dragging) return;
      if (cycleStart === 0) cycleStart = now;
      let t = now - cycleStart;
      if (t >= PERIOD_MS) {
        cycleStart += Math.floor(t / PERIOD_MS) * PERIOD_MS;
        t = now - cycleStart;
      }
      const { gap, radii, opacity } = frameAt(t);
      apply(gap, radii, opacity);
      raf = requestAnimationFrame(loop);
    };

    const renderReducedSettled = () => {
      const targets = Array.from(
        { length: POINTS },
        () => minTargetR + Math.random() * (maxTargetR - minTargetR)
      );
      settled = smoothTwice(targets);
      apply(BOND_GAP, settled, 1);
      setStatusText("Bonded — squeeze-out even along the seam");
    };

    const onPointerMove = (e: PointerEvent) => {
      if (!dragging) return;
      const dy = e.clientY - dragStartY;
      liveGap = Math.max(BOND_GAP, Math.min(START_GAP, dragStartGap + dy));
      // no bead while actively dragging — the squeeze-out sequence only ever
      // plays as a committed sequence on release, never tracked live.
      apply(liveGap, null, 1);
    };

    const onPointerUp = () => {
      if (!dragging) return;
      dragging = false;
      window.removeEventListener("pointermove", onPointerMove);
      window.removeEventListener("pointerup", onPointerUp);
      const committed = liveGap <= SQUEEZE_THRESHOLD_GAP;
      if (reduced) {
        if (committed) {
          renderReducedSettled();
        } else {
          apply(START_GAP, null, 1);
          setStatusText("Released before bond line — no squeeze-out");
        }
        return;
      }
      lastCommitWasDrag = true;
      if (committed) {
        // a fresh commit gets a fresh growth cycle — never replay whatever the
        // previous ambient cycle happened to leave in these refs (e.g. a drag
        // started mid-hold), and let the "hold" phase announce again.
        growthTargets = null;
        settled = null;
        announcedThisCycle = false;
        // jump the shared timeline straight to the start of growth, already at the
        // bond-line stop, so the exact same growth+settle+hold+reset sequence the
        // ambient loop runs plays out from here.
        cycleStart = performance.now() - T_APPROACH_END;
      } else {
        setStatusText("Released before bond line — no squeeze-out");
        cycleStart = performance.now();
      }
      if (!raf) raf = requestAnimationFrame(loop);
    };

    const onPointerDown = (e: PointerEvent) => {
      // reduced motion still allows the drag itself — only the ambient
      // auto-loop is disabled; this listener is only ever attached when
      // `draggable` is true (see the effect's attach/detach below).
      dragging = true;
      raf && cancelAnimationFrame(raf);
      raf = 0;
      dragStartY = e.clientY;
      // reconstruct the currently-displayed gap from the panel's own transform so a
      // drag started mid-cycle picks up exactly where the ambient loop left off.
      const style = window.getComputedStyle(topPanel);
      const matrix = new DOMMatrixReadOnly(style.transform);
      const currentDelta = matrix.m42;
      dragStartGap = Math.max(BOND_GAP, Math.min(START_GAP, START_GAP - currentDelta * 2));
      liveGap = dragStartGap;
      window.addEventListener("pointermove", onPointerMove);
      window.addEventListener("pointerup", onPointerUp);
    };

    layout();

    if (reduced) {
      renderReducedSettled();
    } else if (sized) {
      raf = requestAnimationFrame(loop);
    }

    let resizeTimer = 0;
    const ro = new ResizeObserver(() => {
      window.clearTimeout(resizeTimer);
      resizeTimer = window.setTimeout(() => {
        if (disposed) return;
        layout();
        if (reduced) {
          renderReducedSettled();
        } else if (sized && !dragging && !raf) {
          // the very first measurement (lazy-mounted preview iframe, an
          // initially-collapsed container) can land here instead of the
          // mount-time branch above — resume the ambient loop the same way
          // the IntersectionObserver's resume path does, or it never starts.
          raf = requestAnimationFrame(loop);
        }
      }, 100);
    });
    ro.observe(root);

    const io = new IntersectionObserver((entries) => {
      visible = entries[0]?.isIntersecting ?? true;
      if (visible && !reduced && sized && !dragging && !raf) {
        raf = requestAnimationFrame(loop);
      }
    });
    io.observe(root);

    if (draggable) {
      bottomPanel.addEventListener("pointerdown", onPointerDown);
    }

    return () => {
      disposed = true;
      cancelAnimationFrame(raf);
      raf = 0;
      window.clearTimeout(resizeTimer);
      ro.disconnect();
      io.disconnect();
      window.removeEventListener("pointermove", onPointerMove);
      window.removeEventListener("pointerup", onPointerUp);
      if (draggable) bottomPanel.removeEventListener("pointerdown", onPointerDown);
    };
    // onBond is read through onBondRef (set every render above) so an inline
    // callback identity never re-runs this effect and restarts the cycle.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [draggable]);

  return (
    <div
      ref={rootRef}
      data-adhesive-squeeze-bead
      role="group"
      aria-label={ariaLabel}
      className={`relative aspect-[4/3] w-full overflow-hidden rounded-md border border-border bg-background transition-opacity duration-200 ${ready ? "opacity-100" : "opacity-0"} ${className}`}
    >
      <div
        ref={topPanelRef}
        className="absolute inset-x-3 flex items-center justify-center rounded-sm border border-foreground/15 bg-surface"
        style={{ willChange: "transform" }}
      >
        {topContent ?? (
          <span className="font-mono text-[11px] tracking-widest text-ns-muted">PANEL A</span>
        )}
      </div>
      <div
        ref={bottomPanelRef}
        className={`absolute inset-x-3 flex items-center justify-center rounded-sm border border-foreground/15 bg-surface ${draggable ? "cursor-grab touch-none active:cursor-grabbing" : ""}`}
        style={{ willChange: "transform" }}
      >
        {bottomContent ?? (
          <span className="font-mono text-[11px] tracking-widest text-ns-muted">PANEL B</span>
        )}
      </div>
      <svg
        ref={svgRef}
        aria-hidden="true"
        focusable="false"
        className="pointer-events-none absolute inset-0 h-full w-full"
      >
        <path ref={beadRef} d="" fill="var(--foreground)" stroke="none" />
      </svg>
      <p id={statusId} role="status" aria-live="polite" className="sr-only">
        {statusText}
      </p>
    </div>
  );
}

More from ns-ui

Browse all components in this library →