powerbuoy
powerbuoy

Reputation: 12848

Determine how much of the viewport is covered by element (IntersectionObserver)

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>

Upvotes: 10

Views: 9590

Answers (4)

bene
bene

Reputation: 11

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);
    }
    }
    

Upvotes: 0

Simon Epskamp
Simon Epskamp

Reputation: 9996

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);
}

Upvotes: 1

powerbuoy
powerbuoy

Reputation: 12848

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);
});

Upvotes: 4

Mos&#232; Raguzzini
Mos&#232; Raguzzini

Reputation: 15851

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>

Upvotes: 1

Related Questions