Pen Settings

HTML

CSS

CSS Base

Vendor Prefixing

Add External Stylesheets/Pens

Any URLs added here will be added as <link>s in order, and before the CSS in the editor. You can use the CSS from another Pen by using its URL and the proper URL extension.

+ add another resource

JavaScript

Babel includes JSX processing.

Add External Scripts/Pens

Any URL's added here will be added as <script>s in order, and run before the JavaScript in the editor. You can use the URL of any other Pen and it will include the JavaScript from that Pen.

+ add another resource

Packages

Add Packages

Search for and use JavaScript packages from npm here. By selecting a package, an import statement will be added to the top of the JavaScript editor for this package.

Behavior

Auto Save

If active, Pens will autosave every 30 seconds after being saved once.

Auto-Updating Preview

If enabled, the preview panel updates automatically as you code. If disabled, use the "Run" button to update.

Format on Save

If enabled, your code will be formatted when you actively save your Pen. Note: your code becomes un-folded during formatting.

Editor Settings

Code Indentation

Want to change your Syntax Highlighting theme, Fonts and more?

Visit your global Editor Settings.

HTML

              
                -
  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://i.scdn.co/image/ab67706c0000bebb30677d72001e25584b0fbb37",
  ]

.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")
  
  div.drag-proxy
              
            
!

CSS

              
                *
  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
  position absolute
  visibility: hidden
  
.controls
  position absolute
  top 50%
  left 50%
  transform translate(-50%, -50%) scale(1.5)
  z-index 200
  display flex
  justify-content space-between
  min-height var(--min-size)
  min-width var(--min-size)
  height 20vmin
  width 20vmin

button
  height 48px
  width 48px
  border-radius 50%
  position absolute
  top 100%
  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
              
            
!

JS

              
                import gsap from 'https://cdn.skypack.dev/gsap'
import ScrollTrigger from 'https://cdn.skypack.dev/gsap/ScrollTrigger'
import Draggable from 'https://cdn.skypack.dev/gsap/Draggable'

gsap.registerPlugin(ScrollTrigger, 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(STAGGER);

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) {
    // Get the current index
    const IDX = gsap.utils.wrap(0, 10, Math.floor(SCRUB.vars.position * 10))
    const TARGET = BOXES.indexOf(BOX)
    // Can only hit three values
    // -0.2, -0.1, 0, 0.1, 0.2
    // Base it on the current index and then the positions around it.
    // In most cases the bump will be TARGET - IDX
    let bump = TARGET - IDX
    // If we're in the top half and need to wrap around
    if (IDX >= BOXES.length - 2 && TARGET < IDX && TARGET < 2) bump = 2
    if (IDX >= BOXES.length - 1 && TARGET === 0) bump = 1
    // If we're in the lowers and need to wrap back
    if (IDX <= 2 && TARGET > IDX && TARGET >= BOXES.length - 2) bump = -2
    if (IDX < 1 && TARGET === BOXES.length - 1) bump = -1
    if (Math.abs(bump) <= 2)
      scrollToPosition(SCRUB.vars.position + (1 / BOXES.length) * bump)
  }
})

window.BOXES = BOXES

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

// Dragging
Draggable.create(".drag-proxy", {
  type: "x",
  trigger: ".boxes",
  onPress() {
    this.startPosition = SCRUB.vars.position;
  },
  onDrag() {
    SCRUB.vars.position = this.startPosition + (this.startX - this.x) * 0.001;
    SCRUB.invalidate().restart(); // same thing as we do in the ScrollTrigger's onUpdate
  },
  onDragEnd() {
    scrollToPosition(SCRUB.vars.position);
  }
});

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

              
            
!
999px

Console