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.

JavaScript
Frontend
3 snippets
react
hooks
performance-optimization
intersection-observer
ethanhadid

By @ethanhadid

November 25, 2025

·

Updated May 18, 2026

992 views

10

4.3 (14)

// Naive useInView: any time the threshold crosses, flip the state.
// Pure hysteresis logic isolated so we can unit-test it.

function stepNaive(visible, ratio, threshold) {
    if (visible && ratio < threshold) return false;
    if (!visible && ratio >= threshold) return true;
    return visible;
}

function stepHysteresis(visible, ratio, enter, exit) {
    if (!visible && ratio >= enter) return true;
    if (visible && ratio <= exit) return false;
    return visible;
}

// Drive both with a sequence of ratios that wobbles around the threshold,
// the way an item near the fold actually reports during a slow scroll.
const trace = [0.0, 0.3, 0.55, 0.45, 0.52, 0.48, 0.51, 0.7, 0.4, 0.1];

let naive = false;
let flips = 0;
for (const r of trace) {
    const next = stepNaive(naive, r, 0.5);
    if (next !== naive) flips++;
    naive = next;
}
console.log('naive (single threshold 0.5) flipped', flips, 'times');

let smart = false;
let smartFlips = 0;
for (const r of trace) {
    const next = stepHysteresis(smart, r, 0.6, 0.3);
    if (next !== smart) smartFlips++;
    smart = next;
}
console.log('hysteresis (0.6 enter / 0.3 exit) flipped', smartFlips, 'times');

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.