4 November 2026 · 4 min read
Making a pencil-sketch portfolio scroll at 60fps
Four WebGL scenes, a paper-grain overlay, and SVG pencil filters made this site stutter. Here's what was actually slow, the benchmark that lied to me, and the fix that almost erased the design.
This site looks like it was drawn with colored pencils: cream paper with grain, wobbly borders, and four small Three.js scenes drawn in ink. It also stuttered every time you scrolled. This is how I found out why, and what I changed.
Measure first, with the CPU slowed down
Fast laptops hide jank, so I measured in headless Chromium with the CPU throttled 4×, scrolling the whole page one frame at a time and recording every frame's duration.
Before any changes:
| Average frame | 95th percentile | Frames over 50 ms | |
|---|---|---|---|
| Before | ~39 ms | ~117 ms | 40 |
A frame budget at 60fps is 16.7 ms. I was more than twice over on average.
The benchmark that measured nothing
My first numbers looked great after the first fix. Too great. The page had scroll-behavior: smooth in CSS, so every scrollTo(0, y) in my script started a smooth scroll and the next call cancelled it. The page never moved. I was benchmarking a static page.
The fix was one option:
window.scrollTo({ top: y, behavior: "instant" });
Lesson: assert that your benchmark is doing the thing. I now log the final scrollY in every run.
What was actually slow
I turned features off one at a time and re-measured. Three things stood out:
- The WebGL scenes kept rendering while scrolling. Four animation loops competed with scroll for the main thread and the GPU.
- CSS
mask-imageon the canvases. The soft fades around each scene forced the browser to re-composite a masked layer on every scrolled frame. - A full-screen grain overlay (
position: fixedover everything) that had to be blended on every frame.
Fix 1: pause the scenes while scrolling
Nobody watches a decorative animation mid-scroll. Each scene now freezes when a scroll starts and resumes 150 ms after it stops. Elapsed time only advances while rendering, so the animation picks up where it left off instead of jumping.
const onScroll = () => {
if (!scrolling) {
scrolling = true;
pause();
}
clearTimeout(scrollIdle);
scrollIdle = setTimeout(() => {
scrolling = false;
resume();
}, 150);
};
Fix 2: move the fade from CSS into WebGL
Instead of a CSS mask, each scene draws one extra full-screen quad after the scene, with a blend mode that multiplies the framebuffer by the mask. It's one cheap GPU pass per rendered frame, and zero work while scrolling because the scene is paused.
Fix 3: mount scenes one at a time, when idle
All four scenes used to initialise on the first interaction: four WebGL contexts and four shader compiles in the same frame. Now they queue up and mount one per requestIdleCallback, so setup never lands in the middle of a scroll.
After these fixes, same benchmark, same throttling:
| Average frame | 95th percentile | Frames over 50 ms | |
|---|---|---|---|
| Before | ~39 ms | ~117 ms | 40 |
| After | ~18 ms | 17–33 ms | 0–1 |
The fix that almost erased the design
To save the grain overlay's cost, I'd moved the paper grain under the content instead of over it. The numbers improved. The site looked wrong.
I only saw it by building the old version and comparing zoomed-in screenshots side by side: every card, input, and code block had turned clean digital white. The grain over everything was what made it look like pencil on paper.
So the overlay came back, but on its own compositor layer, so it's rasterised once and only composited while scrolling:
.paper::after {
position: fixed;
inset: 0;
opacity: 0.5;
background-image: var(--grain);
will-change: transform;
transform: translateZ(0);
}
The scenes had also lost mix-blend-mode: multiply, which made their lines look like ink soaked into paper. Blending a canvas against the page every frame is expensive, but over a plain background you don't need the browser to do it: multiply the stroke color by the paper color yourself, then draw normally.
// multiply over paper: result = paper × ink
const ink = themeColor(token).multiply(paperColor);
On the dark chalkboard theme the same idea uses screen instead, so lines glow like chalk.
What I'd tell myself at the start
- Throttle the CPU when measuring. Your laptop is not your visitor's phone.
- Check the benchmark moves. Mine didn't, and I almost shipped a conclusion built on a static page.
- Turn things off one by one. Guessing would have blamed the SVG pencil filters, which turned out not to be the main cost.
- Compare screenshots, not just numbers. A performance fix that changes the design isn't a fix.