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