lab

Condense

05 · Aug 2026

A hover preview that doesn't appear — it condenses. Droplets peel off the word you're pointing at and run together into the card, and the text resolves as the shape grows rather than after it lands. Moving to the next link doesn't open a second card; the same ink travels, and trails a neck behind it.

find me on github, x or linkedin

520ms
6px
3

Three verbs, one body

The card condenses, travels and evaporates, and it is the same ink each time. Sweeping the row does not open three cards; the one you are reading is the one you were reading. The links go nowhere on purpose — this is a workbench, and walking off it mid-experiment is the one thing it should never do.

Leaving takes 220ms against the 520 it took to arrive, and it is not the arrival played backwards: the card retracts down through the neck it came out of, and the words un-resolve into the ink as the body passes back over them. An exit that costs what its entrance cost makes everything feel slower than it is. The switch marked make leaving mirror arriving hands the exit back to the birth’s own springs, at their own length; flip it and the card overstays.

Goo wants shapes

The filter blurs, thresholds the alpha and paints the original back on top, so two shapes close enough together stop being two shapes. It is also indiscriminate: a 1px border does not survive a threshold. So the card stopped being a bordered white box and became solid ink — the shape that condenses is the card, with no handoff from a silhouette to a real card and no fourth act to hide.

Everything inside the filter is a solid shape and nothing else: the drops, the body, and the three circles that make the neck. The icon and the type live in a second, unfiltered layer above them, because a filter re-rasterizes its whole subtree every frame and thin type is the most expensive thing you could put in there. Flip remove the goo filter and the choreography is identical — the drops just arrive and stack.

A birth is a separation

One rectangle changing size on one curve reads as a box growing, which is what it is. So the birth is two springs instead: a faster one carrying the rise off the word, a slower one carrying the size. Neither number is decorative, and the lag between them is the whole effect — the body is still catching up to its own height while it is already leaving the word, which is the shape a drop makes when it lets go of a surface. Give the two the same duration and there is nothing left but a box growing again.

Between the body and the word sit three circles whose radius falls as the gap opens. Nothing schedules the moment they let go: the threshold cuts alpha at 0.45, so a circle thinner than about 3.6px of radius stops surviving the filter at all. The neck stretches, thins, crosses that floor about 70ms in, and the card is on its own. The pinch is something the filter performs, not something the code times — which is why it lands in a different place at every condense setting and never looks scheduled.

What all of that is born on is a link you are still reading, and a birth that starts at full density is not a birth, it is a lid — about four frames of opaque ink parked over the word you are pointing at. Brief, and still wrong: it is the one moment the gesture claims to be coming out of the link while it is busy hiding it. So the ink arrives late. The layer’s density is bound to the clearance between the body’s floor and the word’s — nothing while it is still lying on the type, everything once a dozen pixels of daylight have opened under it, squared, because ink thickens as a drop pulls away rather than fading up on a ramp. The most that ever sits over the word is 29% of it, and read backwards it is the exit: the card de-inks on the way down instead of re-covering the link.

Nothing tunes those two against each other and they land a third of a pixel apart. The neck stops surviving the filter at a gap of 19.67px; the shell reaches full density at 20, because both are functions of the same falling number — so the last frames of the neck are drawn at full ink, and the card is solid the instant it is alone. That is the argument for binding to geometry rather than to clocks, in one number: two rules written for unrelated reasons agree without being asked to, at every condense setting.

The neck’s root cannot leave the same way, because the root arriving at the word is the entire reason the neck reads as coming out of it. It is thinned in place instead, to 18% across the twelve pixels the word’s own ink occupies, so it still touches the link — as a stain rather than as a lid. Both of these land on the filtered layer and not on the shapes inside it, which is the only detail that matters here: CSS runs filter, then mask, then opacity, so up there they dim the goo’s result. One element down they would be feeding the threshold a weaker alpha instead, and the threshold does not dim — a translucent seed is an absent seed, and a translucent root is a shorter neck that pinches a frame early.

The words are bound to the shell

The text has no timeline of its own. It is a function of the body’s size: it starts resolving at 30% of full and is done by 92%, while the shape is still sweeping up rather than after it lands. Two timelines tuned against each other always leave a window where a finished card sits on screen with nothing in it — a capture of the version this replaces put that window at 100–175ms. Bound to the size, the frame cannot exist at any speed. Whatever falls outside the ink while the words resolve is bg-on-bg: the rising edge is a free wipe mask.

The same binding read backwards is the exit, which is why the shell never collapses around text that already left. Blur, opacity and a 5% scale run together on the way in, with the scale finishing first — when a composited scale settles, the browser re-rasterizes every glyph at exact size and the line lands half a pixel over, so that snap has to fire while there is still blur left to cover it. Movement settles, then focus resolves, which is also the order a lens does it in.

The lag is the liquid

Moving between links, two bodies chase the same place at different masses: the card on a 0.5s spring, and a slightly smaller rectangle behind it on a 0.9s one. Same curve, same bounce, more weight. At rest the second one sits entirely inside the first and you cannot see it at all.

Hop quickly between two links and the lag falls further behind, so the filter draws more liquid between them and the neck gets longer. Dwell and it catches up and the neck closes. Nothing measures your speed — viscosity that depends on velocity falls out of having a second spring, and springs were the right thing anyway, because changing your mind mid-travel has to carry the speed you already had.

What the filter costs

It used to come off the moment the card settled, on the reasoning that a card sitting still has nothing left to merge. That was wrong, and visibly so. A blur followed by a threshold is a dilation: the filtered card renders about a pixel and a half wider than the same card unfiltered, with corners the blur has rounded further. Swapping the filter for none is a geometry change, and doing it at the exact frame everything else stops moving means the card arrives, holds, and then its border twitches.

So it stays on for as long as the card is on screen. It costs almost nothing to leave it there — a filtered subtree that is not changing is a cached raster, and the expensive thing was never the filter but the will-change holding a compositor layer open under it. That promotion is also held for the card’s whole life, for the same reason the filter is: dropping a layer re-rasterizes the text it was carrying, the line lands half a pixel over, and the only frame where nobody can see that happen is the one where the card is already gone.

Drag viscosity to see what the number actually is. At 2 the drops never fuse and land as separate dots; at 12 they are one pancake before they arrive. Six is the width at which a 10px drop stays a drop right up until it touches its neighbour.