<body>
  <div class="wrapper">
    <div id="targetImage" class="image"></div>
    <p id="targetTitle" class="title">Hello World!</p>
    <p id="targetCaption" class="caption">
      Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
    </p>
  </div>
</body>
body {
  font-family: -apple-system, BlinkMacSystemFont, Hiragino Sans, '游ゴシック体', YuGothic, 'Yu Gothic Medium', Meiryo, sans-serif;
}

.wrapper {
  width: 100%;
  max-width: 400px;
  margin: 0 auto;
  padding: 800px 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  
  > .image {
    display: block;
    width: 400px;
    height: 400px;
    margin-bottom: 24px;
    background: url('https://images.unsplash.com/photo-1548247416-ec66f4900b2e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=800&q=80');
    background-size: cover;
  }
  
  > .title {
    font-size: 32px;
    font-weight: 700;
    line-height: 1;
    margin-bottom: 16px;
  }
  
  > .caption {
    font-size: 18px;
    text-align: center;
    line-height: 1.6;
    color: gray;
  }
}
View Compiled
gsap.defaults({
  duration: 0.1,
  ease: 'none'
})

class TargetProps {
  constructor(target) {
    this.el = target
    this.y = target.offsetTop
    this.height = target.clientHeight
    this.top = this.y - window.innerHeight
    this.bottom = this.y + this.height
  }
}

window.addEventListener('load', () => {
  const image = new TargetProps(document.getElementById('targetImage'))
  const title = new TargetProps(document.getElementById('targetTitle'))
  const caption = new TargetProps(document.getElementById('targetCaption'))
  let scrollAmount = window.pageYOffset
  let isRafActive = false
  
  function calculateRatio(top, bottom, scroll) {
    const topAdjustNum = top * ((bottom - scroll) / bottom)
    return (bottom - scroll + topAdjustNum) / bottom
  }

  function updatePosition(target, ratio, distance) {
    if (0 < ratio && ratio < 1) {
      gsap.to(target, {
        y: distance - distance * ratio
      })
    }
  }

  const ratio = {
    image: calculateRatio(image.top, image.bottom, scrollAmount),
    title: calculateRatio(title.top, title.bottom, scrollAmount),
    caption: calculateRatio(caption.top, caption.bottom, scrollAmount),
  }

  // Set target position when page load
  updatePosition(image.el, ratio.image, 200)
  updatePosition(title.el, ratio.title, -320)
  updatePosition(caption.el, ratio.caption, 40)

  
  window.addEventListener('scroll', throttle(16, () => {
    scrollAmount = window.pageYOffset
    ratio.image = calculateRatio(image.top, image.bottom, scrollAmount)
    ratio.title = calculateRatio(title.top, title.bottom, scrollAmount)
    ratio.caption = calculateRatio(caption.top, caption.bottom, scrollAmount)

    if (!isRafActive) {
      isRafActive = true
      requestAnimationFrame(() => {
        updatePosition(image.el, ratio.image, 200)
        updatePosition(title.el, ratio.title, -320)
        updatePosition(caption.el, ratio.caption, 40)
        isRafActive = false
      })
    }
  }), {passive: true})
})
View Compiled

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://cdnjs.cloudflare.com/ajax/libs/gsap/3.0.2/gsap.min.js
  2. https://cdn.jsdelivr.net/npm/throttle-debounce@2.1.0/dist/index.cjs.min.js