MynduiMyndui
0

Anatomy of the Gooey FAB

How a floating action button fuses satellite blobs with an SVG metaball filter, then springs them apart on a staggered timeline.

01/04The two-layer stack

blob only

control only

together

Blob layer
soft discs under the goo filter
Control layer
sharp icons + hit targets
tsx
{/* Blob layer — native SVG so the filter survives Safari compositing. */}<svg aria-hidden style={{ overflow: "visible" }}><g className="fill-primary" filter={reduce ? undefined : `url(#${filterId})`}>  <circle cx={0} cy={0} r={trigger / 2} />  {actions.map((action) => (    <motion.circle key={action.label} cx={0} cy={0} r={sat / 2} />  ))}</g></svg>{/* Control layer — sharp, clickable icon buttons over the blobs. */}<div className="absolute inset-0 grid place-items-center">{actions.map((action) => (  <motion.button key={action.label} aria-label={action.label}>    {action.icon}  </motion.button>))}</div>
01

The two-layer stack

The goo only works on soft, solid shapes; icons need sharp edges and real hit targets. So every open satellite is drawn twice in the same place — a blob (a filled circle under the SVG filter) and a control (a transparent button with the icon). Apply the filter to the icons and they smear into illegible mush; skip it on the paint and you get hard circles with no mercury bridge. Splitting the job keeps both the illusion and the usability: the control layer owns pointer-events, tabIndex, and aria-label; the blob layer is aria-hidden.

The blob layer is native SVG — circles inside the filtered <g> — not filtered HTML. Safari promotes a transform-animated HTML child onto its own compositing layer, which escapes an HTML filter: url() and breaks the merge; SVG shapes stay inside the filter and fuse on every engine.

02/04The goo filter

raw circles

after goo filter

feGaussianBlur
stdDeviation="6"
feColorMatrix
alpha ×20 − 10
tsx
<filter id={filterId} colorInterpolationFilters="sRGB"><feGaussianBlur in="SourceGraphic" stdDeviation="6" result="blur" /><feColorMatrix  in="blur"  mode="matrix"  values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 20 -10"/></filter>
02

The goo filter

The liquid look is two SVG filter primitives. feGaussianBlur softens every edge so nearby shapes bleed into each other; feColorMatrix then punches the alpha channel back up — multiply by 20, subtract 10 — so the soft overlap collapses into a hard metaball bridge while the rest of the shape stays crisp.

That matrix only touches alpha (0 0 0 20 -10 in the last row). The color channels pass through untouched, so the goo is a shape effect, not a tint.

03/04Spring extrusion
+
spring
open delay
close delay
170 / 12 / 0.1
i × 40ms
(n−i) × 20ms
tsx
const target = offsetFor(direction, step * (i + 1));<motion.circleanimate={  open    ? { x: target.x, y: target.y, scale: 1, opacity: 1 }    : { x: 0, y: 0, scale: 0.2, opacity: 0 }}transition={  reduce    ? { duration: 0.15 }    : {        type: "spring",        stiffness: 170,        damping: 12,        mass: 0.1,        delay: open ? i * 0.04 : (actions.length - i) * 0.02,      }}/>
03

Spring extrusion

Depth is the filter; motion is a spring. Each satellite animates x / y / scale / opacity with stiffness: 170, damping: 12, mass: 0.1. On open they stagger out (i × 40ms); on close they reverse ((n − i) × 20ms) so the farthest blob snaps home first. Icons lag the blobs by an extra 30ms on open — the goo bridge forms a beat before the sharp glyph lands, which sells the "extrude then solidify" read.

Offsets never touch top / left or resize the parent — they come from offsetFor() and land in Framer's x / y, GPU-composited transforms. The wrapper stays a fixed trigger × trigger box, so opening the menu never reflows the page. Same house rule as the rest of Myndui: compositor props only.

04/04Result
04

The result

Every piece, assembled — the real component. Open it, pick an action, watch the blobs fuse and split. Each motion is one of the mechanisms above doing its job: soft circles under a blur matrix, sharp buttons on top, one spring timeline, and the discipline to keep the filter off anything you still need to read.

Accessibility

useReducedMotion() swaps the spring for a 150ms fade and drops the goo filter entirely — no smear for anyone who's asked the OS to cut motion. Closed satellites get tabIndex={-1} and pointerEvents: "none" so they can't steal focus; open ones take 0 and become real buttons. The trigger exposes aria-expanded and a label; every satellite carries its own aria-label, and focus-visible rings land on both layers with a 2px offset.

tsx
 
const reduce = useReducedMotion() ?? false;

// Blob layer drops the filter entirely under reduced motion:
filter={reduce ? undefined : `url(#${filterId})`}

// Closed satellites can't steal focus or clicks; open ones become buttons:
tabIndex={open ? 0 : -1}
style={{ pointerEvents: open ? "auto" : "none" }}

// Trigger announces state; every satellite carries its own label:
aria-expanded={open}
aria-label={action.label}

Motion Score

Gooey FABCCPaint-triggering
StransformBlob open / satellite orbit
CSVG goo filterfeGaussianBlur + feColorMatrix blend while opening
Each property is graded by how the browser runs it, from S (composited off the main thread) down to F (layout thrashing); the component takes the worst. MotionScore methodology →

The SVG goo filter is the one paint-tier cost — that's the C on the score. Everything else that moves is a transform or an opacity, and the filter drops out entirely under reduced motion, so the mercury illusion stays cheap enough to run the moment you open it.