← Back
[W] Writing

How shapes melt into each other

When a timer starts on an iPhone with a Dynamic Island, a small bubble pulls away from the black pill at the top of the screen. For a moment the two are joined by a thin neck, like a drop of water letting go of a tap. Then they're two shapes.

Most UI animation can't do that. A rounded rectangle can grow, shrink and change its corners, but it can't split in two or melt into a neighbour.

Try it below. Drag the circle towards the pill and away again.

Every pixel asks how far it is from each shape, then blends the two answers. The faint rings are lines of equal distance, and the dashed red outlines are the two shapes on their own. Switch to plain min and the neck disappears.

Apple hasn't published how the Dynamic Island is drawn, and I won't pretend to know. Apple's announcement only says it's "fluidly expanding into different shapes". What follows are the two standard ways to get that liquid look, how each one works, and what each one costs.

One detail worth knowing first: the island is really two separate cutouts, a circle for the camera and a pill for the rest of the TrueDepth hardware, "merged through software". It was two shapes pretending to be one from day one.

Describe a shape by distance

The usual way to describe a shape is its outline: a list of points and curves. That's what SVG paths, fonts and border-radius are.

The other way is a question you can ask at any point: how far am I from the edge? That's a signed distance function, or SDF. It returns a positive number outside the shape, a negative number inside, and exactly 0 on the edge.

d = +18 d = −14 d = 0, the edge rings every 18
A circle's distance function is the distance to its centre, minus its radius. Outside is positive, inside is negative, and the shape is wherever the answer is below zero.

For a circle it's one line. A pill is a circle swept along a straight segment, so its distance is the distance to that segment, minus the radius.

const circle = (px, py, cx, cy, r) => Math.hypot(px - cx, py - cy) - r;

// A horizontal pill: a segment of half-length h, thickened by r.
const pill = (px, py, cx, cy, h, r) =>
  Math.hypot(Math.max(Math.abs(px - cx) - h, 0), py - cy) - r;

To draw it, you ask every pixel the question and fill the ones where the answer is negative. On a GPU that runs in parallel for every pixel at once, which is why this approach is a staple of shader programming.

Joining two shapes is a min

Put two shapes in one scene and each pixel gets two answers: its distance to the pill and its distance to the circle. Take the smaller one. If either says you're inside, you're inside.

That's a union, and it's the hard min in the demo. It's exact, and it gives a crease wherever the shapes meet. While they're apart, the gap stays a gap no matter how close they get.

The smooth minimum fixes that by cheating near the crease. When the two distances are close, it reports something a bit smaller than either. Here's the quadratic version from Inigo Quilez's article on smooth minimums, translated to JavaScript:

function smin(a, b, k) {
  k *= 4;
  const h = Math.max(k - Math.abs(a - b), 0) / k;
  return Math.min(a, b) - h * h * k / 4;
}

When a and b are far apart, h is 0 and it's a plain min. The shapes don't change at all. When they're equal, h is 1 and the result is pulled down by exactly k. In his words, k is "the thickness of the blended area, in actual distance units".

Distance along a line through both shapes
0 inside outside min: +20, still a gap smooth min: −5, joined shape A shape B
Two circles of radius 30 with a gap of 40. Plain min peaks at +20 between them, so the gap stays empty. Smooth min with k = 25 pulls that peak down by 25 to −5, so the middle is now inside.

That gives you a rule you can design with. Halfway across a gap of width g, both distances are g/2, so the blended value there is g/2 − k. The shapes connect once the gap is narrower than 2k. Drag the circle in the first demo and the readout shows that exact moment.

It also tells you the effect is local. Shapes more than k apart in distance terms are untouched, so the pill keeps its true outline until something comes close. That's the property Quilez calls rigidity, and it's why the quadratic version is the common pick for UI shapes.

The web's cheap version: blur, then threshold

Running a distance function per pixel needs a canvas or a shader. In 2015, Lucas Bebber's Gooey Effect on CSS-Tricks showed a way to get a similar look with an SVG filter on ordinary elements.

<filter id="goo">
  <feGaussianBlur in="SourceGraphic" stdDeviation="10" />
  <feColorMatrix type="matrix"
    values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 18 -7" />
</filter>

Step through it below.

The same pill and circle, plus a square, drawn as plain SVG with a filter. Blur softens every edge. The threshold turns each soft pixel back to fully solid or fully clear.

The blur spreads each shape's opacity outwards. Where two shapes are close, their soft edges overlap and add up.

The colour matrix leaves red, green and blue alone. The last row rewrites opacity as 18 × alpha − 7, with alpha running from 0 to 1 and the result clamped to that range. Anything under 7/18, about 39% opacity, goes to zero. Anything over 8/18, about 44%, goes fully solid. The edge lands wherever the blurred opacity crosses that line, and between two close shapes the overlapping blur crosses it, so a neck appears.

This is older than CSS. In a 1982 paper, Jim Blinn rendered molecules by giving each atom a soft field that fades with distance, adding the fields up, and drawing the surface where the total crosses a threshold. Those are now called blobby objects, or metaballs. Blur and threshold is the same idea, done with image filters: the blur is each shape's field, the overlap is the sum, and the colour matrix draws the line. Compositing doesn't add opacity exactly, but it's close enough to look the same.

What the cheap version costs

Switch the goo demo to the threshold step and look at the square. Its corners are round now too. The filter can't tell a join from a corner, so every sharp feature smaller than the blur gets melted. Anything much thinner than the blur fades below the threshold and disappears.

Text and icons inside the shapes melt as well. The fix is two layers: the blobs live in one filtered layer, and the content sits on top of it, unfiltered.

It's also a full-area blur. Every frame the shapes move, the browser has to blur and recolour every pixel in the filter region, and a big Gaussian blur is one of the more expensive things you can ask it to redraw. Keep the region tight and the blur small.

The distance version has none of these problems. The pill stays a true pill until the circle gets close, the edges stay exact at any zoom, and k means a real distance you can choose. What it costs is that you have to own the drawing, in a canvas, a WebGL shader, or a native view.

Path morphAnimates the points of one outline. Can't split one shape into two, or join two into one.
Blur + thresholdOne SVG filter on ordinary elements. Rounds every corner, melts content, redraws the whole area every frame.
Distance + smooth minExact shapes that only change near a join. Needs a canvas, shader or native view to draw it.
Three ways to animate a shape. Only the bottom two can change how many pieces there are.

Why this can split and a morph can't

A path morph moves the points of an outline from one position to another. It works when both shapes have the same structure, like a circle becoming a rounded square.

But a single outline can never become two outlines. There is no point list halfway between one pill and a pill plus a circle. That's a change in topology, the number of separate pieces, and it's exactly the moment the island's neck snaps.

A distance field doesn't store outlines at all. It stores where the shapes are, and the outline is recomputed every frame from the threshold. So you animate the simple things, a centre, a width, a radius, usually with a spring, and the joining and splitting falls out of the maths for free.

That's the real trick behind anything that looks liquid: stop animating the outline, and animate the things the outline is computed from.

Next