Use an Observer, Not a Scroll Handler
Scroll events fire whether or not anything is happening. IntersectionObserver only fires when something crosses a line you care about.
A scroll listener runs on the main thread dozens of times a second for the entire life of the page. If it measures anything with getBoundingClientRect, it forces a layout on each call.
What the Observer Does Better
IntersectionObserver is evaluated off the main thread and calls you only when an element crosses a threshold. For reveal animations that is exactly the event you wanted, and you were only approximating it with scroll maths.
Unobserve After Firing
A reveal happens once. Calling observer.unobserve(entry.target) inside the callback means each element stops being watched the moment it has done its job, so a long page winds down to zero observers rather than carrying all of them to the bottom.
When You Genuinely Need Scroll
Continuous effects — a progress rail, parallax — need scroll position rather than a threshold crossing. Two rules make that acceptable: mark the listener { passive: true } so it never blocks scrolling, and write only transforms inside it, never reads that force layout.
The One Listener We Kept
The progress rail on the scroll story. It is passive, it writes a single scaleX, and it is the only scroll listener in the template.