It is a real scroll container with CSS scroll snap, not a faked transform stack, so the wheel gets native momentum and stays accessible. On scroll, a handler throttled with requestAnimationFrame measures each row's distance from the center, normalizes it over a falloff span, and maps that one number to scale, opacity, and blur. It only touches transform, opacity, and filter, so the whole thing rides the compositor and never janks.
// One distance drives all three. Written plainly on purpose.
const n = Math.min(1, distanceFromCenter / SPAN) // 0 at center, 1 at edges
row.style.transform = `scale(${1 - n * 0.22})` // 1 down to 0.78
row.style.opacity = String(1 - n * 0.72) // 1 down to 0.28
row.style.filter = `blur(${n * 3}px)` // 0 up to 3px
The pretty part was the easy part. Underneath it is a single-select listbox, and that was the actual work: one active option at a time with aria-selected, roving tabindex so focus follows the selection, arrow and Home and End keys, and the active label announced in a live region. The tick is synthesized live with the Web Audio API, no sound file, just a short click on each row change that you can mute. Under reduced motion the blur and smooth scroll drop out and the selection still reads clearly, because the white card and aria-selected carry the meaning, not the effect.