Community JavaScript Snippet
useInView Hook With Hysteresis
An IntersectionObserver-based `useInView` that does not flip on/off when an element straddles the viewport edge. Uses two thresholds (enter and exit) so analytics events fire once per real visibility.
useInView Hook With Hysteresis
An IntersectionObserver-based `useInView` that does not flip on/off when an element straddles the viewport edge. Uses two thresholds (enter and exit) so analytics events fire once per real visibility.
By @ethanhadid
November 25, 2025
·
Updated May 18, 2026
992 views
10
4.3 (14)
Single-threshold visibility detection looks fine in a demo and falls apart on real product pages. The wobbly trace above is what an actual IntersectionObserver reports when a user scrolls slowly past a card with a parallax background: 0.55, 0.45, 0.52, 0.48 over a few hundred milliseconds. With one threshold of 0.5 we get five state flips and five analytics.track('card_viewed') events; with separate enter (0.6) and exit (0.3) thresholds we get one. The pure helper means I can drive the table-test in vitest without spinning up IntersectionObserver at all.
The threshold: [exit, enter] array is the part most blog posts get wrong. IntersectionObserver only fires when the ratio crosses one of the values you pass, so if you give it [0.5] you do not get sub-half ratios in the entry. We need both 0.3 and 0.6 in the array so the callback runs at both edges, and then the stepHysteresis decision happens in JS. Stashing visible in a ref alongside the React state lets the callback compute the next value without going through a stale closure. I default enter to 0.6 and exit to 0.3 because that is what kept the analytics dashboard quiet in production.
triggerOnce is the option I use most: track a card view the first time it appears, then disconnect the observer entirely so the browser stops doing layout work for it. The two refs do separate jobs. visibleRef mirrors React state so the next frame computes correctly; firedRef is the latch that prevents re-firing if a parent layout shift causes an unmount/remount cycle. Splitting them is the difference between one analytics event per user and one event per scroll, which is what made me write this hook the same week our PostHog bill doubled.
