Skip to article body
A man walking through a projector beam in an old Cairo cinema, his figure smeared by a slow shutter, the wall ahead bare
A man walking through a projector beam in an old Cairo cinema, his figure smeared by a slow shutter, the wall ahead bare

Web Development · Field Notes

61fps and Nothing on Screen

Our text morph ran at full speed on iPhone because Safari had stopped drawing it.

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.

Share
X LinkedIn Facebook WhatsApp Email
A hand holding a spinning film reel, blurred into a soft disc

Running at full speed, showing nothing.

61fps

Fast because the filter was skipped

1fps

The filter runs, the blur is on the CPU

56fps

The filter runs, the words only fade

Heading size benchmark in the iOS Simulator, October 2026.

We saw it when a descendant of the filtered element had its own compositing layer. will-change is the common trigger, so we removed it from everything under a goo filter. We have not tested every trigger, so check any effect like this by eye.

Not at this size, if the filter stays small. Ours holds close to 60fps on an iPhone when the blur lives inside the filter, the filtered box hugs the text and no overflow clip sits above it.

Record the screen and step through the frames in the middle of the animation. If the shape never changes the way the filter should change it, the filter did not run, whatever the frame rate says.

We build sites with heavy motion and test them on actual iPhones before they ship.

See our web development work

More from the Journal

view all →