9

I'm using the IntersectionObserver to add and remove classes to elements as they enter the viewport.

Instead of saying "when X% of the element is visible - add this class" I would like to say "when X% of the element is visible or when X% of the viewport is covered by the element - add this class".

I'm assuming this isn't possible? If so I think it's a bit of a flaw with the IntersectionObserver because if you have an element that's 10 times taller than the viewport it'll never count as visible unless you set the threshold to 10% or less. And when you have variable height elements, especially in a responsive design, you'll have to set the threshold to something like 0.1% to be "sure" the element will receive the class (you can never be truly sure though).

Edit: In response to Mose's reply.

Edit2: Updated with several thresholds to force it to calculate percentOfViewport more often. Still not ideal.

var observer = new IntersectionObserver(function (entries) {
 entries.forEach(function (entry) {
  var entryBCR = entry.target.getBoundingClientRect();
  var percentOfViewport = ((entryBCR.width * entryBCR.height) * entry.intersectionRatio) / ((window.innerWidth * window.innerHeight) / 100);

  console.log(entry.target.id + ' covers ' + percentOfViewport + '% of the viewport and is ' + (entry.intersectionRatio * 100) + '% visible');

  if (entry.intersectionRatio > 0.25) {
   entry.target.style.background = 'red';
  }
  else if (percentOfViewport > 50) {
   entry.target.style.background = 'green';
  }
  else {
   entry.target.style.background = 'lightgray';
  }
 });
}, {threshold: [0.025, 0.05, 0.075, 0.1, 0.25]});

document.querySelectorAll('#header, #tall-content').forEach(function (el) {
 observer.observe(el);
});
#header {background: lightgray; min-height: 200px}
#tall-content {background: lightgray; min-height: 2000px}
<header id="header"><h1>Site header</h1></header>
<section id="tall-content">I'm a super tall section. Depending on your resolution the IntersectionObserver will never consider this element visible and thus the percentOfViewport isn't re-calculated.</section>
powerbuoy
  • 12,460
  • 7
  • 48
  • 78
  • Ya it's super annoying that IO doesn't support this case - especially considering it feels like it really ought to just work. It just needs an 'opposite' mode. And what's worse is you can sort of get it working but then as your page height increases it'll suddenly break because math. – Simon_Weaver May 24 '22 at 01:06

4 Answers4

4

What you need to do is give each element a different threshold. If the element is shorter than the default threshold (in relation to the window) then the default threshold works fine, but if it's taller you need a unique threshold for that element.

Say you want to trigger elements that are either:

  1. 50% visible or
  2. Covering 50% of the screen

Then you need to check:

  1. If the element is shorter than 50% of the window you can use option 1
  2. If the element is taller than 50% of the window you need to give it a threshold that is the windows' height divided by the height of the element multiplied by the threshold (50%):
function doTheThing (el) {
    el.classList.add('in-view');
}

const threshold = 0.5;

document.querySelectorAll('section').forEach(el => {
    const elHeight = el.getBoundingClientRect().height;
    var th = threshold;

    // The element is too tall to ever hit the threshold - change threshold
    if (elHeight > (window.innerHeight * threshold)) {
        th = ((window.innerHeight * threshold) / elHeight) * threshold;
    }

    new IntersectionObserver(iEls => iEls.forEach(iEl => doTheThing(iEl)), {threshold: th}).observe(el);
});
powerbuoy
  • 12,460
  • 7
  • 48
  • 78
1
let optionsViewPort = {
  root: document.querySelector('#viewport'), // assuming the viewport has an id "viewport"
  rootMargin: '0px',
  threshold: 1.0
}

let observerViewport = new IntersectionObserver(callback, optionsViewPort);
observerViewPort.observe(target);

In callback, given the size of the viewport, given the size of the element, given the % of overlapping, you can calculate the percent overlapped in viewport:

  const percentViewPort = viewPortSquarePixel/100;
  const percentOverlapped = (targetSquarePixel * percent ) / percentViewPort;

Example:

const target = document.querySelector('#target');
const viewport = document.querySelector('#viewport');
const optionsViewPort = {
  root: viewport, // assuming the viewport has an id "viewport"
  rootMargin: '0px',
  threshold: 1.0
}

let callback = (entries, observer) => { 
  entries.forEach(entry => {  
    const percentViewPort = (parseInt(getComputedStyle(viewport).width) * parseInt(getComputedStyle(viewport).height))/100;    
    const percentOverlapped = ((parseInt(getComputedStyle(target).width) * parseInt(getComputedStyle(viewport).height)) * entry.intersectionRatio) / percentViewPort;
    console.log("% viewport overlapped", percentOverlapped);
    console.log("% of element in viewport", entry.intersectionRatio*100);
    // Put here the code to evaluate percentOverlapped and target visibility to apply the desired class
  });
    
};

let observerViewport = new IntersectionObserver(callback, optionsViewPort);
observerViewport.observe(target);
#viewport {
  width: 900px;
  height: 900px;
  background: yellow;
  position: relative;
}

#target {
  position: absolute;
  left: 860px;
  width: 100px;
  height: 100px;
  z-index: 99;
  background-color: red;
}
<div id="viewport">
  <div id="target" />
</div>

Alternate math to calculate overlap area/percent of target with getBoundingClientRect()

const target = document.querySelector('#target');
const viewport = document.querySelector('#viewport');

const rect1 = viewport.getBoundingClientRect();
const rect2 = target.getBoundingClientRect();

const rect1Area = rect1.width * rect1.height;
const rect2Area = rect2.width * rect2.height;

const x_overlap = Math.max(0, Math.min(rect1.right, rect2.right) - Math.max(rect1.left, rect2.left));
const y_overlap = Math.max(0, Math.min(rect1.bottom, rect2.bottom) - Math.max(rect1.top, rect2.top));

const overlapArea = x_overlap * y_overlap;
const overlapPercentOfTarget = overlapArea/(rect2Area/100);

console.log("OVERLAP AREA", overlapArea);
console.log("TARGET VISIBILITY %", overlapPercentOfTarget);
#viewport {
  width: 900px;
  height: 900px;
  background: yellow;
  position: relative;
}

#target {
  position: absolute;
  left: 860px;
  width: 100px;
  height: 100px;
  z-index: 99;
  background-color: red;
}
<div id="viewport">
  <div id="target" />
</div>
Mosè Raguzzini
  • 15,399
  • 1
  • 31
  • 43
  • The problem with this is that the IntersectionObserver only triggers once when the element is considered visible and once when it's not. So the "percent of viewport covered" value isn't updated as you scroll. Hence, if my threshold is 25% (which is what I'd like) and my super tall element never reaches the threshold the percent of viewport is never re-calculated... So I _still_ need to set the threshold to something so low as to be relatively sure it will always trigger, and even if it does trigger then the element might not cover much of the viewport. – powerbuoy Sep 04 '19 at 12:34
  • If you reduce the size of your viewport with my snippet, you will notice that element is not *visible* (you have to scroll to right), although the intersection is detected. if you can reproduce your issue in a snippet I can be more accurate. – Mosè Raguzzini Sep 04 '19 at 12:36
  • I just realized that by passing in several thresholds I can make it trigger more often. It's far from perfect as it's still an arbitrary number that can fail under certain circumstances so I'm not happy with the solution. – powerbuoy Sep 04 '19 at 13:01
  • Why do not pass 0 so it is always triggered, then move the logic in the callback ? – Mosè Raguzzini Sep 04 '19 at 13:08
  • Because with 0 it'll still just trigger once, as soon as a single pixel of the element is visible. – powerbuoy Sep 04 '19 at 16:59
  • Once triggered, you can instantiate immediatly a new observer. Not very resource-wise, but it should work – Mosè Raguzzini Sep 05 '19 at 07:36
  • I'm not sure I follow? – powerbuoy Sep 05 '19 at 08:52
  • As you see, when instantiating an observer, if the observer meet the threshold, it is immediately triggered. IMHO it is better to rely on other techniques, like taking advantage of `getBoundingClientRect()` and apply some simple math to it. – Mosè Raguzzini Sep 05 '19 at 11:44
  • I've added a snippet with calculations relying on `getBoundingClientRect()` that you can trigger anytime (onScroll, setInterval or whatever event) – Mosè Raguzzini Sep 05 '19 at 12:07
  • Ok thanks, the whole point is to not use the scroll event though. I already have a working "scrollspy" that relies on the scroll event and boundingClientRect. Was hoping to replace it with IntersectionObserver but clearly I can't. – powerbuoy Sep 06 '19 at 14:34
  • IntersectionObserver purpose is to limit events emitted avoiding "onScroll" event trigger, if you want to check intersections you'll always have to determine "when". "When" will be determined by the event matching the user input or a timing. Even in a game, where collisions are critical, you do it in the game loop. If you have a method that "move" your asset (like a game loop) there aren't other ways that I'm aware of. – Mosè Raguzzini Sep 06 '19 at 14:52
1

Here is a solution using ResizeObserver that fires the callback when the element is > ratio visible or when it fully covers the viewport.

/**
 * Detect when an element is > ratio visible, or when it fully
 * covers the viewport.
 * Callback is called only once.
 * Only works for height / y scrolling.
 */
export function onIntersection(
  elem: HTMLElement,
  ratio: number = 0.5,
  callback: () => void
) {
  // This helper is needed because IntersectionObserver doesn't have 
  // an easy mode for when the elem is taller than the viewport. 
  // It uses ResizeObserver to re-observe intersection when needed.

  const maxRatio = window.innerHeight / elem.getBoundingClientRect().height;

  const threshold = maxRatio < ratio ? 0.99 * maxRatio : ratio;
  const intersectionObserver = new IntersectionObserver(
    (entries) => {
      const entry = entries[0];
      if (entry.isIntersecting && entry.intersectionRatio >= threshold) {
        disconnect();
        callback();
      }
    },
    { threshold: [threshold] }
  );

  const resizeObserver = new ResizeObserver(() => {
    const diff =
      maxRatio - window.innerHeight / elem.getBoundingClientRect().height;
    if (Math.abs(diff) > 0.0001) {
      disconnect();
      onIntersection(elem, ratio, callback);
    }
  });

  const disconnect = () => {
    intersectionObserver.disconnect();
    resizeObserver.disconnect();
  };

  resizeObserver.observe(elem);
  intersectionObserver.observe(elem);
}

Simon Epskamp
  • 8,813
  • 3
  • 53
  • 58
0

Create two intersectionObservers:

  1. Checks if 25% of the element is visible:

        run25Percent() {
    // if 25% of the element is visible, callback is triggered
    const options = {
        threshold: 0.25,
    };
    
    const changeBackground = (entries) => {
        entries.forEach((entry) => {
            if (
                // check if entry is intersecting (moving into root),
                entry.isIntersecting &&
                // check if entry moving into root has a intersectionRatio of 25%
                entry.intersectionRatio.toFixed(2) == 0.25
            ) {
                // change Backgroundcolor here
            }
        });
    };
    const observer = new IntersectionObserver(
        changeBackground,
        options
    );
    document.querySelectorAll('#header, #tall-content').forEach(function 
    (el) {
    observer.observe(el);
    });
    

    }

  2. Checks if 50% of the screen is covered

    run50Percent() {
    //transforms the root into a thin line horizonatlly crossing the center of the screen
    const options = {
        rootMargin: "-49.9% 0px -49.9% 0px",
    };
    
    const changeBackground = (entries) => {
        entries.forEach((entry) => {
            //checks if element is intersecting
            if (entry.isIntersecting) {
             //change background color here
            }
        });
    };
    const observer = new IntersectionObserver(
        changeBackground,
        options
    );
    document.querySelectorAll('#header, #tall-content').forEach((el) => 
    {
        observer.observe(el);
    }
    }
    
bene
  • 11
  • 4