GodUIGodUI
129Follow on X

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 effect 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, so the goo bridge forms a beat before the sharp glyph lands and the satellite reads as "extrude, then solidify".

Offsets never touch top / left or resize the parent. They come from offsetFor() and land in Framer's x / y, which are 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 GodUI: compositor props only.

04/04Result
04

The result

Every piece assembled in the real component. Open it, pick an action, and watch the blobs fuse and split. Each motion comes from one of the mechanisms above: soft circles under a blur matrix, sharp buttons on top, one spring timeline, and no filter on anything you still need to read.

Accessibility

useReducedMotion() swaps the spring for a 150ms fade and drops the goo filter entirely, so there is 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 FABCC: Paint-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, which is why the score is a C. Everything else that moves is a transform or an opacity, and the filter drops out entirely under reduced motion.