The about hero is cursor-driven. There are two cases where that’s the wrong default: someone on a phone, and someone who has prefers-reduced-motion on. The shipped code checks both before doing anything:
const isTouch = window.matchMedia('(pointer: coarse)').matches;
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (stage && frames.length === FRAME_COUNT && !isTouch && !reduceMotion) {
// attach pointermove listener, run update loop
}
If either flag is true, the listener never attaches. The first frame stays at opacity 1 (its baseline state from the rendered HTML), and the other nine sit at opacity 0. The page renders a single static portrait. No flicker, no jank, no half-built interactive state.
The “Move cursor” hint is hidden in the same conditions, via a CSS media query. The hint exists to teach a desktop affordance, so it has no business showing up on a phone.
What I avoided: building a touch-equivalent like swipe-to-scrub. Touch users get a real photo, not a knockoff of the desktop interaction. The page is about who I am, and one photo is enough to communicate that. The cross-fade is a delight layer, not a content layer.
There’s a quiet principle here that I keep coming back to. The accessible fallback should not be a degraded version of the interaction. It should be the same content presented in the medium the device supports. If the interaction can’t survive translation, drop it for that medium and rely on the underlying content.
The principle: an interaction that doesn’t work on a phone shouldn’t pretend to. Show the still photo and move on.