0

I would like to implement the 4 callbacks of gsap's ScrollTrigger: onEnter, onEnterBack, onLeave, and onLeaveBack, with IntersectionObserver.

https://greensock.com/docs/v3/Plugins/ScrollTrigger

(ScrollTrigger notifies 4 events because it takes into account the scroll direction in addition to visible and invisible state transitions).

function createScrollTrigger({
  element,
  onStateChange,
  root,
  marginTop = "0px",
  marginBottom = "0px"
}) {
  let state = undefined;
  const getDirection = ({ rootBounds, boundingClientRect }) => {
    return boundingClientRect.bottom < rootBounds.top
      ? "leave"
      : boundingClientRect.top > rootBounds.bottom
      ? "beforeEnter"
      : "enter";
  };
  const observer = new IntersectionObserver(
    (entries) => {
      const entry = entries[0];
      const oldState = state;
      const newState = getDirection(entry);
      if (newState !== oldState) {
        state = newState;
        onStateChange(newState, oldState);
      }
    },
    {
      root,
      rootMargin: `${marginTop} 0px ${marginBottom} 0px`,
      threshold: 0
    }
  );
  observer.observe(element);
  return () => {
    observer.disconnect();
  };
}

createScrollTrigger({
  element: document.querySelector(".box"),
  root: document,
  marginTop: "-20%",
  marginBottom: "-20%",
  onStateChange: (newState, oldState) => {
    console.log({ newState, oldState });

    if (newState === "enter") {
      if (oldState === "beforeEnter") {
        console.log("→ onEnter()");
      } else {
        console.log("→ onEnterBack()");
      }
    } else {
      if (newState === "leave") {
        console.log("→ onLeave()");
      } else {
        console.log("→ onLeaveBack()");
      }
    }
  }
});
body {
  font-family: sans-serif;
}

.spacer {
  height: 400vh;
  background-color: #ffffff;
  opacity: 0.8;
  background-image: repeating-linear-gradient(
      45deg,
      #c4c4c4 25%,
      transparent 25%,
      transparent 75%,
      #c4c4c4 75%,
      #c4c4c4
    ),
    repeating-linear-gradient(
      45deg,
      #c4c4c4 25%,
      #ffffff 25%,
      #ffffff 75%,
      #c4c4c4 75%,
      #c4c4c4
    );
  background-position: 0 0, 10px 10px;
  background-size: 20px 20px;
  background-repeat: repeat;
}

.container {
  display: flex;
  align-items: center;
  justify-content: center;
}

.box {
  background: #000;
  width: 50vw;
}
.box::before {
  content: "";
  display: block;
  padding-top: 100%;
}

.root-margin-overlay {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  box-sizing: border-box;
  border: solid rgba(0, 0, 0, 0.5);
  border-width: 20vh 0;
  pointer-events: none;
}

.nav {
  position: fixed;
  top: 0;
  left: 0;
}

.nav li {
  color: #fff;
  text-decoration: underline;
  cursor: pointer;
}
<!DOCTYPE html>
<html>
  <head>
    <title>Sandbox</title>
    <meta charset="UTF-8" />
  </head>

  <body>
    <div class="spacer container">
      <div class="box"></div>
    </div>
    <div class="root-margin-overlay"></div>
    <nav class="nav">
      <button onclick="window.scrollTo(0, 0)">Go to top of page</button>
      <button onclick="window.scrollTo(0, document.body.scrollHeight)">
        Go to end of page
      </button>
    </nav>
  </body>
</html>

It almost works, but there is one problem.

How to reproduce:

  1. Scroll back to the top of the page
  2. Press the "Go to end of page" button
  3. Scroll back to where the element is visible
  4. Application state is not updated and incorrect callback onEnter() is called. onEnterBack() should be called.

IntersectionObserver notifies state transitions between invisible and visible, but not from invisible (element is above viewport) to invisible (element is below viewport).

Therefore, it is difficult for me to reproduce the ScrollTrigger's callbacks in IntersectionObserver.

I think just knowing if an element is visible or not would be of limited use, but is there any workaround?

Is there any use for IntersectionObserver beyond stopping requestAnimationFrame() when it is off-screen or triggering an animation only once?

Edit: What I end up wanting to do is an animation that is triggered by scrolling, but is replayable, such as the following:

Start animation mid-viewport, but reset it offscreen

However, I would like to do this without installing gsap & ScrollTrigger if possible; ScrollTrigger is great, but when using other animation libraries like framer-motion or simply with CSS Transition, it is too much.

Can I make good use of IntersectionObserver for this, or should I use Scroll event and Element.getBoundingClinetRect()?

1 Answers1

0

I am not sure if this will work in all major browsers, but a possible workaround is to simply use the last scroll direction.

function createScrollTrigger({
  element,
  root,
  marginTop = '0px',
  marginBottom = '0px',
  onEnter,
  onEnterBack,
  onLeave,
  onLeaveBack,
}) {
  const target = root == null || root === document ? window : root
  const getScrollPosition =
    target === window
      ? () => window.pageYOffset || document.documentElement.scrollTop
      : () => root.scrollTop
  const observer = new IntersectionObserver(
    (entries) => {
      onEntryUpdate(entries[0])
    },
    {
      root,
      rootMargin: `${marginTop} 0px ${marginBottom} 0px`,
      threshold: 0,
    }
  )

  let lastPosition = getScrollPosition()
  let lastDirection = 0
  let didInit = false

  target.addEventListener('scroll', onScroll)
  observer.observe(element)

  return () => {
    target.removeEventListener('scroll', onScroll)
    observer.disconnect()
  }

  function onScroll() {
    const position = getScrollPosition()
    if (position > lastPosition) {
      lastDirection = 1
    } else if (position < lastPosition) {
      lastDirection = -1
    } else {
      lastDirection = 0
    }
    lastPosition = position
  }

  function onEntryUpdate(entry) {
    if (!didInit) {
      didInit = true
      notifyInitialState(entry)
      return
    }

    if (entry.isIntersecting) {
      if (lastDirection >= 0) {
        onEnter?.()
      } else {
        onEnterBack?.()
      }
    } else {
      if (lastDirection >= 0) {
        onLeave?.()
      } else {
        onLeaveBack?.()
      }
    }
  }

  // Determine inital callbacks
  function notifyInitialState(entry) {
    const { rootBounds, boundingClientRect } = entry
    if (boundingClientRect.bottom < rootBounds.top) {
      // element is below the viewport
      onLeave?.()
      return
    }
    if (boundingClientRect.top > rootBounds.bottom) {
      // element is above the viewport
      return
    }

    // element is in the viewport
    onEnter?.()
  }
}

const createCallback = (message) => {
  return () => {
    console.log(message)
    document.querySelectorAll('.last-event').forEach((label) => {
      label.textContent = message
    })
  }
}

createScrollTrigger({
  element: document.querySelector('.box'),
  root: document,
  marginTop: '-50px',
  marginBottom: '-50px',
  onEnter: createCallback('onEnter()'),
  onEnterBack: createCallback('onEnterBack()'),
  onLeave: createCallback('onLeave()'),
  onLeaveBack: createCallback('onLeaveBack()'),
})
body {
  font-family: sans-serif;
}

.spacer {
  height: 400vh;
  background-color: #ffffff;
  opacity: 0.8;
  background-image: repeating-linear-gradient(
      45deg,
      #c4c4c4 25%,
      transparent 25%,
      transparent 75%,
      #c4c4c4 75%,
      #c4c4c4
    ),
    repeating-linear-gradient(
      45deg,
      #c4c4c4 25%,
      #ffffff 25%,
      #ffffff 75%,
      #c4c4c4 75%,
      #c4c4c4
    );
  background-position: 0 0, 10px 10px;
  background-size: 20px 20px;
  background-repeat: repeat;
}

.container {
  display: flex;
  align-items: center;
  justify-content: center;
}

.box {
  position: relative;
  background: #000;
  width: 50vw;
}
.box::before {
  content: "";
  display: block;
  padding-top: 100%;
}

.last-event {
  position: absolute;
  left: 0;
  width: 100%;
  color: #fff;
  text-align: center;
}
.last-event:first-child {
  top: 0;
}
.last-event:last-child {
  bottom: 0;
}

.root-margin-overlay {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  box-sizing: border-box;
  border: solid rgba(0, 0, 0, 0.5);
  border-width: 50px 0;
  pointer-events: none;
}

.nav {
  position: fixed;
  top: 0;
  left: 0;
}

.nav li {
  color: #fff;
  text-decoration: underline;
  cursor: pointer;
}
<!DOCTYPE html>
<html>
  <head>
    <title>Sandbox</title>
    <meta charset="UTF-8" />
  </head>

  <body>
    <div class="spacer container">
      <div class="box">
        <p class="last-event"></p>
        <p class="last-event"></p>
      </div>
    </div>
    <div class="root-margin-overlay"></div>
    <nav class="nav">
      <button onclick="window.scrollTo(0, 0)">Go to top of page</button>
      <button onclick="window.scrollTo(0, document.body.scrollHeight)">
        Go to end of page
      </button>
    </nav>
    <script src="src/index.js"></script>
  </body>
</html>

The following issues remain.

IntersectionObserver notifies state transitions between invisible and visible, but not from invisible (element is above viewport) to invisible (element is below viewport).

However, onEnter / onEnterBack will be called correctly. At this point, you have a chance to redo the animation reset you wanted to do in onLeave / onLeaveBack. The lack of callbacks during off-screen may not be a major problem for some applications.

Edit: This is not a good solution. I found that resetting the animation (hiding the element) on onEnter / onEnterBack is too late and causes an undesired flash...