Switch Solder Bead

Switch

A boolean switch rendered as two liquid solder beads on a hairline rail, trading mass through a gooey neck that bulges, stretches and pinches off on toggle — with a ratio prop for rendering any partial allocation, not just on/off.

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

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

// ---------------------------------------------------------------------------
// SolderBridge — a boolean switch rendered as two liquid solder beads on a
// hairline rail, trading mass through a gooey neck instead of sliding a
// thumb. Both beads sit at FIXED anchor points; only their radius (mass)
// and a connecting neck's thickness change. The gooey look (bulge, stretch,
// pinch-off) comes from the same recipe as toc-minimap-mercury: blur the whole
// liquid layer, then push contrast back up with a matrix threshold so
// anything close enough merges into one blob and anything far enough snaps
// apart cleanly — no manual blob-path math needed.
//
// `ratio` (0-1) is the fraction of the total mass sitting in the RIGHT
// bead. It defaults to 0.9 when checked, 0.1 when unchecked (a solder bead
// never fully empties), but can be passed explicitly to render a partial
// allocation independent of the boolean switch semantics — the control
// stays a switch (role=switch/aria-checked/click/Space toggle checked),
// the ratio is just what's drawn.
//
// All per-frame writes (bead radii, neck thickness) are direct DOM
// attribute/style writes via refs inside a rAF loop that only runs for the
// ~450ms of an active transition — not a persistent per-frame loop.
// prefers-reduced-motion skips the tween: the target ratio is applied
// instantly, neck never appears.
// ---------------------------------------------------------------------------

export interface SolderBridgeProps {
  checked?: boolean;
  defaultChecked?: boolean;
  onCheckedChange?: (checked: boolean) => void;
  /** Fraction (0-1) of mass in the right bead. Overrides the checked-derived default. */
  ratio?: number;
  disabled?: boolean;
  "aria-label"?: string;
  className?: string;
}

const VIEW_W = 64;
const VIEW_H = 24;
const CY = 12;
const LEFT_X = 18;
const RIGHT_X = 46;
const RAIL_X1 = 8;
const RAIL_X2 = 56;
const R_MIN = 3.5;
const R_MAX = 8.2;
const NECK_MAX_H = 9;
const TRANSITION_MS = 450;

function radiusFor(mass: number): number {
  return R_MIN + (R_MAX - R_MIN) * Math.min(1, Math.max(0, mass));
}

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

function defaultRatio(checked: boolean): number {
  return checked ? 0.9 : 0.1;
}

export function SolderBridge({
  checked,
  defaultChecked = false,
  onCheckedChange,
  ratio,
  disabled = false,
  "aria-label": ariaLabel = "Toggle allocation",
  className = "",
}: SolderBridgeProps) {
  const autoId = useId();
  const isControlled = checked !== undefined;
  const [uncontrolledChecked, setUncontrolledChecked] = useState(defaultChecked);
  const isChecked = isControlled ? checked : uncontrolledChecked;

  const targetRatio = ratio ?? defaultRatio(isChecked);
  // Frozen at mount — the only JSX-driven radius. Every ratio change after
  // that is a direct DOM write from the rAF tween below, never a re-render,
  // so React can't snap the shape ahead of the animation and cause a flash.
  const [initialRatio] = useState(targetRatio);

  const leftRef = useRef<SVGCircleElement | null>(null);
  const rightRef = useRef<SVGCircleElement | null>(null);
  const neckRef = useRef<SVGRectElement | null>(null);

  const currentRatioRef = useRef(initialRatio);
  const reducedRef = useRef(false);
  const rafRef = useRef(0);

  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);
  }, []);

  const paint = (r: number, neckH: number) => {
    const leftMass = 1 - r;
    const rightMass = r;
    leftRef.current?.setAttribute("r", String(radiusFor(leftMass)));
    rightRef.current?.setAttribute("r", String(radiusFor(rightMass)));
    if (neckRef.current) {
      neckRef.current.setAttribute("height", String(neckH));
      neckRef.current.setAttribute("y", String(CY - neckH / 2));
      neckRef.current.setAttribute("rx", String(neckH / 2));
      neckRef.current.style.opacity = neckH > 0.3 ? "1" : "0";
    }
  };

  // Animate whenever the target ratio changes.
  useEffect(() => {
    const from = currentRatioRef.current;
    const to = targetRatio;
    cancelAnimationFrame(rafRef.current);

    if (reducedRef.current || from === to) {
      currentRatioRef.current = to;
      paint(to, 0);
      return;
    }

    const start = performance.now();
    const tick = (now: number) => {
      const t = Math.min(1, (now - start) / TRANSITION_MS);
      const eased = easeInOutCubic(t);
      const r = from + (to - from) * eased;
      currentRatioRef.current = r;
      // Neck bulges mid-transition, pinches off before it completes.
      const neckPhase = Math.min(1, Math.max(0, (t - 0.08) / 0.72));
      const neckH = t < 0.08 || t > 0.8 ? 0 : Math.sin(Math.PI * neckPhase) * NECK_MAX_H;
      paint(r, neckH);
      if (t < 1) {
        rafRef.current = requestAnimationFrame(tick);
      } else {
        paint(to, 0);
      }
    };
    rafRef.current = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(rafRef.current);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [targetRatio]);

  useEffect(() => {
    setAnnounce(ratio !== undefined ? `${Math.round(ratio * 100)}% allocated` : isChecked ? "On" : "Off");
  }, [isChecked, ratio]);

  const toggle = () => {
    if (disabled) return;
    const next = !isChecked;
    if (!isControlled) setUncontrolledChecked(next);
    onCheckedChange?.(next);
  };

  const fid = `sb-goo-${autoId.replace(/:/g, "")}`;

  return (
    <button
      type="button"
      role="switch"
      aria-checked={isChecked}
      aria-label={ariaLabel}
      disabled={disabled}
      onClick={toggle}
      className={`relative inline-flex shrink-0 items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-background ${
        disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer group"
      } ${className}`}
      style={{ width: VIEW_W, height: VIEW_H }}
    >
      <style>{CSS}</style>
      <span role="status" aria-live="polite" aria-atomic="true" className="sr-only">
        {announce}
      </span>

      <svg
        viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
        width={VIEW_W}
        height={VIEW_H}
        aria-hidden="true"
        focusable="false"
        className="overflow-visible"
      >
        <defs>
          <filter id={fid} x="-60%" y="-120%" width="220%" height="340%" colorInterpolationFilters="sRGB">
            <feGaussianBlur in="SourceGraphic" stdDeviation="2.6" result="blur" />
            <feColorMatrix in="blur" type="matrix" values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 19 -9" />
          </filter>
        </defs>

        <line x1={RAIL_X1} y1={CY} x2={RAIL_X2} y2={CY} stroke="var(--border)" strokeWidth={1} />

        <g filter={`url(#${fid})`} fill="var(--foreground)">
          <rect ref={neckRef} x={LEFT_X + 2} y={CY} width={RIGHT_X - LEFT_X - 4} height={0} rx={0} />
          <circle ref={leftRef} cx={LEFT_X} cy={CY} r={radiusFor(1 - initialRatio)} />
          <circle ref={rightRef} cx={RIGHT_X} cy={CY} r={radiusFor(initialRatio)} />
        </g>

        <circle className="ns-sb-sheen" cx={LEFT_X - 1.6} cy={CY - 1.8} r={1.1} fill="var(--background)" />
        <circle className="ns-sb-sheen" cx={RIGHT_X - 1.6} cy={CY - 1.8} r={1.1} fill="var(--background)" />
      </svg>
    </button>
  );
}

const CSS = `
.ns-sb-sheen { opacity: 0; transition: opacity 200ms ease-out; }
.group:hover .ns-sb-sheen, .group:focus-visible .ns-sb-sheen { opacity: 0.5; }
@media (prefers-reduced-motion: reduce) {
  .ns-sb-sheen { transition: none; }
}
`;
Build spec

Build a boolean switch (role="switch", aria-checked, native <button> so click and Space both toggle for free) that renders as two liquid solder beads on a hairline horizontal rail instead of a sliding thumb, sized to a 64x24 viewBox. Both beads sit at FIXED x anchors (18 and 46 out of 64, y centered at 12) — only their radius (mass) and a connecting neck's thickness ever change; nothing travels along the rail. Radius is derived from a mass fraction via radius = 3.5 + (8.2-3.5) * mass, so a bead ranges roughly 3.5-8.2px and never fully vanishes (real solder doesn't disappear). A `ratio` prop (0-1) is the fraction of total mass in the RIGHT bead; when omitted it defaults to 0.9 when the switch is checked and 0.1 when unchecked, but can be passed explicitly to render any partial split (e.g. 0.35) independent of the boolean switch semantics — the control stays a switch regardless of what ratio is currently drawn. The gooey look comes from an SVG filter applied to a <g> that wraps both bead circles plus a connecting <rect> neck (toc-minimap-mercury's exact recipe: feGaussianBlur stdDeviation ~2.6-4 on SourceGraphic, then a feColorMatrix contrast/alpha threshold matrix `1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 19 -9` applied to the blur) — when the neck rect's height (drawn as a capsule spanning between the two bead edges) is nonzero and the beads are close, the blur+threshold naturally fuses everything into one blob; as thickness goes to zero they snap cleanly apart, which reads as bulge/stretch/pinch-off without any manual blob-path geometry. On every toggle (or ratio change), a requestAnimationFrame loop that runs only for the ~450ms transition (not persistently) eases the ratio from its previous value to the new target with easeInOutCubic, and derives the neck's thickness from a sine curve that is 0 for the first ~8% and last ~20% of the transition and peaks at 9px around the midpoint — so the neck appears after a beat, bulges, then pinches off before the transition fully settles. All of this — both bead radii, the neck rect's height/y/rx — is written directly to the SVG elements via refs (setAttribute), never through React state per frame; the ONLY value ever set through JSX is each bead's radius at mount (frozen in a lazy useState initializer) so a later re-render can never snap the shape ahead of the animation and cause a one-frame flash. Hover (or focus-visible) reveals a subtle specular dot inside each bead (a small var(--background)-filled circle offset toward the upper-left, opacity 0 to ~0.5 over a 200ms CSS transition) — the sheen is the one purely decorative, non-hot-path animation and is fine as a CSS transition rather than rAF. A dedicated sr-only span (role="status", aria-live="polite", aria-atomic="true") announces "On"/"Off" on a boolean toggle, or "N% allocated" when a ratio prop is explicitly supplied. prefers-reduced-motion applies the target ratio instantly with no rAF tween and the neck never appears — no bulge, no stretch, just an immediate mass swap. Props: checked/defaultChecked/onCheckedChange (controlled or uncontrolled boolean), ratio (0-1, optional), disabled, aria-label (default "Toggle allocation"), className. Zero dependencies.

Tags
switchtogglecontrolsvggooeyfilteranimationaccessibility