Scroll down slowly to see each card fade in via IntersectionObserver, triggered by a rootMargin that effectively fires at 88% of the viewport height. Scroll an element out of view and back in — it re-animates every time.
Now scroll back up. Direction is inferred from entry.boundingClientRect.top vs entry.rootBounds.top — scrolling down applies translateY(-20px), scrolling up applies translateY(20px), before the "in" class triggers the transition.
This build uses a single IntersectionObserver (threshold: 0, rootMargin: '0px 0px -12% 0px'), will-change: transform, opacity for GPU compositing, and respects prefers-reduced-motion by only fading opacity with no transform offset.