Open rodneyrehm opened 8 years ago
If no thresholds are provided, the default is zero, which would work as expected in this case (i.e., notifications are sent when there's a transition from non-intersecting to intersecting-any-amount, and vice versa).
Beyond that, I suppose it's up to the developer to use care if they choose a threshold that can never be crossed because the target is bigger than the root. And I'm afraid that doing anything different could be misleading.
Did you have something else in mind?
I suppose it's up to the developer to use care if they choose a threshold that can never be crossed because the target is bigger than the root.
I don't think anyone would expect that behavior - I certainly wouldn't. I understand that this is an edge-case. But the possibility of this happening, however slim, would force me to calculate dimensions of the element and the root in order to calculate the max ratio for this element. And that would have to be recalculated every time the dimensions of the viewport, scrollable containers or the element change.
Did you have something else in mind?
I don't propose to have a good solution to the problem. But I guess I would have expected an intersection to be observed for el.top <= root.top && el.bottom >= root.bottom && el.left <= root.left && el.right >= root.right
(i.e. the element fully covers the available space) and the observer's threshold was set to 1. I'm not sure if the reported intersectionRatio should be the real ratio (obviously <1), 1 or some other value for better differentiation.
Well, it would certainly be misleading to create an IntersectionObserver with {thresholds: [1.0]}, and then receive a callback even though your target is not fully visible.
Note also that for a cross-origin iframe observing the top-level viewport, we do not report rootBounds, and we would not be able to reveal the "true" intersection ratio. So there would be no way to discover whether the target was really and truly completely visible or not.
I have a hard time imagining a use case where special-casing the behavior of threshold 1.0 in the way you suggest would be useful. Would you mind explaining what you have in mind?
Well, it would certainly be misleading to create an IntersectionObserver with {thresholds: [1.0]}, and then receive a callback even though your target is not fully visible.
I think we have different views of what "fully visible" means. You consider the element only fully visible if all its bounds are within the root. I also consider the case where the element fully covers the root as "fully visible", because that's the most I can get in that situation.
Would you mind explaining what you have in mind?
As a library author I prefer to not know as little as possible about what the user of the library is doing. That includes dimensions of the elements they're working with. The situation described is an edge-case test in my own - far less capable, much uglier - IntersectionObserver implementation. It popped up when I tried to replace those internals with the IntersectionObserver currently implemented in Chrome. As the explainer did not mention this scenario I thought I'd bring it up. Maybe you're right and I'm overthinking this. Maybe this never happens in reality. A hint in the spec wouldn't hurt, though.
Marking this "needs spec", it can be clarified in the explainer.
I'm inclined to keep the behavior as-is, unless someone comes up with a real-world case where this is a problem.
a real-world case where this is a problem.
Highlighting active-in-viewport document sections, as per http://getbootstrap.com/javascript/#scrollspy
As for a potential solution:
add a mode property to the observer settings which switches the ratio computation from <intersection>:<original>
to <intersection>:<root>
. This changes the ratio semantics from "how much of an element is in view" to "how much of the viewport is covered by an element".
FYI, we are considering implementing the behavior suggested in the previous comment. Note that tracking the (intersection/root) ratio would only be permitted in cases where IntersectionObserver currently reports the root bounds (i.e., this wouldn't work for cross-origin iframes).
If you have any other thoughts on this feature, or can give use real-world uses cases, please do.
@rjgotten can you describe how that would help implement scrollspy? scrollspy seems to just look at what element is at the top of the scroller, not which element is taking up the most of the viewport.
Regarding @rodneyrehm's original post, I definitely agree that this is somewhat surprising, but I'm not sure what a better alternative would be. We know some use-cases (e.g. ad and video viewability metrics) want the current behavior. I'm open to adding alternatives, but as @szager-chromium said, we'd need to drive that with concrete use cases.
@ojanvafai can you describe how that would help implement scrollspy?
The Bootstrap implementation tracks the topmost visible top of a section (and thus assumedly; its header/heading).
Ideally, you would want a scrollspy implementation to highlight in its jump-links, the link belonging to the 'dominantly visible' section. That needn't be the top-most section that has a heading visible. You want to factor into the equation other things, perhaps such as tracking whether a particular section still takes up more than a certain threshold percentage of the viewport.
Those kind of improvements are where an 'inverted' IntersectionObserver could help.
Any updates on this thread?
I have a scenario where I'm trying to progress an animation based on the scroll position while a target is in view instead of as its coming into view.
Scenario: In my design, I have a horizontal event timeline and the pointer head moves left to right based on how much you've scrolled. Using IO I set the root to the default viewport. However, with this behavior, the pointer will get from the beginning of the timeline to the end way too quick (pretty much the equivalent duration it takes to scroll the viewport's height). So instead I made the target a div that spans 300vh giving it more slack, but in which case the once the target covered 100% of the viewport it stopped firing events.
Any thoughts on how to get this to work?
@didimedina Any thoughts on how to get this to work?
What you're looking for sounds like scroll-linked animations, but limited to a vertical sub-slice of the total document height; possibly tracked/expressed by a given container element.
Don't think Intersection Observer is meant as a solution to your type of scenario on its own. It's meant to call a callback function once when crossing over a particular threshold. Not for repeated calls with each pixel scrolled.
In your particular case, I would probably use a passive scroll event listener to monitor scroll offset.
And then use an IntersectionObserver to dynamically add and remove that listener based on whether a portion of the tracked element remains in view - i.e. whether it has any non-zero intersection ratio, using Number.MIN_VALUE
as the threshold to avoid mishaps with 0
acting differently in different browsers.
This could also easily fall back to active scroll listeners and/or having the event listener always added and active in older browsers which don't support the requisite features.
@rjgotten I like that idea. thanks for the feedback!
One solution to lazyload large infographics is to check for 2 thresholds. [0, 1]
.
if (entry.isIntersecting) {
if (
entry.rootBounds.height < entry.boundingClientRect.height ||
entry.intersectionRatio == 1
) {
this.renderImage();
}
}
During intersection. If the viewport's height is smaller than the image's height, render the image immediately (this is the only chance you get to render a large infographic).
Otherwise (for smaller images), render then when the threshold is 1 (when they show in full in viewport).
This way we get to cater for both situations and achieve lazyloading for small and large images.
Another solution for a large infographic; set the top margin to -100% 0px 0px
which effectively turns the viewport bounds into a single horizontal lines at the bottom of the viewport. Use a zero threshold.
This way, when isIntersecting === true
the infographic touches the bottom of the screen, as soon it leaves it will fire another event with isIntersecting === false
.
Hi, is there any update on this one?
I feel there are real world cases for this as scroll-linked animations are becoming more popular. An example that comes to my mind is this trendy animation in articles (like the special articles of New York Times) where an element acts as a sticky element and it becomes fixed as you scroll the article, leaving a large element with a scroll-linked animation (scroll is linked to every animation step).
It's a nice way to represent data in a easy way (e.g. graphs that overlap as you scroll or how-to animations embedded in an article so user can control the animation with the scroll instead of a gif where the user doesn't have control over the animation). But it's impossible with the current state of IntersectionObserver. It forces us to use scroll event and calculate dimensions with getBoundingClientRect().
What you're looking for sounds like scroll-linked animations, but limited to a vertical sub-slice of the total document height; possibly tracked/expressed by a given container element.
Don't think Intersection Observer is meant as a solution to your type of scenario on its own. It's meant to call a callback function once when crossing over a particular threshold. Not for repeated calls with each pixel scrolled.
In your particular case, I would probably use a passive scroll event listener to monitor scroll offset. And then use an IntersectionObserver to dynamically add and remove that listener based on whether a portion of the tracked element remains in view - i.e. whether it has any non-zero intersection ratio, using Number.MIN_VALUE as the threshold to avoid mishaps with 0 acting differently in different browsers.
This could also easily fall back to active scroll listeners and/or having the event listener always added and active in older browsers which don't support the requisite features.
I disagree @rjgotten. The main goal of Intersection Observer was to prevent implementations with the use of every single thing you're describing here: attaching to scroll events, recalculating dimensions with getBoundingClientRect(), handling resizing etc.
While it is a little bit better because you can add/remove the listener with IntersectionObserver, implementing something that reveals at which position is the viewport or root in relation to the element larger than the viewport (either with thresholds reversing ratios and an additional option or providing that info in each observer entry, etc.) would prevent the implementation of scroll-linked animations with scroll events. The concept of thresholds is still feasible for this use case if this change is introduced. You can still pass a threshold with 100 steps (more if you need more frames for your animation or even less e.g. by using css transitions and only 4 steps like scaling or translating an element at 25%, 50%, 75% and 100%) and it would be much more efficient than having multiple scroll events and recalculating dimensions, one scroll event per animation in a single page.
As others said before, a way to check the ratio of the viewport covered by an element would be helpful for different things.
https://codesandbox.io/s/purple-breeze-6nt1o?file=/index.html
I stumbled upon the same issue just today. I gave up solving my issue by IntersectionObserver + threashold: [0, 1]
as I needed to see my element coming into the viewport with a threshold.
https://codesandbox.io/s/w3c-intersectionobserver-issues-124-6nt1o?file=/index.html
I instead used getBoundingClientRect
to check if which elements were in the viewport and then checked which one had enough threshold height to focus on.
Then you're stuck either polling or reacting to scroll events, right?
@tremby YES... but in my case it was only required by the user action (clicking on a button)
maybe you can use the both IntersectionObserver + my solution.
IntersectionObserver reacts -> start a scroll event to kick the function (when ON / turn the event OFF otherwise) -> check if target element is within your desired threshold -> then do your actions
Any news about this?
I also found myself looking into this. In a screen type with a series of dynamic components building on vertically, we needed to know when all components had been seen. "Seen" meaning the whole component had been scrolled into view.
Some components ended up being taller than the viewport depending on the size of the browser window, so I believe detecting "fully in view" turns out to be a flawed approach.
We ended up testing if the bottom of each component had come into view. There were two approaches to that that I could think of: 1) if you still want to use an IntersectionObserver, you could add a tiny 1x1 pixel helper div positioned to the bottom of each component, and observe that helper with a threshold of 1. We have a lot of components so adding this to each component wasn't ideal. 2) not use the IntersectionObserver and instead revert back to using scroll events and testing the getBoundingClientReact as @github0013 suggested :(
The above is a plug for a commercial package. I didn't "seize the opportunity and evaluate its potential" but from a glance it looks irrelevant.
As an addition to @tremby, the project/developer also has the worst website I ever saw. I would not trust the project and its capabilities by any means.
This issue was annoying and unexpected.
I agree that this issue is annoying and unexpected.
I have a situation where targets are mixed sizes, some larger than the viewport and some smaller. This has made the intersection observer overly complex to implement.
I support the idea of a second mode where the intersection ratio is calculated to represent the amount of the viewport that is covered by the target. however, even that solution does not satisfy my use case - in my particular case a process that returns the max of those two ratio calculations would be best.
I have a use case where I have a section that is very tall (say 1800px). I want a trigger for when the user scrolls past this section by 10% viewport height, i.e the section is still mostly in view but part of it is now going over the top of the viewport.
It seems that a package called "IntersectionObserver" should support this use case, but it seems that tracking this kind of inverse ratio when the object is larger than the root is just not possible?
I have a use case where I have a section that is very tall (say 1800px). I want a trigger for when the user scrolls past this section by 10% viewport height.?
If I've understood you right ("scrolls past this section" is somewhat ambiguous) how about this:
I started doing that (hidden triggers) but it quickly got very messy. I ended up switching to gsap which supported this use case.
I have also run into this issue and what I did was to calculate threshold dynamically based on window.innerHeight
and element.offsetHeight
. If element is higher than viewport, a threshold is calculated as window.innerHeight / element.offsetHeight
, or else it is just 0.5 or some other arbitrary value.
I also made an eventListener that listens for "resize" event, which deactivates old observers and creates new observers with newly calculated threshold. It works as intended, even if it feels "hacky".
Here is my composable useIntersectionObserver
I made in Vue. It accepts an array of elements. I suggest using updateObservers
with debounce. Maybe it will help someone:
import { ref, onMounted, onBeforeUnmount } from "vue";
export function useIntersectionObserver(
targets,
handler,
nonExceedingThreshold = 0.6,
exceedingThreshold = 0.5,
options = {}
) {
const observers = ref([]);
function disconectObservers() {
observers.value.forEach((observer) => {
observer.disconnect(); // Disconnect observers
});
observers.value = []; // Reset observers array
}
function calculateThreshold(sectionHeight) {
const viewportHeight = window.innerHeight;
if (sectionHeight > viewportHeight) {
return (viewportHeight / sectionHeight) * exceedingThreshold;
}
return nonExceedingThreshold;
}
function updateObservers() {
disconectObservers(); // Disconnect previous observers
targets.forEach((el) => {
const sectionHeight = el.offsetHeight;
const threshold = calculateThreshold(sectionHeight);
const observer = new IntersectionObserver(handler, {
...options,
threshold
});
observer.observe(el);
observers.value.push(observer);
});
}
onMounted(() => {
updateObservers(nonExceedingThreshold, exceedingThreshold);
});
onBeforeUnmount(() => {
disconectObservers();
});
return { updateObservers };
}
@rborowski I have also run into this issue and what I did was to calculate threshold dynamically based on
window.innerHeight
andelement.offsetHeight
.
The problem with that approach is you'd also need to register for the resize
event on the window and you'd need to create a ResizeObserver
to observe the element and know when its offsetHeight
changes.
My simple solution ensures that the content fills the viewport.
<script setup>
import { useIntersectionObserver } from '@vueuse/core'
import { onMounted, ref } from 'vue'
const items = ref([])
const loadMoreTrigger = ref(null)
const isLoading = ref(false)
const { resume } = useIntersectionObserver(
loadMoreTrigger,
([{ isIntersecting }]) => {
if (isIntersecting && !isLoading.value) {
loadMoreItems()
}
},
{
immediate: false,
root: null,
rootMargin: '0px',
threshold: 0.1,
},
)
function loadMoreItems() {
if (isLoading.value)
return
isLoading.value = true
try {
const newItems = Array.from({ length: 10 }, (_, i) => `Item ${items.value.length + i + 1}`)
items.value.push(...newItems)
}
catch (error) {
console.error('Failed to load more items:', error)
}
finally {
isLoading.value = false
}
}
function ensureContentFillsViewport() {
const trigger = loadMoreTrigger.value
if (trigger && trigger.getBoundingClientRect().top < window.innerHeight) {
loadMoreItems()
requestAnimationFrame(ensureContentFillsViewport)
}
else {
resume()
}
}
onMounted(() => {
ensureContentFillsViewport()
})
</script>
<template>
<div id="app">
<h1>Infinite Scroll Example</h1>
<div id="item-container">
<div v-for="(item, index) in items" :key="index" class="item">
{{ item }}
</div>
</div>
<!-- The load more trigger -->
<div id="load-more-trigger" ref="loadMoreTrigger">
Loading more...
</div>
</div>
</template>
<style>
#item-container {
padding: 10px;
display: flex;
flex-direction: column;
}
.item {
padding: 10px;
margin: 5px;
border: 1px solid #ddd;
}
#load-more-trigger {
text-align: center;
padding: 10px;
margin: 20px 0;
font-weight: bold;
}
</style>
My simple solution ensures that the content fills the viewport.
<script setup>
import { useIntersectionObserver } from '@vueuse/core'
import { onMounted, ref } from 'vue'
const items = ref([])
const loadMoreTrigger = ref(null)
const isLoading = ref(false)
const { resume } = useIntersectionObserver(
loadMoreTrigger,
([{ isIntersecting }]) => {
if (isIntersecting && !isLoading.value) {
loadMoreItems()
}
},
{
immediate: false,
root: null,
rootMargin: '0px',
threshold: 0.1,
},
)
function loadMoreItems() {
if (isLoading.value)
return
isLoading.value = true
try {
const newItems = Array.from({ length: 10 }, (_, i) => `Item ${items.value.length + i + 1}`)
items.value.push(...newItems)
}
catch (error) {
console.error('Failed to load more items:', error)
}
finally {
isLoading.value = false
}
}
function ensureContentFillsViewport() {
const trigger = loadMoreTrigger.value
if (trigger && trigger.getBoundingClientRect().top < window.innerHeight) {
loadMoreItems()
requestAnimationFrame(ensureContentFillsViewport)
}
else {
resume()
}
}
onMounted(() => {
ensureContentFillsViewport()
})
</script>
<template>
<div id="app">
<h1>Infinite Scroll Example</h1>
<div id="item-container">
<div v-for="(item, index) in items" :key="index" class="item">
{{ item }}
</div>
</div>
<!-- The load more trigger -->
<div id="load-more-trigger" ref="loadMoreTrigger">
Loading more...
</div>
</div>
</template>
<style>
#item-container {
padding: 10px;
display: flex;
flex-direction: column;
}
.item {
padding: 10px;
margin: 5px;
border: 1px solid #ddd;
}
#load-more-trigger {
text-align: center;
padding: 10px;
margin: 20px 0;
font-weight: bold;
}
</style>
How's the observer supposed to react for elements that are larger than the area they are displayed it? Their visible ratio may never become 1, as
ratio: visible area / total area
does not account for that. Here's a demo of that happening and preventing the observer to fire in Chrome Canary.(possibly related to #69)