.boxes
  - const COUNT = 10
  - let b = 0
  while b < COUNT
    .box= b + 1
    - b++
  .controls
    button.next Prev
    button.prev Next
View Compiled
*
  box-sizing border-box
  
body
  display grid
  place-items center
  min-height 100vh
  padding 0
  margin 0
  overflow-y hidden
  background hsl(0, 0%, 10%)
  
.controls
  position absolute
  top 50%
  left 50%
  transform translate(-50%, -50%)
  z-index 200
  display flex
  justify-content space-between
  width 500px
  max-width 90vw
  
button
  height 48px
  width 48px
  border-radius 50%
  
.boxes
  height 100vh
  width 100%
  overflow hidden
  position absolute
  
.box
  position absolute
  top 50%
  left 50%
  height 25vmin
  width 25vmin
  display grid
  place-items center
  font-size 5vmin
  font-family sans-serif
  transform translate(-50%, -50%)
  
  &:nth-of-type(odd)
    background hsl(90, 80%, 70%)
  
  &:nth-of-type(even)
    background hsl(90, 80%, 40%)
View Compiled
import gsap from 'https://cdn.skypack.dev/gsap'
import ScrollTrigger from 'https://cdn.skypack.dev/gsap/ScrollTrigger'

gsap.registerPlugin(ScrollTrigger)


const STAGGER = 0.2
const DURATION = 1
const OFFSET = 0
const BOXES = gsap.utils.toArray('.box')

const LOOP = gsap.timeline({
  paused: true,
  repeat: -1,
  ease: 'none'
})

const SHIFTS = [...BOXES, ...BOXES, ...BOXES]

SHIFTS.forEach((BOX, index) => {
  const BOX_TL = gsap
    .timeline()
    .fromTo(
      BOX,
      {
        xPercent: 100,
      },
      {
        xPercent: -200,
        duration: 1,
        ease: 'none',
        immediateRender: false,
      }, 0
    )
    .fromTo(
      BOX, {
        opacity: 0,
      }, {
        opacity: 1,
        duration: 0.25,
        repeat: 1,
        repeatDelay: 0.5,
        immediateRender: false,
        ease: 'none',
        yoyo: true,
      }, 0)
    .fromTo(
      BOX,
      {
        scale: 0,
      },
      {
        scale: 1,
        repeat: 1,
        zIndex: BOXES.length,
        yoyo: true,
        ease: 'none',
        duration: 0.5,
        immediateRender: false,
      },
      0
    )
  LOOP.add(BOX_TL, index * STAGGER)
})

const CYCLE_DURATION = STAGGER * BOXES.length
const START_TIME = CYCLE_DURATION + (DURATION * 0.5) + OFFSET
const END_TIME = START_TIME + CYCLE_DURATION

const LOOP_HEAD = gsap.fromTo(LOOP, {
  totalTime: START_TIME,
},
{
  totalTime: `+=${CYCLE_DURATION}`,
  duration: 1,
  ease: 'none',
  repeat: -1,
  paused: true,
})


const PLAYHEAD = {
  position: 0
}

const POSITION_WRAP = gsap.utils.wrap(0, LOOP_HEAD.duration())

const SCRUB = gsap.to(PLAYHEAD, {
  position: 0,
  onUpdate: () => {
    LOOP_HEAD.totalTime(POSITION_WRAP(PLAYHEAD.position))
  },
  paused: true,
  duration: 0.25,
  ease: 'power3',
})

let iteration = 0
const TRIGGER = ScrollTrigger.create({
  start: 0,
  end: '+=2000',
  horizontal: false,
  pin: '.boxes',
  onUpdate: self => {
    const SCROLL = self.scroll()
    if (SCROLL > self.end - 1) {
      // Go forwards in time
      WRAP(1, 1)
    } else if (SCROLL < 1 && self.direction < 0) {
      // Go backwards in time
      WRAP(-1, self.end - 1)
    } else {
      const NEW_POS = (iteration + self.progress) * LOOP_HEAD.duration()
      SCRUB.vars.position = NEW_POS
      SCRUB.invalidate().restart() 
    }
  }
})

const WRAP = (iterationDelta, scrollTo) => {
  iteration += iterationDelta
  TRIGGER.scroll(scrollTo)
  TRIGGER.update()
}

const SNAP = gsap.utils.snap(1 / BOXES.length)

const progressToScroll = progress => gsap.utils.clamp(1, TRIGGER.end - 1, gsap.utils.wrap(0, 1, progress) * TRIGGER.end)

const scrollToPosition = position => {
  const SNAP_POS = SNAP(position)
  const PROGRESS = (SNAP_POS - LOOP_HEAD.duration() * iteration) / LOOP_HEAD.duration()
  const SCROLL = progressToScroll(PROGRESS)
  if (PROGRESS >= 1 || PROGRESS < 0) return WRAP(Math.floor(PROGRESS), SCROLL)
  TRIGGER.scroll(SCROLL)
}


ScrollTrigger.addEventListener('scrollEnd', () => scrollToPosition(SCRUB.vars.position))


const NEXT = () => scrollToPosition(SCRUB.vars.position - (1 / BOXES.length))
const PREV = () => scrollToPosition(SCRUB.vars.position + (1 / BOXES.length))

document.addEventListener('keydown', event => {
  if (event.keyCode === 37 || event.keyCode === 65) NEXT()
  if (event.keyCode === 39 || event.keyCode === 68) PREV()
})

document.querySelector('.next').addEventListener('click', NEXT)
document.querySelector('.prev').addEventListener('click', PREV)
View Compiled
Run Pen

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

This Pen doesn't use any external JavaScript resources.