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.
↓Scroll to step through it
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.
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.
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.
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.
blob only
control only
together
- Blob layer
- soft discs under the goo filter
- Control layer
- sharp icons + hit targets
{/* 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>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.
blob only
control only
together
- Blob layer
- soft discs under the goo filter
- Control layer
- sharp icons + hit targets
{/* 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>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.
raw circles
after goo filter
- feGaussianBlur
- stdDeviation="6"
- feColorMatrix
- alpha ×20 − 10
<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>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.
- spring
- open delay
- close delay
- 170 / 12 / 0.1
- i × 40ms
- (n−i) × 20ms
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, }}/>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.
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.
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
transformBlob open / satellite orbitSVG goo filterfeGaussianBlur + feColorMatrix blend while openingThe 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.