{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "mercury-dial",
  "type": "registry:ui",
  "title": "Mercury Dial",
  "description": "A speed dial that bursts into droplets. The trigger swells while the drops gather inside it, each one oozes out under surface tension and then fires past full size on a pop spring; closing winds up the wrong way and dives them back in with a splat. Press and drag any drop and a liquid finger stretches out of its rim.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://crafterui.com/r/mercury-surface.json"
  ],
  "files": [
    {
      "path": "registry/crafterui/ui/mercury-dial.tsx",
      "type": "registry:ui",
      "content": "\"use client\"\n\n// A speed dial that breaks into droplets.\n//\n// Press the trigger and it does not open a menu so much as burst: the button\n// swells while the drops gather inside it, then each one oozes out to about\n// 40% - heavy, hanging, all surface tension - before a pop spring fires it the\n// rest of the way past full size and rings it still. Closing is authored, not\n// reversed: every drop takes a blink of wind-up the wrong way and then dives\n// back in, and the button takes the hit with a splat.\n//\n// The whole picture is drawn twice and exactly one copy is ever visible. At\n// rest you are looking at real CSS circles with real borders and one real\n// shadow each; the instant anything moves the goo layer takes the picture over,\n// draws its own rim exactly where those borders were, and casts one shadow for\n// the whole mass. No half-blended state - a sub-pixel of overlap reads as a\n// doubled border.\n//\n// Hold and drag any drop and the same liquid answers: see mercury-surface.\nimport * as React from \"react\"\nimport { animate } from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\nimport {\n  EASE_ANTICIPATE,\n  EASE_OUT_STRONG,\n  GRAB_CHAIN,\n  GooFilter,\n  SPRING_HOUSE,\n  SPRING_POP,\n  setGooBlur,\n  useMercuryStretch,\n} from \"@/registry/crafterui/ui/mercury-surface\"\n\nexport interface MercuryDialItem {\n  /** Stable key; falls back to the label. @default undefined */\n  id?: string\n  /** Names the drop for a screen reader, and the only text it carries. */\n  label: string\n  /** Glyph inside the drop. Sized by the caller - 14px reads best at size 32. */\n  icon: React.ReactNode\n  /** Fires when the drop is chosen. The dial closes either way. @default undefined */\n  onSelect?: () => void\n}\n\nexport interface MercuryDialProps\n  extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"children\" | \"onSelect\"> {\n  /** Drops, fanned left to right across the arc. */\n  items: MercuryDialItem[]\n  /** Open state when controlled. Leave unset for internal state. @default undefined */\n  open?: boolean\n  /** Open on mount when uncontrolled. @default false */\n  defaultOpen?: boolean\n  /** Fires on every change, from click, Escape or an outside press. @default undefined */\n  onOpenChange?: (open: boolean) => void\n  /** Glyph on the trigger. Throws 135° into a cross as the dial opens. @default a plus */\n  icon?: React.ReactNode\n  /** Rendered width of the trigger, px. Everything scales off it. @default 32 */\n  size?: number\n  /** Names the menu for a screen reader. @default \"Actions\" */\n  label?: string\n}\n\n/* Layout is authored at 32px and scaled as a whole, so the rim calibration -\n   which is solved against a 32px disc - holds at every size. */\nconst BUTTON = 32\nconst HALF = BUTTON / 2\n\n/* Resting scale: a shrunk drop must hide entirely inside the trigger's 16px\n   radius, even while the press squash and the landing splat deform it. */\nconst REST_SCALE = 0.12\n/* Degrees the fan spans, centred on straight up. */\nconst SWEEP = 135\nconst MIN_RADIUS = 52\n/* Room past any body's edge for the longest thing the liquid can do: a finger\n   pulled the full 44px, plus its head, plus three σ of blur tail. The canvas is\n   what the browser rasterises into - anything past it is cut off with a straight\n   edge and no rim. */\nconst GOO_PAD = 76\n\n/* Just enough blur to bridge the fan gaps in flight; more reads soft, not\n   liquid. Rest is non-zero so the threshold has a soft edge to bite on, and the\n   grab needs less because its chain already overlaps geometrically. */\nconst BLUR_ACTIVE = 7\nconst BLUR_REST = 1\nconst BLUR_GRAB = 5\n\nconst rad = (deg: number) => (deg * Math.PI) / 180\n\nexport function MercuryDial({\n  items,\n  open,\n  defaultOpen = false,\n  onOpenChange,\n  icon,\n  size = BUTTON,\n  label = \"Actions\",\n  className,\n  ...props\n}: MercuryDialProps) {\n  const reactId = React.useId()\n  const menuId = `mercury-dial-menu-${reactId.replace(/:/g, \"\")}`\n  const gooId = `mercury-dial-goo-${reactId.replace(/:/g, \"\")}`\n\n  const rootRef = React.useRef<HTMLDivElement>(null)\n  const gooRef = React.useRef<SVGSVGElement>(null)\n  const bodiesRef = React.useRef<HTMLDivElement>(null)\n  const triggerRef = React.useRef<HTMLButtonElement>(null)\n  const triggerBodyRef = React.useRef<HTMLDivElement>(null)\n  const triggerBlobRef = React.useRef<SVGCircleElement>(null)\n  const iconRef = React.useRef<HTMLSpanElement>(null)\n  const chainRefs = React.useRef<(SVGCircleElement | null)[]>([])\n  const bodyRefs = React.useRef<(HTMLDivElement | null)[]>([])\n  const blobRefs = React.useRef<(SVGCircleElement | null)[]>([])\n  const dropRefs = React.useRef<(HTMLButtonElement | null)[]>([])\n\n  const [internal, setInternal] = React.useState(defaultOpen)\n  const isOpen = open ?? internal\n  /* The stretch host is built once and must not read a stale render's state. */\n  const openRef = React.useRef(isOpen)\n  openRef.current = isOpen\n\n  /* Geometry: the fan opens wider as it is given more drops, so the arc always\n     has room for them rather than fusing them into one puddle. */\n  const fan = React.useMemo(() => {\n    const count = items.length\n    const arc = rad(SWEEP)\n    const radius = Math.max(MIN_RADIUS, (count * (BUTTON + 2)) / arc)\n    const drops = items.map((_item, i) => {\n      const angle = count > 1 ? -90 + (i / (count - 1) - 0.5) * SWEEP : -90\n      return {\n        dx: Math.cos(rad(angle)) * radius,\n        dy: Math.sin(rad(angle)) * radius,\n        /* Each drop rests tilted toward its own flight path, so the pop also\n           swings it upright. */\n        rest: (angle + 90) * 0.18,\n      }\n    })\n    const cx = radius + HALF + GOO_PAD\n    return { drops, cx, cy: cx, width: cx * 2, height: cx + HALF + GOO_PAD }\n  }, [items.length])\n\n  const run = React.useRef<{ stop(): void }[]>([])\n  const timers = React.useRef<number[]>([])\n  const killRun = React.useCallback(() => {\n    run.current.forEach((animation) => animation.stop())\n    run.current = []\n    timers.current.forEach((id) => window.clearTimeout(id))\n    timers.current = []\n  }, [])\n  const play = (...animations: ({ stop(): void } | null | undefined)[]) => {\n    animations.forEach((animation) => animation && run.current.push(animation))\n  }\n  const at = (delay: number, fn: () => void) => {\n    timers.current.push(window.setTimeout(fn, delay * 1000))\n  }\n\n  const bits = React.useCallback(\n    () => [triggerBlobRef.current, triggerBodyRef.current, iconRef.current].filter(isEl),\n    [],\n  )\n  const stretchBits = React.useCallback(\n    () => [triggerBlobRef.current, triggerBodyRef.current].filter(isEl),\n    [],\n  )\n  const dropBits = React.useCallback(\n    (i: number) => [blobRefs.current[i], bodyRefs.current[i], dropRefs.current[i]].filter(isEl),\n    [],\n  )\n  /* The crisp halves fade as one; the goo blob never fades, it hides by scale. */\n  const dropFade = React.useCallback(\n    (i: number) => [bodyRefs.current[i], dropRefs.current[i]].filter(isEl),\n    [],\n  )\n\n  /* Every blob carries the held tint while it is grabbed; a full open or close\n     is always plain liquid, so the tint is cleared before either runs. */\n  const clearTint = React.useCallback(() => {\n    ;[triggerBlobRef.current, ...blobRefs.current, ...chainRefs.current].forEach((blob) =>\n      blob?.style.removeProperty(\"fill\"),\n    )\n  }, [])\n\n  const goLiquid = React.useCallback(\n    (blur: 1 | 5 | 7) => {\n      rootRef.current?.setAttribute(\"data-liquid\", \"\")\n      if (gooRef.current) gooRef.current.style.opacity = \"1\"\n      if (bodiesRef.current) bodiesRef.current.style.opacity = \"0\"\n      blobRefs.current.forEach((blob) => blob?.style.setProperty(\"opacity\", \"1\"))\n      setGooBlur(gooRef.current, blur)\n    },\n    [],\n  )\n\n  /* Cross-fade back to the identical crisp picture while the blur is still at\n     its working width: thinning the rim on screen blinks every border, so the\n     blur only resets once the goo is already hidden. */\n  const handoff = React.useCallback(\n    (delay: number, fade = 0.15) => {\n      /* Explicit start values, not just targets: the instant states below are\n         written straight to style.opacity, which Motion does not see - it keeps\n         its own cached value per element and would read this fade as \"already\n         at 0\" and skip it, leaving the goo painted over the crisp picture. */\n      play(\n        animate(gooRef.current!, { opacity: [1, 0] }, { duration: fade, ease: \"easeOut\", delay }),\n        animate(bodiesRef.current!, { opacity: [0, 1] }, { duration: fade, ease: \"easeOut\", delay }),\n      )\n      at(delay + fade, () => {\n        setGooBlur(gooRef.current, BLUR_REST)\n        rootRef.current?.removeAttribute(\"data-liquid\")\n        clearTint()\n      })\n    },\n    [clearTint],\n  )\n\n  const stretch = useMercuryStretch({\n    buttonSize: BUTTON,\n    scale: size / BUTTON,\n    auxLean: 0.22,\n    root: () => rootRef.current,\n    triggerBits: bits,\n    triggerStretchBits: stretchBits,\n    triggerIcon: () => iconRef.current,\n    chain: () => chainRefs.current,\n    auxBits: dropBits,\n    liquidOn: (target) => {\n      /* Every blob stays in the goo, so a finger pulled into a neighbouring\n         drop merges with it. */\n      goLiquid(BLUR_GRAB)\n      const held =\n        target === \"trigger\" ? triggerBlobRef.current : blobRefs.current[target as number]\n      /* The held piece and its finger wear the button's own hover tint, mixed\n         rather than taken from a token: `--muted` is a whole step lighter than\n         the surface in dark mode, which turns a 5% wash into a grey slug and\n         swallows the rim along with it. */\n      ;[held, ...chainRefs.current].forEach((blob) =>\n        blob?.style.setProperty(\n          \"fill\",\n          \"color-mix(in oklab, var(--foreground) 5%, var(--background))\",\n        ),\n      )\n    },\n    handoff: (delay) => handoff(delay),\n  })\n\n  /* Rest layout. Written through Motion rather than as a style string: Motion\n     owns each transform component separately and cannot read one back out of a\n     `transform` something else authored. */\n  const layout = React.useCallback(\n    (opened: boolean, instant: boolean) => {\n      fan.drops.forEach((drop, i) => {\n        animate(\n          dropBits(i),\n          {\n            scaleX: opened ? 1 : REST_SCALE,\n            scaleY: opened ? 1 : REST_SCALE,\n            rotate: opened ? 0 : drop.rest,\n            x: 0,\n            y: 0,\n          },\n          { duration: instant ? 0 : 0.2 },\n        )\n        dropFade(i).forEach((el) => {\n          ;(el as HTMLElement).style.opacity = opened ? \"1\" : \"0\"\n        })\n      })\n      animate(bits(), { scaleX: 1, scaleY: 1, x: 0, y: 0 }, { duration: instant ? 0 : 0.2 })\n      animate(chainRefs.current.filter(isEl), { scale: 0, x: 0, y: 0 }, { duration: 0 })\n      if (iconRef.current) {\n        animate(iconRef.current, { rotate: opened ? 135 : 0 }, { duration: instant ? 0 : 0.2 })\n      }\n      if (gooRef.current) gooRef.current.style.opacity = \"0\"\n      if (bodiesRef.current) bodiesRef.current.style.opacity = \"1\"\n      rootRef.current?.removeAttribute(\"data-liquid\")\n    },\n    [bits, dropBits, dropFade, fan.drops],\n  )\n\n  /* One layout pass on mount, and again only when the number of drops changes.\n     Keyed on anything finer, an incidental re-render - and mapping `items`\n     inline in JSX causes one on every state change - would re-run this, snap a\n     choreography mid-flight back to its rest pose and kill the animations\n     carrying it. `layout` is read through a ref so it can stay out of the deps. */\n  const layoutRef = React.useRef(layout)\n  layoutRef.current = layout\n  React.useEffect(() => {\n    layoutRef.current(openRef.current, true)\n  }, [items.length])\n  React.useEffect(() => killRun, [killRun])\n\n  const reduced = () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n\n  const commit = React.useCallback(\n    (next: boolean) => {\n      if (open === undefined) setInternal(next)\n      onOpenChange?.(next)\n    },\n    [onOpenChange, open],\n  )\n\n  const runOpen = React.useCallback(() => {\n    killRun()\n    stretch.kill()\n    commit(true)\n    if (reduced()) {\n      layout(true, true)\n      return\n    }\n\n    clearTint()\n    goLiquid(BLUR_ACTIVE)\n    /* The trigger swells and HOLDS while the drops gather - pressure building -\n       and only lets go once the first one has fired. x/y come home too, in case\n       the click landed at the end of a sponge stretch. */\n    play(\n      animate(bits(), { x: 0, y: 0, scaleX: 1.16, scaleY: 1.16 }, { duration: 0.17, ease: EASE_OUT_STRONG }),\n      animate(bits(), { scaleX: 1, scaleY: 1 }, { ...SPRING_HOUSE, delay: 0.2 }),\n      animate(stretchBits(), { rotate: 0 }, { duration: 0 }),\n      animate(chainRefs.current.filter(isEl), { x: 0, y: 0, scale: 0 }, { duration: 0.2, ease: EASE_OUT_STRONG }),\n      /* The plus spins itself into a cross: a 135° throw that overshoots and\n         rings back into place with everything else. */\n      iconRef.current && animate(iconRef.current, { rotate: 135 }, { ...SPRING_HOUSE, delay: 0.04 }),\n    )\n\n    fan.drops.forEach((_drop, i) => {\n      const start = 0.03 + i * 0.045\n      play(\n        animate(dropBits(i), { scaleX: 0.42, scaleY: 0.42 }, { duration: 0.16, ease: \"easeInOut\", delay: start }),\n        /* The two axes ride the same spring 30ms out of phase - height leads,\n           width lags - which is the whole squash-and-stretch of a drop. */\n        animate(dropBits(i), { scaleY: 1, rotate: 0 }, { ...SPRING_POP, delay: start + 0.16 }),\n        animate(dropBits(i), { scaleX: 1 }, { ...SPRING_POP, delay: start + 0.19 }),\n        animate(dropFade(i), { opacity: [0, 1] }, { duration: 0.13, ease: EASE_OUT_STRONG, delay: start + 0.18 }),\n      )\n    })\n\n    handoff(0.56, 0.22)\n  }, [bits, clearTint, commit, dropBits, dropFade, fan.drops, goLiquid, handoff, killRun, layout, stretch, stretchBits])\n\n  const runClose = React.useCallback(\n    (fromTrigger: boolean) => {\n      killRun()\n      stretch.kill()\n      commit(false)\n      if (reduced()) {\n        layout(false, true)\n        return\n      }\n\n      clearTint()\n      goLiquid(BLUR_ACTIVE)\n      play(\n        animate(bits(), { x: 0, y: 0 }, { duration: 0.18, ease: EASE_OUT_STRONG }),\n        animate(stretchBits(), { rotate: 0 }, { duration: 0 }),\n        animate(chainRefs.current.filter(isEl), { x: 0, y: 0, scale: 0 }, { duration: 0.2, ease: EASE_OUT_STRONG }),\n        iconRef.current && animate(iconRef.current, { rotate: 0 }, SPRING_HOUSE),\n        /* Carry the pressed squash back up first when the close came from the\n           button itself. */\n        fromTrigger ? animate(bits(), { scaleX: 1, scaleY: 1 }, { ...SPRING_HOUSE, delay: 0 }) : undefined,\n      )\n\n      /* Last out, first in: a blink of wind-up, then a plunge into the button,\n         width leading and height trailing. The glyph dissolves INTO the plunge\n         once the drop is visibly deforming, never leaving an empty ring. */\n      fan.drops.forEach((drop, i) => {\n        const start = 0.1 + (fan.drops.length - 1 - i) * 0.04\n        play(\n          animate(dropBits(i), { scaleX: REST_SCALE }, { duration: 0.18, ease: EASE_ANTICIPATE, delay: start }),\n          animate(\n            dropBits(i),\n            { scaleY: REST_SCALE, rotate: drop.rest },\n            { duration: 0.18, ease: EASE_ANTICIPATE, delay: start + 0.04 },\n          ),\n          animate(dropFade(i), { opacity: [1, 0] }, { duration: 0.1, ease: \"easeIn\", delay: start + 0.06 }),\n        )\n      })\n\n      /* The drops land IN the button and the button is liquid too: a splat, a\n         slosh back, then it rings itself round again. */\n      play(\n        animate(\n          bits(),\n          { scaleX: [1.16, 0.96, 1], scaleY: [0.86, 1.05, 1] },\n          { duration: 0.54, times: [0.15, 0.35, 1], ease: \"easeOut\", delay: 0.32 },\n        ),\n      )\n\n      handoff(0.46)\n    },\n    [bits, clearTint, commit, dropBits, dropFade, fan.drops, goLiquid, handoff, killRun, layout, stretch, stretchBits],\n  )\n\n  const toggle = () => {\n    if (stretch.consumeClick()) return\n    if (isOpen) runClose(true)\n    else runOpen()\n  }\n\n  /* Outside press and Escape close, and Escape puts focus back on the trigger. */\n  React.useEffect(() => {\n    if (!isOpen) return\n    const onPointerDown = (event: PointerEvent) => {\n      if (!rootRef.current?.contains(event.target as Node)) runClose(false)\n    }\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (event.key !== \"Escape\") return\n      runClose(false)\n      triggerRef.current?.focus()\n    }\n    document.addEventListener(\"pointerdown\", onPointerDown)\n    document.addEventListener(\"keydown\", onKeyDown)\n    return () => {\n      document.removeEventListener(\"pointerdown\", onPointerDown)\n      document.removeEventListener(\"keydown\", onKeyDown)\n    }\n  }, [isOpen, runClose])\n\n  const scale = size / BUTTON\n  const surface =\n    \"bg-background border-border rounded-full border shadow-[0_3px_6px_-1px_rgb(0_0_0/0.10)]\"\n  /* While the liquid owns the picture the crisp hover discs step aside: painted\n     over the moving goo they read as a second border. */\n  const hit =\n    \"text-foreground/80 hover:text-foreground hover:bg-foreground/5 [[data-liquid]_&]:bg-transparent focus-visible:outline-foreground absolute grid place-items-center rounded-full outline-none transition-colors focus-visible:outline-2 focus-visible:outline-offset-1\"\n\n  return (\n    <div\n      className={cn(\"relative select-none\", className)}\n      style={{ width: size, height: size }}\n      {...props}\n    >\n      <div\n        ref={rootRef}\n        className=\"relative\"\n        style={{\n          width: BUTTON,\n          height: BUTTON,\n          transform: scale === 1 ? undefined : `scale(${scale})`,\n          transformOrigin: \"0 0\",\n        }}\n      >\n        {/* Layer 1 - the resting picture: real borders, real shadows. */}\n        <div ref={bodiesRef} className=\"absolute inset-0\" aria-hidden=\"true\">\n          <div\n            ref={triggerBodyRef}\n            className={cn(\"absolute\", surface)}\n            style={{ width: BUTTON, height: BUTTON, left: 0, top: 0 }}\n          />\n          {fan.drops.map((drop, i) => (\n            <div\n              key={items[i].id ?? items[i].label}\n              ref={(el) => {\n                bodyRefs.current[i] = el\n              }}\n              className={cn(\"absolute\", surface)}\n              style={{\n                width: BUTTON,\n                height: BUTTON,\n                left: drop.dx,\n                top: drop.dy,\n                /* Scaling around the trigger's centre is what makes a drop grow\n                   out of the button rather than out of thin air. */\n                transformOrigin: `${HALF - drop.dx}px ${HALF - drop.dy}px`,\n              }}\n            />\n          ))}\n        </div>\n\n        {/* Layer 2 - the liquid. A real <svg>, because Safari will not reliably\n            repaint a CSS `filter: url()` on an HTML element whose children\n            animate. It draws its own rim and one shadow for the whole mass. */}\n        <svg\n          ref={gooRef}\n          width={fan.width}\n          height={fan.height}\n          viewBox={`0 0 ${fan.width} ${fan.height}`}\n          className=\"pointer-events-none absolute overflow-visible opacity-0\"\n          style={{\n            left: HALF - fan.cx,\n            top: HALF - fan.cy,\n            filter: \"drop-shadow(0 3px 6px rgb(0 0 0 / 0.10))\",\n          }}\n          aria-hidden=\"true\"\n          focusable=\"false\"\n        >\n          <defs>\n            <GooFilter id={gooId} width={fan.width} height={fan.height} blur={BLUR_REST} />\n          </defs>\n          <g filter={`url(#${gooId})`}>\n            <circle\n              ref={triggerBlobRef}\n              cx={fan.cx}\n              cy={fan.cy}\n              r={HALF}\n              className=\"fill-background [transform-box:fill-box] [transform-origin:50%_50%]\"\n            />\n            {GRAB_CHAIN.map((link, i) => (\n              <circle\n                key={link.follow}\n                ref={(el) => {\n                  chainRefs.current[i] = el\n                }}\n                cx={fan.cx}\n                cy={fan.cy}\n                r={11}\n                className=\"fill-background [transform-box:fill-box] [transform-origin:50%_50%]\"\n              />\n            ))}\n            {fan.drops.map((drop, i) => (\n              <circle\n                key={items[i].id ?? items[i].label}\n                ref={(el) => {\n                  blobRefs.current[i] = el\n                }}\n                cx={fan.cx + drop.dx}\n                cy={fan.cy + drop.dy}\n                r={HALF}\n                className=\"fill-background [transform-box:fill-box]\"\n                style={{ transformOrigin: `${HALF - drop.dx}px ${HALF - drop.dy}px` }}\n              />\n            ))}\n          </g>\n        </svg>\n\n        {/* Layer 3 - glyphs and hit areas, riding the same tweens above the\n            liquid, so the icons stay crisp and the accessibility lives in real\n            DOM buttons. */}\n        <div id={menuId} role=\"menu\" aria-label={label} inert={!isOpen} className=\"absolute inset-0\">\n          {fan.drops.map((drop, i) => (\n            <button\n              key={items[i].id ?? items[i].label}\n              ref={(el) => {\n                dropRefs.current[i] = el\n              }}\n              type=\"button\"\n              role=\"menuitem\"\n              aria-label={items[i].label}\n              className={cn(hit, \"opacity-0\")}\n              style={{\n                width: BUTTON,\n                height: BUTTON,\n                left: drop.dx,\n                top: drop.dy,\n                transformOrigin: `${HALF - drop.dx}px ${HALF - drop.dy}px`,\n              }}\n              onClick={() => {\n                if (stretch.consumeClick()) return\n                items[i].onSelect?.()\n                runClose(false)\n              }}\n              onPointerDown={(event) => stretch.beginGrab(i, event, { x: drop.dx, y: drop.dy })}\n              onPointerMove={(event) => stretch.pointerMove(event)}\n              onPointerUp={() => stretch.release()}\n              onPointerCancel={() => stretch.release()}\n            >\n              {items[i].icon}\n            </button>\n          ))}\n        </div>\n\n        <button\n          ref={triggerRef}\n          type=\"button\"\n          className={cn(hit, \"inset-0\")}\n          aria-expanded={isOpen}\n          aria-haspopup=\"menu\"\n          aria-controls={menuId}\n          aria-label={label}\n          onClick={toggle}\n          onPointerDown={(event) => stretch.beginGrab(\"trigger\", event, { x: 0, y: 0 })}\n          onPointerMove={(event) => stretch.pointerMove(event)}\n          onPointerUp={() => stretch.release()}\n          onPointerCancel={() => stretch.release()}\n        >\n          <span ref={iconRef} className=\"flex\">\n            {icon ?? <PlusGlyph />}\n          </span>\n        </button>\n      </div>\n    </div>\n  )\n}\n\n/** The default trigger glyph. A plus, so the 135° throw lands it as a cross. */\nexport function PlusGlyph() {\n  return (\n    <svg viewBox=\"0 0 14 14\" className=\"size-3.5\" aria-hidden=\"true\">\n      <path\n        d=\"M7 1.5v11M1.5 7h11\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.6\"\n        strokeLinecap=\"round\"\n      />\n    </svg>\n  )\n}\n\nconst isEl = <T,>(el: T | null): el is T => el !== null\n"
    }
  ]
}
