#app
View Compiled
*,
*:after,
*:before {
  box-sizing: border-box;
  transform-style: preserve-3d;
}
body {
  background: hsl(210, 20%, 10%);
  min-height: 100vh;
  display: grid;
  place-items: center;
  overflow: hidden;
  touch-action: none;
}

#app {
  height: 65vmin;
  width: 65vmin;
}

.parallax {
  transform: translate3d(0, 0, 100vmin) rotateX(calc(((var(--rx, 0) * var(--range-y, 0)) * var(--allow-motion)) * 1deg))
    rotateY(calc(((var(--ry, 0) * var(--range-x, 0)) * var(--allow-motion)) * 1deg))
    rotate(calc(((var(--r, 0) * var(--range-x, 0)) * var(--allow-motion)) * 1deg));
  transform-style: preserve-3d;
  /* Trick to make the perfect square */
  padding-bottom: 100%;
}

.parallax__item {
  left: calc(var(--x, 50) * 1%);
  top: calc(var(--y, 50) * 1%);
  height: calc(var(--height, auto) * 1%);
  width: calc(var(--width, auto) * 1%);
  transform: translate(-50%, -50%)
    translate3d(
      calc(((var(--mx, 0) * var(--range-x, 0)) * var(--allow-motion)) * 1%),
      calc(((var(--my, 0) * var(--range-y, 0)) * var(--allow-motion)) * 1%),
      calc(var(--z, 0) * 1vmin)
    )
    rotateX(calc(((var(--rx, 0) * var(--range-y, 0)) * var(--allow-motion)) * 1deg))
    rotateY(calc(((var(--ry, 0) * var(--range-x, 0)) * var(--allow-motion)) * 1deg))
    rotate(calc(((var(--r, 0) * var(--range-x, 0)) * var(--allow-motion)) * 1deg));
  transform-style: preserve-3d;
}
.kody-segment {
  /* background-image: url("https://assets.codepen.io/605876/kody-spritesheet.png"); */
  background-image: url("https://assets.codepen.io/605876/kody-sprite.png");
  height: 100%;
  width: 100%;
  background-position: calc(var(--pos-x, 0) * 1%) calc(var(--pos-y, 0) * 1%);
  background-size: calc(var(--size, 0) * 1%);
}

/* @media(prefers-reduced-motion: reduce) {
  .parallax {
    --allow-motion: 0;
  }
}
@media (hover: none) {
  .parallax {
    --allow-motion: 0;
  }
} */
import * as React from 'https://cdn.skypack.dev/react'
import ReactDOM from 'https://cdn.skypack.dev/react-dom'
import gsap from 'https://cdn.skypack.dev/gsap@3.11.0'
import {motion} from 'https://cdn.skypack.dev/framer-motion@5.0.0'

interface ParallaxConfig {
  coefficientX?: number
  coefficientY?: number
  rotate?: number
  rotateX?: number
  rotateY?: number
}

export interface ParallaxItemConfig {
  positionX?: number
  positionY?: number
  positionZ?: number
  rotate?: number
  rotateX?: number
  rotateY?: number
  moveX?: number
  moveY?: number
  height?: number
  width?: number
}

interface ContainerCSS extends React.CSSProperties {
  '--r': number
  '--rx': number
  '--ry': number
}

interface ItemCSS extends React.CSSProperties {
  '--x': number
  '--y': number
  '--z': number
  '--r': number
  '--rx': number
  '--ry': number
  '--mx': number
  '--my': number
  '--height': number
  '--width': number
}

const DEFAULT_CONFIG = {
  // Starting positions for X and Y
  positionX: 50,
  positionY: 50,
  positionZ: 0,
  // Range of movement in decimal where 1 === 100. Use negative for opposite directions.
  // Range of movement in decimal where 1 === 100. Use negative for opposite directions.
  rotate: 0,
  rotateX: 0,
  rotateY: 0,
  moveX: 0,
  moveY: 0,
}

const useParallax = (callback, elementRef, proximityArg = 100) => {
  React.useEffect(() => {
    if (!elementRef.current || !callback) return
    const UPDATE = ({ x, y }) => {
      const bounds = 100
      const proximity = typeof proximityArg === 'function' ? proximityArg() : proximityArg
      const elementBounds = elementRef.current.getBoundingClientRect()
      const centerX = elementBounds.left + elementBounds.width / 2
      const centerY = elementBounds.top + elementBounds.height / 2
      const boundX = gsap.utils.mapRange(centerX - proximity, centerX + proximity, -bounds, bounds, x)
      const boundY = gsap.utils.mapRange(centerY - proximity, centerY + proximity, -bounds, bounds, y)
      callback(boundX / 100, boundY / 100)
    }
    window.addEventListener('pointermove', UPDATE)
    return () => {
      window.removeEventListener('pointermove', UPDATE)
    }
  }, [elementRef, callback])
}

const Parallax = ({
  config,
  children,
}: {
  config: ParallaxConfig
  children: React.ReactNode | React.ReactNode[]
}) => {
  const containerRef = React.useRef<HTMLDivElement>(null)
  
  useParallax((x, y) => {
    containerRef.current.style.setProperty('--range-x', Math.floor(gsap.utils.clamp(-60, 60, x * 100)))
    containerRef.current.style.setProperty('--range-y', Math.floor(gsap.utils.clamp(-60, 60, y * 100)))
  }, containerRef, () => window.innerWidth * 0.5)

  return (
    <div
      ref={containerRef}
      className="parallax relative w-full h-full"
      style={
        {
          '--r': config.rotate,
          '--rx': config.rotateX,
          '--ry': config.rotateY,
        } as ContainerCSS
      }
    >
      {children}
    </div>
  )
}

Parallax.defaultProps = {
  config: {
    rotate: 0,
    rotateX: 0,
    rotateY: 0,
    coefficientX: 0.5,
    coefficientY: 0.5,
  },
}

const ParallaxItem = ({
  children,
  config,
}: {
  config: ParallaxItemConfig
  children: React.ReactNode | React.ReactNode[]
}) => {
  const params = {...DEFAULT_CONFIG, ...config}
  return (
    <div
      className="parallax__item absolute"
      style={
        {
          '--x': params.positionX,
          '--y': params.positionY,
          '--z': params.positionZ,
          '--r': params.rotate,
          '--rx': params.rotateX,
          '--ry': params.rotateY,
          '--mx': params.moveX,
          '--my': params.moveY,
          '--height': params.height,
          '--width': params.width,
        } as ItemCSS
      }
    >
      {children}
    </div>
  )
}

ParallaxItem.defaultProps = {
  config: DEFAULT_CONFIG,
}

const ParallaxWrapper = ({children}: {children: React.ReactNode}) => (
  <div className="parallax__wrapper w-full h-full">{children}</div>
)


interface ParallaxItem {
  config: ParallaxItemConfig
  backgroundPositionX: number
  backgroundPositionY: number
  size: number
  identifier: string
}

interface KodySegmentCSS extends React.CSSProperties {
  '--pos-x': number
  '--pos-y': number
  '--size': number
}

const ITEMS = [
  {
    identifier: 'kody-yellow',
    backgroundPositionX: 53.15,
    backgroundPositionY: 50,
    size: 739,
    config: {
      positionX: 50,
      positionY: 54,
      moveX: 0.15,
      moveY: -0.25,
      height: 58,
      width: 55,
      rotate: 0.01,
    },
  },
  {
    identifier: 'kody-red',
    backgroundPositionX: 100,
    backgroundPositionY: 50,
    size: 739,
    config: {
      positionX: 50,
      positionY: 54,
      moveX: 0.15,
      moveY: -0.25,
      height: 58,
      width: 55,
      rotate: 0.01,
    },
  },
  {
    identifier: 'kody-white',
    backgroundPositionX: 68.8,
    backgroundPositionY: 50,
    size: 739,
    config: {
      positionX: 50,
      positionY: 54,
      moveX: 0.15,
      moveY: -0.25,
      height: 58,
      width: 55,
      rotate: 0.01,
    },
  },
  {
    identifier: 'kody-blue',
    backgroundPositionX: 84.4,
    backgroundPositionY: 50,
    size: 739,
    config: {
      positionX: 50,
      positionY: 54,
      moveX: 0.15,
      moveY: -0.25,
      height: 58,
      width: 55,
      rotate: 0.01,
    },
  },
  {
    identifier: 'battery',
    size: 2620,
    backgroundPositionX: -0.075,
    backgroundPositionY: 0,
    config: {
      positionX: 74,
      positionY: 15,
      positionZ: -1,
      moveX: 1.5,
      moveY: -0.85,
      height: 17,
      width: 17,
    },
  },
  {
    identifier: 'leaf-one',
    size: 10000,
    backgroundPositionX: 3.75,
    backgroundPositionY: -1,
    config: {
      positionX: 35,
      positionY: 94,
      moveX: 1.5,
      moveY: -0.85,
      height: 7,
      width: 4,
      rotate: 0.6,
    },
  },
  {
    identifier: 'leaf-two',
    size: 5800,
    backgroundPositionX: 10.15,
    backgroundPositionY: -0.25,
    config: {
      positionX: 97,
      positionY: 63,
      moveX: 1.5,
      moveY: -0.85,
      height: 4,
      width: 8,
      rotate: -0.5,
    },
  },
  {
    identifier: 'leaf-three',
    size: 8000,
    backgroundPositionX: 8.85,
    backgroundPositionY: -0.5,
    config: {
      positionX: 84,
      positionY: 21,
      moveX: 1.5,
      moveY: -0.85,
      height: 7,
      width: 6,
      rotate: 0.75,
    },
  },
  {
    identifier: 'leaf-four',
    size: 13500,
    backgroundPositionX: 19.125,
    backgroundPositionY: -0.5,
    config: {
      positionX: 57,
      positionY: 18,
      moveX: 1.5,
      moveY: -0.85,
      height: 7,
      width: 3,
      rotate: 0.35,
    },
  },
  {
    identifier: 'leaf-five',
    size: 6500,
    backgroundPositionX: 16,
    backgroundPositionY: -1,
    config: {
      positionX: 55,
      positionY: 94,
      moveX: 1.5,
      moveY: -0.85,
      height: 10,
      width: 6,
      rotate: 0.6,
    },
  },
  {
    identifier: 'leaf-six',
    size: 5000,
    backgroundPositionX: 14,
    backgroundPositionY: -0.5,
    config: {
      positionX: 9,
      positionY: 22,
      moveX: 1.5,
      moveY: -0.85,
      height: 8,
      width: 8,
      rotate: 1,
    },
  },
  {
    identifier: 'leaf-seven',
    size: 5100,
    backgroundPositionX: 11.975,
    backgroundPositionY: -0.5,
    config: {
      positionX: 4,
      positionY: 83,
      moveX: 1.5,
      moveY: -0.85,
      height: 8,
      width: 8,
      rotate: -0.5,
    },
  },
  {
    identifier: 'leaf-eight',
    size: 5000,
    backgroundPositionX: 20.15,
    backgroundPositionY: -0.5,
    config: {
      positionX: 10,
      positionY: 74,
      moveX: 1.5,
      moveY: -0.85,
      height: 5,
      width: 8,
      rotate: 0.25,
    },
  },
  {
    identifier: 'leaf-nine',
    size: 5000,
    backgroundPositionX: 4.8,
    backgroundPositionY: -0.25,
    config: {
      positionX: 83,
      positionY: 64,
      moveX: 1.5,
      moveY: -0.85,
      height: 9,
      width: 9,
      rotate: -0.6,
    },
  },
  {
    identifier: 'leaf-ten',
    size: 5000,
    backgroundPositionX: 6.85,
    backgroundPositionY: 0,
    config: {
      positionX: 56,
      positionY: 4,
      moveX: 1.5,
      moveY: -0.85,
      height: 8,
      width: 8,
      rotate: 0.8,
    },
  },
  {
    identifier: 'leaf-eleven',
    size: 6200,
    backgroundPositionX: 17.65,
    backgroundPositionY: -0.5,
    config: {
      positionX: 28,
      positionY: 32,
      moveX: 1.5,
      moveY: -0.85,
      height: 4,
      width: 8,
      rotate: 0.6,
    },
  },
  {
    identifier: 'one-wheel',
    size: 1250,
    backgroundPositionX: 27.5,
    backgroundPositionY: -8,
    config: {
      positionX: 80,
      positionY: 83,
      positionZ: 2,
      rotate: 0.2,
      moveX: 1.5,
      moveY: -0.85,
      height: 26,
      width: 32,
    },
  },
  {
    identifier: 'speaker',
    size: 2150,
    backgroundPositionX: 35,
    backgroundPositionY: 0,
    config: {
      positionX: 12,
      positionY: 51,
      positionZ: 2,
      moveX: 1.5,
      moveY: -0.85,
      height: 24,
      width: 19,
    },
  },
  {
    identifier: 'skis',
    size: 1240,
    backgroundPositionX: 41.3,
    backgroundPositionY: -2,
    config: {
      positionX: 77,
      positionY: 40,
      positionZ: 10,
      moveX: 1.5,
      moveY: -0.85,
      height: 30,
      width: 30,
    },
  },
  {
    identifier: 'recycle',
    size: 2850,
    backgroundPositionX: 22.55,
    backgroundPositionY: 0,
    config: {
      positionX: 28,
      positionY: 11,
      rotate: -2,
      moveX: 1.5,
      moveY: -0.85,
      height: 15,
      width: 15,
    },
  },
]

const KodyParallax = ({team = 'white'}: {team: string}) => (
  <motion.div
    initial={{scale: 1.5, opacity: 0}}
    animate={{scale: 1, opacity: 1}}
    transition={{duration: 0.75}}
    className="w-full"
  >
    <ParallaxWrapper>
      <Parallax
        config={{
          rotate: 0.01,
          rotateX: 0.1,
          rotateY: 0.25,
          coefficientX: 0.75,
          coefficientY: 0.75,
        }}
      >
        {/* Debug image. Uncomment to match up positions in DevTools */}
        {/* <img style={{
          position: 'absolute',
          width: '100%',
          objectFit: 'cover',
          zIndex: 2,
          opacity: 0.5,
        }} src="https://res.cloudinary.com/kentcdodds-com/image/upload/kentcdodds.com/illustrations/kody-flying_blue.png"/> */}
        {ITEMS.filter(
          item =>
            item.identifier === `kody-${team}` ||
            !item.identifier.includes('kody'),
        ).map((item: ParallaxItem) => (
          <ParallaxItem key={item.identifier} config={item.config}>
            <div
              className="kody-segment"
              style={
                {
                  '--pos-x': item.backgroundPositionX,
                  '--pos-y': item.backgroundPositionY,
                  '--size': item.size,
                } as KodySegmentCSS
              }
            />
          </ParallaxItem>
        ))}
      </Parallax>
    </ParallaxWrapper>
  </motion.div>
)

const OPTIONS = ['white', 'red', 'yellow', 'blue']
const AUDIO = new Audio('https://assets.codepen.io/605876/sparkle.mp3')
const App = () => {
  const [selected, setSelected] = React.useState(OPTIONS[0])
  const [motion, setMotion] = React.useState(1)
  
  React.useEffect(() => {
    document.documentElement.style.setProperty('--allow-motion', motion)
  }, [motion])
  
  return (
    <>
      <KodyParallax team={selected}/>
      <div className="flex align-center items-center justify-center mt-4 font-bold">
        <label className="text-white mr-4" htmlFor="motion">Motion</label>
        <input type="range" id="motion" value={motion} min="0" max="1" step="0.1" onChange={e => setMotion(e.target.value)}/>
      </div>
      <div className="flex align-center items-center justify-center mt-6 font-bold">
        <label htmlFor="select" className="text-white mr-4">Choose team:</label>
        <select id="select" value={selected} onChange={e => {
                                                       setSelected(e.target.value)
                                                       AUDIO.play()
}}>
          {OPTIONS.map(opt =>
            <option value={opt} key={opt}>
              {`${opt.charAt(0).toUpperCase()}${opt.slice(1)}`}
            </option>
           )}
        </select>
      </div>
    </>
  )
}

ReactDOM.render(<App />, document.getElementById('app'))
View Compiled

External CSS

  1. https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.2.16/tailwind.min.css

External JavaScript

This Pen doesn't use any external JavaScript resources.