The about hero stacks ten photos of me in different wardrobe. The first version of the interaction was discrete: divide the viewport into ten bands, snap to the nearest frame as the cursor crossed a boundary. It worked. It felt like a vending machine.
The fix was to make the cursor a slider, not a switch. The cursor’s X position over the viewport maps to a continuous index between 0 and 9. Floor and ceil give me the two adjacent frames. The fractional part is the cross-fade ratio. Frame floor gets opacity 1 minus fade. Frame ceil gets opacity fade. Everything else is opacity 0.
const continuousIdx = (lastX / w) * (FRAME_COUNT - 1);
const floorIdx = Math.floor(continuousIdx);
const ceilIdx = Math.min(FRAME_COUNT - 1, Math.ceil(continuousIdx));
const fade = continuousIdx - floorIdx;
The transition CSS is 60ms linear, which is short enough that the cross-fade tracks the cursor instead of trailing it. Anything longer felt laggy. Anything shorter and the GPU compositor would tear visible step boundaries between frames.
Two things make this cheap to run. The opacity updates happen inside requestAnimationFrame, so pointermove events that arrive faster than a frame coalesce. And every frame is on the GPU layer (opacity + will-change), so the browser never repaints, only recomposites.
I added a “Move cursor” hint that fades out the first time the user actually moves. It sits at the bottom-center, low-contrast, in the body font. It teaches the interaction without explaining it, and it gets out of the way the second it’s served its purpose.
The principle: when an interaction is the point, make it continuous. Discrete steps belong to nav menus.