Pen Settings

HTML

CSS

CSS Base

Vendor Prefixing

Add External Stylesheets/Pens

Any URL's added here will be added as <link>s in order, and before the CSS in the editor. If you link to another Pen, it will include the CSS from that Pen. If the preprocessor matches, it will attempt to combine them before processing.

+ 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

Save Automatically?

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

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)
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)
  },
})

              
            
!
999px

Console