ns-ui
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.
Use when a 'merge/combine two items' affordance — merging two cards, docking a panel onto another, attaching a chip to a row — that currently gets a generic snap-together animation or a plain overlap-and-fade. Pick status-metaball-merge instead when the point is GROUP MEMBERSHIP of many items over time (an implicit-surface isoline that necks two nearby items together and pinches apart when one leaves), not a one-shot two-item join. Pick crimp-barrel-set instead when the surface is a link/attach CONFIRM control rather than the two items themselves — its open-barrel crimp reads as 'connect these two conductors,' a mechanically simpler and faster-reading affordance for a plain confirm button, where this component's slower, uneven-then-even squeeze-out is for a moment that benefits from reading as a real physical join with a pass/fail tell (continuous squeeze-out = good bond, broken/lumpy = starved joint).
Install
npx shadcn add https://design.helpmarq.com /r/adhesive-squeeze-bead.jsonSource
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>
);
}
Build spec
`<AdhesiveSqueezeBead topContent? bottomContent? draggable? onBond? ariaLabel? className? />` renders a card-scale `role="group"` region with two flat panel `<div>`s (bg-surface, border-foreground/15) stacked with a gap between them, plus an absolutely-positioned inset-0 SVG whose single `<path fill="var(--foreground)">` is the squeeze-out bead. Panel geometry is DERIVED, never hardcoded: `layout()` measures the container and sets each panel's own top/bottom offset and height in JS so the two panels' inner edges are always exactly 14px apart at rest — the panel gap and the 3px bond-line stop below are only meaningful numbers if the resting geometry actually matches them, so no Tailwind arbitrary-height class is trusted to get that right on its own. The whole thing runs an UNFORCED, UNBOUNDED ~3900ms loop with zero input: panels ease-IN (accelerating close — `t*t`, matching two rigid parts under applied pressure, not a soft cushion-stop) from that 14px starting gap to a fixed 3px bond-line stop over 260ms (panels translateY symmetrically toward the seam's fixed midpoint, never past the 3px stop — that stop is the joint's physical spacer thickness, not a spring target); the bead THEN grows: across the FIXED 260-600ms window each of 24 control points spaced evenly along the seam's x-axis (same edge inset as the panels, so the bead reaches all the way to each panel's end) grows from radius 0 toward its OWN randomized target via an ease-out curve applied identically to every point, so only the per-point TARGET varies and the bead visibly builds unevenly. The target range is `[4, 12]px` at a 240px reference container minimum-dimension, then multiplied by `clamp(minDim/240, 0.75, 2.5)` — the binding 'derive geometry from the container's smaller dimension' rule, applied because the spec's literal 2-5px range reads as a sub-pixel wobble at typical card scale and the whole point of this component is that the settle motion stays legible. Across 600-1300ms every point's radius interpolates LINEARLY (deliberately not eased — an eased curve front-loads the change and leaves too little residual unevenness at the resting-loop's t5s 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) from that lumpy target toward a value produced by applying a 3-point moving average (edges clamped to their nearest real neighbor, never wrapped — this is a straight seam, not a loop) TWICE to the target array, so the bead visibly relaxes from lumpy to a continuous, even fillet — this settle motion is the one thing a viewer is asked to follow, and 700ms at card scale is timed to be watchable, not a blink. The settled shape then holds dead still for 2100ms (this is the 't2.5s' resting-loop frame: solidly mid-hold, bead fully even) before a 500ms phase fades the bead's opacity 1->0 while the panels ease back apart to the 14px start, and the whole 3900ms cycle repeats forever. The bead path itself is built fresh every animation frame from the live per-point radius array via `buildBeadPath`: a top edge (seamY - r_i) and a mirrored bottom edge (seamY + r_i) each smoothed through a clamped Catmull-Rom-to-cubic-Bezier pass (no wraparound — both edges terminate as flat vertical caps at the seam's visible extent, like a bead of caulk run the width of a frame, not a closed loop), joined into one filled path with no stroke. INTERACTION: when `draggable` (default true), pointerdown on the lower panel reads its own current on-screen offset back out of its live `transform` (via `getComputedStyle` + `DOMMatrixReadOnly`, never assumed) so a drag started mid-cycle picks up exactly where the ambient loop left off, then pointermove drives the panel gap directly off `clientY` delta (clamped [3,14]px) with NO bead rendered during the drag itself — the growth+settle sequence is a committed sequence, never scrubbed live. On pointerup, if the live gap is at or below the 6px squeeze-out threshold, any growth/settle state left over from a previous ambient cycle is cleared and the shared animation clock is jumped straight to the start of the growth phase (already at the 3px stop), so a genuinely fresh growth->settle->hold->reset sequence plays out — identical in mechanism to the ambient loop's — and the loop simply continues from there afterward; releasing above 6px snaps the panel back to the 14px start and restarts the ambient cycle from t0 — this is a real merge action with a real spring-back, never a state that latches irreversibly, which is why `autoplay.mode` is `"none"`: the resting loop already moves without any input, dragging is an accent on top of it, not the only way to see motion. A visually-hidden `role="status" aria-live="polite"` paragraph announces 'Bonded — squeeze-out even along the seam' once per completed settle (ambient or committed drag) and 'Released before bond line — no squeeze-out' on a sprung-back release; ordinary ambient cycling otherwise announces nothing extra per pass beyond that one bonded line, so a screen reader isn't spammed every 3.9s. REDUCED MOTION (`matchMedia`, read once at mount) disables the ambient rAF loop entirely and renders directly into the settled state described by `STATIC_PHASE = "settled"`: panels already at the 3px bond-line stop, bead already built from one randomized-then-twice-smoothed target set, no growth or hold timers running — this is deliberately NOT the t0 (gap-open, no bead) frame, because an unfinished or gap-open frame reads as 'not bonded', where the settled fillet reads as 'a good bond' at a glance. Dragging still works under reduced motion (it's a real interaction, not decorative motion) but every state change it produces — committing a bond, springing back — applies instantly with no eased tween. Sizing: the SVG's `viewBox`, panel geometry and target-radius scale are all set from the container's own measured `getBoundingClientRect()` inside a `ResizeObserver` callback (never assumed, never before the first successful measurement — the root renders `opacity-0` until that first layout read completes, then fades in over 200ms), and the resize path resumes the rAF loop itself if the container was unmeasurable at mount (a lazily-mounted preview iframe) the same way an `IntersectionObserver` resumes it when the card scrolls back into view. Colors are `--background --foreground --ns-muted --border --surface` only, all via CSS custom properties in JSX/Tailwind classes — no JS color reads are needed because nothing here draws to canvas, and `--ns-accent` never appears: the bead's building-to-even motion is geometry/luminance only, never accent-tinted, per the brief's 'never the climactic moment' rule. DOM + SVG + CSS, no canvas, zero dependencies.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| topContent? | React.ReactNode | — | content rendered inside the upper (fixed) panel |
| bottomContent? | React.ReactNode | — | content rendered inside the lower (draggable) panel |
| draggable? | boolean | true | enable drag-to-attach on the lower panel. Default true. |
| onBond? | (source: "ambient" | "drag") => void | — | fired once the squeeze-out bead finishes settling (ambient cycle completing the hold, or a committed drag). |
| ariaLabel? | string | "Adhesive bond preview" | accessible name for the bonding preview region |
| className? | string | — | extra classes merged onto the rendered root element |