A card becomes halfway visible and its reveal effect runs. Scroll away and back, and the effect runs again. threshold: 0.5 does not mean “run once when at least 50% is visible.” It marks a boundary that can be crossed in either direction. Your code decides whether an effect should happen only once.
When does threshold 0.5 call the observer?
The observer reports when the fraction of the target intersecting its root crosses the 0.5 threshold upward or downward. An initial notification also reports the target's state after observation begins. MDN describes threshold crossings and observation.
The diagram shows one card dropping from roughly 70% to 30% visibility. It crosses the 50% boundary on the way down too, so another callback is expected. The threshold is not a one-shot timer.
What appears when visibility changes from 70% to 30%?
Put a 100-pixel-tall card inside a 100-pixel-tall scrolling region. Save the following as an HTML file and open it in a browser:
<div id="scroll" style="height:100px; overflow:auto; border:1px solid">
<div style="height:150px"></div>
<div id="card" style="height:100px">Card</div>
<div style="height:300px"></div>
</div>
<script>
const root = document.querySelector('#scroll');
const card = document.querySelector('#card');
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
console.log(entry.intersectionRatio.toFixed(2));
}
}, { root, threshold: 0.5 });
observer.observe(card);
</script>In developer tools, set root.scrollTop = 180, observe the callback, then set root.scrollTop = 220. With these dimensions, the card is roughly 70% and 30% visible at those positions.
| Moment | Approximate intersection ratio | Why a callback arrives |
|---|---|---|
| Start observing | 0.00 | Initial state notification |
scrollTop = 180 | 0.70 | Crosses 0.5 upward |
scrollTop = 220 | 0.30 | Crosses 0.5 downward |
intersectionRatio measures the intersecting fraction of the target's area. In this simplified example the width stays fixed, so the fraction matches the visible height. Browser layout can shift the exact values; the important observation is the downward crossing.
How can a card reveal only once above 50%?
Replace the observer above with a callback that checks the ratio and stops observing the card after its first qualifying reveal:
const observer = new IntersectionObserver((entries, observer) => {
for (const entry of entries) {
if (entry.intersectionRatio < 0.5) continue;
entry.target.classList.add('visible');
observer.unobserve(entry.target);
}
}, { root, threshold: 0.5 });
observer.observe(card);If observation continued, the 30% state would fail the ratio check. Here unobserve() stops callbacks for that card after its first reveal. Add the visible CSS style or animation appropriate to the page.
isIntersecting alone does not mean “at least 50% visible”; it reports whether there is an intersection. Check intersectionRatio for the required percentage. Keep root set to the scroll container when that is the viewport of interest; omitting it changes the reference to the browser viewport.
Key takeaways
threshold: 0.5 reports crossings of the halfway boundary in both directions and the initial observation state. For a one-time reveal, check intersectionRatio >= 0.5, then call unobserve() after applying the effect.

