-
  const COVERS = [
    "https://i.scdn.co/image/ab67616d00001e020ecc8c4fd215d9eb83cbfdb3",
    "https://i.scdn.co/image/ab67616d00001e02d9194aa18fa4c9362b47464f",
    "https://i.scdn.co/image/ab67616d00001e02a7ea08ab3914c5fb2084a8ac",
    "https://i.scdn.co/image/ab67616d00001e0213ca80c3035333e5a6fcea59",
    "https://i.scdn.co/image/ab67616d00001e02df04e6071763615d44643725",
    "https://i.scdn.co/image/ab67616d00001e0239c7302c04f8d06f60e14403",
    "https://i.scdn.co/image/ab67616d00001e021c0bcf8b536295438d26c70d",
    "https://i.scdn.co/image/ab67616d00001e029bbd79106e510d13a9a5ec33",
    "https://i.scdn.co/image/ab67616d00001e021d97ca7376f835055f828139",
    "https://www.udiscovermusic.com/wp-content/uploads/2015/10/Kanye-West-Yeezus.jpg",
  ]

.boxes
  - const COUNT = 10
  - let b = 0
  while b < COUNT
    .box(style=`--src: url(${COVERS[b]})`)
      span= b + 1
      img(src=COVERS[b])
    - b++
  .controls
    button.next
      span Previous album
      svg(viewBox="0 0 448 512" width="100" title="Previous Album")
        path(d="M424.4 214.7L72.4 6.6C43.8-10.3 0 6.1 0 47.9V464c0 37.5 40.7 60.1 72.4 41.3l352-208c31.4-18.5 31.5-64.1 0-82.6z")
    button.prev
      span Next album
      svg(viewBox="0 0 448 512" width="100" title="Next Album")
        path(d="M424.4 214.7L72.4 6.6C43.8-10.3 0 6.1 0 47.9V464c0 37.5 40.7 60.1 72.4 41.3l352-208c31.4-18.5 31.5-64.1 0-82.6z")
svg.scroll-icon(viewBox="0 0 24 24")
  path(fill="currentColor" d="M20 6H23L19 2L15 6H18V18H15L19 22L23 18H20V6M9 3.09C11.83 3.57 14 6.04 14 9H9V3.09M14 11V15C14 18.3 11.3 21 8 21S2 18.3 2 15V11H14M7 9H2C2 6.04 4.17 3.57 7 3.09V9Z")
.drag-proxy
View Compiled
*
  box-sizing border-box

:root
  --bg hsl(0, 0%, 10%)
  --min-size 200px

body
  display grid
  place-items center
  min-height 100vh
  padding 0
  margin 0
  overflow-y hidden
  background var(--bg)

.drag-proxy
  visibility hidden
  position absolute

.controls
  position absolute
  top calc(50% + clamp(var(--min-size), 20vmin, 20vmin))
  left 50%
  transform translate(-50%, -50%) scale(1.5)
  display flex
  justify-content space-between
  min-width var(--min-size)
  height 44px
  width 20vmin
  z-index 300

button
  height 48px
  width 48px
  border-radius 50%
  position absolute
  top 0%
  outline transparent
  cursor pointer
  background none
  appearance none
  border 0
  transition transform 0.1s
  transform translate(0, calc(var(--y, 0)))


  &:before
    border 2px solid hsl(0, 0%, 90%)
    background linear-gradient(hsla(0, 0%, 80%, 0.65), hsl(0, 0%, 0%)) hsl(0, 0%, 0%)
    content ''
    box-sizing border-box
    position absolute
    top 50%
    left 50%
    transform translate(-50%, -50%)
    height 80%
    width 80%
    border-radius 50%

  &:active:before
    background linear-gradient(hsl(0, 0%, 0%), hsla(0, 0%, 80%, 0.65)) hsl(0, 0%, 0%)

  &:nth-of-type(1)
    right 100%

  &:nth-of-type(2)
    left 100%

button span
  position absolute
  width 1px
  height 1px
  padding 0
  margin -1px
  overflow hidden
  clip rect(0, 0, 0, 0)
  white-space nowrap
  border-width 0

button:hover
  --y -5%


button svg
  position absolute
  top 50%
  left 50%
  transform translate(-50%, -50%) rotate(0deg) translate(2%, 0)
  height 30%
  fill hsl(0, 0%, 90%)

button:nth-of-type(1) svg
  transform translate(-50%, -50%) rotate(180deg) translate(2%, 0)

.scroll-icon
  height 30px
  position fixed
  top 1rem
  right 1rem
  color hsl(0, 0%, 90%)
  animation action 4s infinite

@keyframes action
  0%, 25%, 50%, 100%
    transform translate(0, 0)
  12.5%, 37.5%
    transform translate(0, 25%)

.boxes
  height 100vh
  width 100%
  overflow hidden
  position absolute
  transform-style preserve-3d
  perspective 800px
  touch-action none

.box
  transform-style preserve-3d
  position absolute
  top 50%
  left 50%
  height 20vmin
  width 20vmin
  min-height var(--min-size)
  min-width var(--min-size)
  display none

  &:after
    content ''
    position absolute
    top 50%
    left 50%
    height 100%
    width 100%
    background-image var(--src)
    background-size cover
    transform translate(-50%, -50%) rotate(180deg) translate(0, -100%) translate(0, -0.5vmin)
    opacity 0.75

  &:before
    content ''
    position absolute
    top 50%
    left 50%
    height 100%
    width 100%
    background linear-gradient(var(--bg) 50%, transparent)
    transform translate(-50%, -50%) rotate(180deg) translate(0, -100%) translate(0, -0.5vmin) scale(1.01)
    z-index 2


  img
    position absolute
    height 100%
    width 100%
    top 0
    left 0
    object-fit cover


  &:nth-of-type(odd)
    background hsl(90, 80%, 70%)

  &:nth-of-type(even)
    background hsl(90, 80%, 40%)

@supports(-webkit-box-reflect: below)
  .box
    -webkit-box-reflect below 0.5vmin linear-gradient(transparent 0 50%, white 100%)

    &:after
    &:before
      display none
View Compiled
import gsap from 'https://cdn.skypack.dev/gsap@3.7.0'
import ScrollTrigger from 'https://cdn.skypack.dev/gsap@3.7.0/ScrollTrigger'
import Draggable from 'https://cdn.skypack.dev/gsap@3.7.0/Draggable'

gsap.registerPlugin(ScrollTrigger)
gsap.registerPlugin(Draggable)

gsap.set('.box', {
  yPercent: -50,
})

const STAGGER = 0.1
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()
    .set(BOX, {
      xPercent: 250,
      rotateY: -50,
      opacity: 0,
      scale: 0.5,
    })
    // Opacity && Scale
    .to(
      BOX,
      {
        opacity: 1,
        scale: 1,
        duration: 0.1,
      },
      0
    )
    .to(
      BOX,
      {
        opacity: 0,
        scale: 0.5,
        duration: 0.1,
      },
      0.9
    )
    // Panning
    .fromTo(
      BOX,
      {
        xPercent: 250,
      },
      {
        xPercent: -350,
        duration: 1,
        immediateRender: false,
        ease: 'power1.inOut',
      },
      0
    )
    // Rotations
    .fromTo(
      BOX,
      {
        rotateY: -50,
      },
      {
        rotateY: 50,
        immediateRender: false,
        duration: 1,
        ease: 'power4.inOut',
      },
      0
    )
    // Scale && Z
    .to(
      BOX,
      {
        z: 100,
        scale: 1.25,
        duration: 0.1,
        repeat: 1,
        yoyo: true,
      },
      0.4
    )
    .fromTo(
      BOX,
      {
        zIndex: 1,
      },
      {
        zIndex: BOXES.length,
        repeat: 1,
        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 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.code === 'ArrowLeft' || event.code === 'KeyA') NEXT()
  if (event.code === 'ArrowRight' || event.code === 'KeyD') PREV()
})

document.querySelector('.boxes').addEventListener('click', e => {
  const BOX = e.target.closest('.box')
  if (BOX) {
    let TARGET = BOXES.indexOf(BOX)
    let CURRENT = gsap.utils.wrap(
      0,
      BOXES.length,
      Math.floor(BOXES.length * SCRUB.vars.position)
    )
    let BUMP = TARGET - CURRENT
    if (TARGET > CURRENT && TARGET - CURRENT > BOXES.length * 0.5) {
      BUMP = (BOXES.length - BUMP) * -1
    }
    if (CURRENT > TARGET && CURRENT - TARGET > BOXES.length * 0.5) {
      BUMP = BOXES.length + BUMP
    }
    scrollToPosition(SCRUB.vars.position + BUMP * (1 / BOXES.length))
  }
})

window.BOXES = BOXES

document.querySelector('.next').addEventListener('click', NEXT)
document.querySelector('.prev').addEventListener('click', PREV)

// Dragging
// let startX = 0
// let startOffset = 0

// const onPointerMove = e => {
//   e.preventDefault()
//   SCRUB.vars.position = startOffset + (startX - e.pageX) * 0.001
//   SCRUB.invalidate().restart() // same thing as we do in the ScrollTrigger's onUpdate
// }

// const onPointerUp = e => {
//   document.removeEventListener('pointermove', onPointerMove)
//   document.removeEventListener('pointerup', onPointerUp)
//   document.removeEventListener('pointercancel', onPointerUp)
//   scrollToPosition(SCRUB.vars.position)
// }

// // when the user presses on anything except buttons, start a drag...
// document.addEventListener('pointerdown', e => {
//   if (e.target.tagName.toLowerCase() !== 'button') {
//     document.addEventListener('pointermove', onPointerMove)
//     document.addEventListener('pointerup', onPointerUp)
//     document.addEventListener('pointercancel', onPointerUp)
//     startX = e.pageX
//     startOffset = SCRUB.vars.position
//   }
// })

gsap.set('.box', { display: 'block' })

gsap.set('button', {
  z: 200,
})

Draggable.create('.drag-proxy', {
  type: 'x',
  trigger: '.box',
  onPress() {
    this.startOffset = SCRUB.vars.position
  },
  onDrag() {
    SCRUB.vars.position = this.startOffset + (this.startX - this.x) * 0.001
    SCRUB.invalidate().restart() // same thing as we do in the ScrollTrigger's onUpdate
  },
  onDragEnd() {
    scrollToPosition(SCRUB.vars.position)
  },
})
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.