The matrix rain behind my home page looked fine on every screen I tested it on, right up until I scrolled on my own laptop. It runs at 120Hz, and I could watch the frames drop. Smooth motion turned into a visible stutter the moment the page started moving. Two separate problems were hiding in there, and the fix for the bad one was the opposite of what I expected.
The void at the top
The first problem was easy to see. On a phone, if you pull the page down past the top, iOS gives you that rubber-band bounce. My canvas ended exactly at the top of the screen, so pulling down revealed a flat black strip below the rain. It looked like the animation had run out of road.
The fix was to give the canvas some overhang. I start it half a viewport above the page top and make it one and a half times as tall as the hero. Now the rain already exists in the space the bounce reveals, so a pull-down shows more matrix instead of a void. The canvas stays position: absolute, not fixed, so it still scrolls away once you leave the hero and never turns into a full-page backdrop. A small resize handler sets the pixel top and height so it tracks the hero on any screen.
The frames were dropping
The second problem was the real one. Sitting still, the rain was fine. The instant I scrolled, the frame rate collapsed from 120 down to something closer to 30. On a 60Hz screen you might not notice. On a 120Hz one it is obvious, because you have felt what smooth actually looks like and then it is gone.
My first instinct was that the animation itself was too heavy. It was not. The draw loop was cheap. The problem was a single line of CSS I had added weeks earlier and forgotten about.
The culprit was a CSS mask
I wanted the rain to fade out softly at the bottom of the hero instead of ending in a hard line. The obvious way to do that is a mask-image with a gradient. It looks perfect and it is one line, so that is what I used.
What I did not think about is what a mask costs. The canvas is a big, semi-transparent, constantly animating layer. Adding a mask tells the browser it cannot just paint that layer straight to the screen. It has to render the layer off to the side, apply the mask, and composite the result back, and it has to redo that every single frame the canvas changes. While the page is still, that is survivable. While you are scrolling, the compositor is already busy moving the whole page, and now it is also re-masking a full-screen canvas 120 times a second. That is where the frames went.
Baking the fade into the draw
The fix was to stop asking CSS for the fade and do it inside the canvas instead. At the end of each frame I paint a single gradient rectangle over the bottom of the canvas, going from transparent to the page background color. It produces the same soft fade, but now it is just a few more pixels drawn in a loop that was already running. There is no extra layer, no off-screen pass, nothing for the compositor to redo.
With the mask gone, the layer composites plainly and scrolling went straight back to a locked 120. The animation code never changed. I only moved the fade from a place that was expensive to a place that was almost free.
The small stuff that also helped
- I throttle the rain to a slower redraw rate while the page is actively scrolling, so it is not fighting the scroll for the compositor's attention. It speeds back up when you stop.
- I cap the device pixel ratio the canvas renders at. A retina screen does not need the rain drawn at full native resolution, and capping it cuts the pixel count without any visible loss.
- An off-screen check pauses the loop entirely once the hero scrolls out of view. No point animating pixels nobody can see.
The lesson
The effects that feel free are the ones to watch. A mask, a blur, a filter, a drop shadow on a moving element: each of those can quietly force the browser to render a layer off-screen and composite it back every frame. On a static element you will never notice. On something that animates or sits behind a scroll, it is the first place to look when the frame rate falls apart.
The rule I took from it: if an effect can live inside the thing that is already drawing, put it there. The canvas was going to run its loop regardless. Folding the fade into that loop cost almost nothing. Asking the compositor to do the same job from the outside cost me three quarters of my frame rate. You can see the result at the top of the home page, and now it holds its frames while you scroll.