vaweGitHub

MovesProduct moments

Gooey filter

look: vawe3.6 sgooey-filter.md

Gooey filter: final frame of the product UI animation
Tap the clip to play it.

When to use

Use when a tab, a pill or a panel must melt into the next one: a tab bar whose active tab is joined to the page below it, a blob that stretches to a new slot, a menu that pours out of a button. Shapes that touch merge into one soft outline with rounded joins. Clip: gooey-filter.mp4. Demo: demo/gooey-filter.html.

Credit: port of fancy by Daniel Petho, MIT. Source: gooey-svg-filter (repo). The filter values and the demo are the original's.

Put the shapes and a filter: url(#gooey-filter) on one wrapper. Keep text that must stay crisp outside the wrapper: the blur would smear it. The filter blurs the shapes, pushes their alpha through a steep matrix (a threshold with a soft edge), then draws the sharp source on top of that mask (atop).

The code

html
<svg style="position:absolute;width:0;height:0"><defs>
  <filter id="gooey-filter" x="-30%" y="-30%" width="160%" height="160%">
    <feGaussianBlur id="gblur" in="SourceGraphic" stdDeviation="15" result="blur-sm"/>
    <feColorMatrix in="blur-sm" type="matrix" values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 19 -9" result="goo"/>
    <feComposite in="SourceGraphic" in2="goo" operator="atop"/>
  </filter>
</defs></svg>
<div class="filtered" style="filter:url(#gooey-filter)"> tab row (one #efefef box slides) + panel </div>
<div class="labels"> the tab titles, no filter </div>

Easing and timing

The clip runs 3.6 s. The look is vawe.

Notes

js
const S = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--vh')) / 532;  // the original demo is 532 px tall
document.getElementById('gblur').setAttribute('stdDeviation', String(15 * S));   // filter px do not scale with CSS: scale them
const move = spring(t - clickAt, 171.4, 26.2);                  // the tab box: motion spring { duration: 0.4, bounce: 0 }
ind.style.translate = `${(from + (to - from) * move) * 100}% 0`;  // one box, equal cells: a pure translate
const u = cubicBezier(0, 0, 0.58, 1)(clamp01((t - clickAt) / 0.2));   // the page swap: motion "easeOut", 0.2 s
// leaving page: opacity 1-u, y -50*u, blur 10*u. entering page: opacity u, y 50*(1-u), blur 10*(1-u). Both at once, same spot.

The numbers that make it look expensive

  • stdDeviation 15 (8 on a phone) for a demo 532 px tall: about 2.8 percent of the height. Scale it with the frame.
  • Colour matrix alpha row 0 0 0 19 -9: the blurred alpha times 19 minus 9. Keep both numbers. A smaller 19 makes a muddy, wide join.
  • The tab box moves 0.4 s on a critically damped spring (no overshoot), the page swap is 0.2 s easeOut: the box lands after the text.
  • The filter acts on the whole wrapper: any text inside it stays sharp (atop) but its edges are bound by the blob, so pad the text well inside the shape.
  • The label row is separate and switches colour at the click, not with the box: the new label is dark on the dark ground for a moment, as in the original.
  • Give the filter a wide region (x="-30%" y="-30%" width="160%" height="160%") and the wrapper will-change: transform. The default region (10 percent of the wrapper box) cuts the blur, and on a page that two render workers share the outline then flipped 1.5 px between frames on a still shape. Both lines fixed that in the render.

The common mistake

Using a fixed stdDeviation on a large frame: the filter works in element pixels, so the same number gives a tiny join at 1080 p. Also putting the labels inside the wrapper (the threshold eats thin glyphs), and animating the filter itself: animate the shapes, never the filter values.

Difference from the original: the tab titles use Fraunces in place of Calendas, the cursor click is a time table (CLICKS), and the Safari branch (no spring) is not ported.

Sound

Sound: none; or a soft wet tick on each click.