By Shehab Waleed, Founder, Cairo Studio 4 min read
Titles on our site change through a goo morph. The outgoing word blurs, a hard threshold turns the blur into blobs, and the incoming word grows out of them. It is the one effect we want identical on every device, and on iPhone it was gone. The words simply faded.
Our frame counter said 61fps. We read that as a phone with headroom to spare, and our first fixes were per-device quality caps and more will-change. All of them came back out, because the frame counter was measuring a page that had stopped doing the work.
The original setup
The container held the SVG filter. Each word animated a CSS blur and an opacity underneath it, and each word carried will-change, which is the usual advice for keeping an animation smooth.
.morph {
filter: url(#goo);
}
.morph-word {
will-change: filter, opacity;
}Desktop Chrome renders this correctly, so nothing looked wrong during development.
What iOS Safari does with it
We put test boxes side by side in the iOS Simulator and changed one thing at a time. When a child under filter: url() has its own compositing layer, iOS Safari drops the SVG filter. There is no warning and nothing in the console. You get a plain blur and fade, and the frame rate rises because the expensive step has stopped running.
This is behaviour we measured, and we can't point you to a line in Apple's documentation that describes it.
So will-change came off and the filter came back. Then the benchmark fell to about 1fps at heading size, because a CSS blur on a child under url() gets rasterized on the CPU on every frame.
Move the blur inside the filter
The fix is to stop asking the children to blur. The blur becomes the first step of the filter graph, followed by the threshold and a small soften, and the words only animate opacity.
<filter id="goo" color-interpolation-filters="sRGB"
x="-20%" y="-100%" width="140%" height="300%">
<feGaussianBlur in="SourceGraphic" result="spread"
stdDeviation="0" />
<feColorMatrix in="spread" result="cut" type="matrix"
values="1 0 0 0 0
0 1 0 0 0
0 0 1 0 0
0 0 0 400 -132" />
<feGaussianBlur in="cut" stdDeviation="0" />
</filter>
Each frame writes the blur radius to the filter and the opacity to the words. This is trimmed from our morph component, which also handles the first and last frames.
const paint = () => {
const f = progress.f;
const spread = blurBase / f - blurBase;
spreadEl.setAttribute('stdDeviation', `${spread}`);
softEl.setAttribute('stdDeviation', `${soften}`);
container.style.filter = `url(#${gooId})`;
const opacity = `${Math.pow(f, 0.4)}`;
for (const word of words) word.style.opacity = opacity;
};The same benchmark went to 56fps, and live swipes on the site hold 61fps with the real goo on screen.
Two more costs that only showed up on iPhone
An overflow clip above the morph
The same titles inside an overflow: hidden ancestor dropped to about 40fps on a physical iPhone 17, with frames of 110 to 150ms. Removing the clip gave 58fps, and changing the filter parameters made no difference. Our nav panel now clips only while it resizes, then switches to visible on transitionend.
Filtered area
Filter cost follows area. We measured every filtered element per frame as its box, times its filter region, times the device pixel ratio squared. One project name was filtering 0.98 megapixels a frame against 0.12 for a service title, because the filter sat on a 70vw box around a short line of text. Moving it to the row that hugs the text took a full morph from 143 megapixels to 31.
How to check it
Frame rate alone cannot tell you the effect rendered, since a skipped filter scores better than a working one. Record the simulator and look at the frames.
xcrun simctl io booted recordVideo --codec=h264 --force out.mp4
Stop it with SIGINT, pull frames out with ffmpeg and look at the middle of the morph. Blobs mean the filter ran. A soft fade means it did not.
We use the same approach when a fixed element misbehaves: trust what is on screen, then go looking for the number that explains it.
