<div class="ghost__drag">
  <svg xmlns="http://www.w3.org/2000/svg" class="ghost" viewBox="0 0 87.69 133.803">
    <defs>
      <clipPath id="bodyClip">
        <path class="ghost__tails" d="M.08 0A.079.079 0 000 .08v122.084l.044-.002c5.322 0 5.622 11.62 10.935 11.64h.03c5.313-.02 5.613-11.64 10.935-11.64 5.323 0 5.623 11.62 10.936 11.64h.03c5.313-.02 5.612-11.64 10.935-11.64s5.622 11.62 10.936 11.64h.029c5.313-.02 5.613-11.64 10.936-11.64 5.323 0 5.622 11.62 10.936 11.64h.029c5.313-.02 5.613-11.64 10.936-11.64.014 0 .028 0 .043.002V.08a.079.079 0 00-.08-.08H43.846z" />
        <path class="ghost__tails--two" d="M.08 0A.079.079 0 000 .08v122.084l.044-.002c5.322 0 5.622 11.62 10.935 11.64h.03c5.313-.02 5.613-11.64 10.935-11.64 5.323 0 5.623 11.62 10.936 11.64h.03c5.313-.02 5.612-11.64 10.935-11.64s5.622 11.62 10.936 11.64h.029c5.313-.02 5.613-11.64 10.936-11.64 5.323 0 5.622 11.62 10.936 11.64h.029c5.313-.02 5.613-11.64 10.936-11.64.014 0 .028 0 .043.002V.08a.079.079 0 00-.08-.08H43.846z" />
      </clipPath>
      <clipPath id="mouthClip">
        <circle class="ghost__mouth-clip" cx="43.845000" cy="50.5" r="10"></circle>
      </clipPath>
      <clipPath id="shookClip">
        <ellipse cx="43.7964" cy="51.6723" rx="4" ry="4"/>
      </clipPath>
    </defs>
    <g class="ghost__ghost">
      <g class="ghost__body-wrapper" clip-path="url(#bodyClip)">
        <path class="ghost__body" d="M43.845 0C19.555 0 0 16.028 0 49.742v81.416c0 .887.017 1.768.043 2.645h87.604c.027-.877.043-1.758.043-2.645V49.742C87.69 16.028 68.135 0 43.845 0z"/>
      </g>
      <g class="ghost__features--shook">
        <ellipse cx="43.7964" cy="51.6723" rx="4" ry="4" fill="black"/>
        <g clip-path="url(#shookClip)">
          <path class="ghost__tongue--shook" fill="red" d="M46.276 50.864a2.91 2.91 0 00-2.91 2.91 2.91 2.91 0 00.151.917 6.147 6.147 0 003.402-.81 6.147 6.147 0 001.833-1.633 2.91 2.91 0 00-2.476-1.384z" />
        </g>
        <path d="M13.0777 42.6763L21.8369 45.7902L13.0777 48.6123" fill="none" stroke="black" stroke-width="2" stroke-linejoin="round"/>
        <path d="M74.8678 48.4368L65.8635 46.1247L74.3328 42.525" fill="none" stroke="black" stroke-width="2" stroke-linejoin="round"/>
      </g>
      <g class="ghost__eyes">
        <g class="ghost__eye" transform="translate(-14.366 -64.075)">
          <circle r="5.292" cy="109.431" cx="31.751"/>
          <circle class="ghost__pupil" cx="29.751" cy="107.431" r="1.323" />
        </g>
        <g class="ghost__eye" transform="translate(38.553 -64.075)">
          <circle cx="31.751" cy="109.431" r="5.292"/>
          <circle class="ghost__pupil" r="1.323" cy="107.431" cx="29.751" />
        </g>
      </g>
      <g class="ghost__cheeks" transform="translate(-14.526 -64.075)">
        <circle cx="23.226" cy="117.689" r="2.646"/>
        <circle r="2.646" cy="117.689" cx="93.517"/>
      </g>
      <g class="ghost__mouth-group" clip-path="url(#mouthClip)">
        <path class="ghost__mouth" d="M49.992 48.558a6.147 6.147 0 01-3.073 5.323 6.147 6.147 0 01-6.147 0 6.147 6.147 0 01-3.074-5.323h6.147z"/>
        <path class="ghost__tongue" d="M46.276 50.864a2.91 2.91 0 00-2.91 2.91 2.91 2.91 0 00.151.917 6.147 6.147 0 003.402-.81 6.147 6.147 0 001.833-1.633 2.91 2.91 0 00-2.476-1.384z" />
      </g>
      <g class="ghost__boo-features">
        <path class="ghost__teeth" d="M39.235 51.373l1.537-2.116 1.536 2.116 1.537-2.116 1.537 2.116 1.537-2.116 1.537 2.116 1.536-2.815H37.698z" />
        <path class="ghost__brow" d="M22.944 39.021c0 1.297-1.06 2.495-2.78 3.144-1.72.648-3.838.648-5.558 0-1.72-.649-2.78-1.847-2.78-3.144" stroke-width=".756" stroke-linecap="round" stroke-linejoin="round"/>
        <path class="ghost__brow" d="M75.863 39.02c0 1.298-1.059 2.497-2.779 3.146-1.72.65-3.839.65-5.559 0-1.72-.649-2.779-1.848-2.779-3.147" stroke-width=".757" stroke-linecap="round" stroke-linejoin="round"/>
      </g>
    </g>
  </svg>
</div>
View Compiled
*
  box-sizing border-box

body
  display flex
  align-items center
  justify-content center
  background 'hsl(%s, 0%, %s)' % (var(--hue, 0) calc(var(--light, 6) * 1%))
  min-height 100vh
  overflow hidden

h1
  margin 0
  position fixed
  bottom 1rem
  right 1rem
  color hsl(280, 50%, 30%)
  font-family sans-serif
  font-size 2rem
  user-select none

.ghost
  height 25vmin
  overflow visible !important
  filter drop-shadow(0 0 4vmin var(--glow, white));
  transition filter 0.2s
  
  &__drag
    display none
  // &__ghost
  //   display none
  &__cheeks
    fill hsl(0, 80%, 85%)
  &__pupil
    fill hsl(0, 0%, 100%)
  &__body
    fill hsla(0, 0%, 100%, 1)
  &__teeth
    fill hsl(0, 0%, 100%)
  &__brow
    fill hsla(0, 0%, 100%, 1)
    stroke hsl(0, 0%, 10%)
  &__tongue
    fill hsl(0, 80%, 50%)

  &__teeth
  &__brow
    stroke none
  //   display none

.sweet-bowl
  &__opening
    fill hsl(30, 80%, 30%)
  &__body
    fill hsl(30, 80%, 50%)
  &__groove
    stroke hsl(35, 80%, 40%)
  &__eye
  &__mouth
    fill hsl(0, 0%, 10%)
  &__tongue
    fill hsl(0, 80%, 50%)

.sweet
  display none
  &--lolly
    path
      fill hsl(0, 0%, 95%)
    circle
      fill 'hsl(%s, 70%, 50%)' % var(--hue, 0)
  &--sweet
    path
      fill 'hsl(%s, 70%, 20%)' % var(--hue, 0)
    circle
      fill 'hsl(%s, 70%, 50%)' % var(--hue, 0)
View Compiled
const {
  gsap: {
    set,
    to,
    timeline,
    registerPlugin,
    utils: { mapRange, clamp, random },
  },
  Draggable,
  InertiaPlugin,
} = window

registerPlugin(InertiaPlugin)

set(document.documentElement, {
  '--hue': random(0, 359)
})

let shakeReset
let comparator
let INERT_COUNT = 0
let SHAKING = false
const BOUNDS = 800
const SHAKE_BOUND = 200
const ROTATE = 25
const SHAKE_THRESHOLD = 6
const SHAKE_TIMEOUT = 500
const BOO_TIMER = 2000
const TIMING = {
  TAILS: 1,
}

set('.ghost__tails--two', { xPercent: -100 })
// Move pupils across 2 and down 2 to center: x: '+=2', y: '+=2'
set(['.ghost__eyes', '.ghost__mouth-clip', '.ghost__mouth-group'], {
  transformOrigin: '50% 50%',
})
set('.ghost__pupil', { x: 0, y: 0, transformOrigin: '50% 50%' })
set(['.ghost__features--shook', '.ghost__boo-features'], { display: 'none' })
set('.ghost__tongue--shook', { yPercent: 50 })
set('.ghost__teeth', { scale: 1.15, transformOrigin: '50% 50%'})

const AUDIO = {
  PAC: new Audio('https://assets.codepen.io/605876/pac-out.mp3'),
  BOO: new Audio('https://assets.codepen.io/605876/boo.mp3')
}

AUDIO.BOO.volume = 0.5

const BLINK = () => {
  const delay = random(2, 6)
  timeline().to('.ghost__eyes', {
    delay,
    onComplete: () => BLINK(),
    scaleY: 0.1,
    repeat: 3,
    yoyo: true,
    duration: 0.05,
  })
}

let tailsTl

const checkReverseLoop = (tl = tailsTl) => {
  if (tl.reversed() && tl.totalTime() <= tl.duration()) {
    tl.totalTime(tl.totalTime() + tl.duration() * 100, true) //just shoot it out 100 cycles forward and suppress events
  }
}

tailsTl = timeline({
  repeat: -1,
  ease: 'none',
  onRepeat: checkReverseLoop,
})
  .to(
    '.ghost__tails',
    { duration: TIMING.TAILS, xPercent: 100, ease: 'none' },
    0
  )
  .to(
    '.ghost__tails--two',
    { duration: TIMING.TAILS, xPercent: 0, ease: 'none' },
    0
  )

BLINK()

let direction = 'left'
let ghosting = false
const GHOST_DRAG = document.querySelector('.ghost__drag')
let booTimer
GHOST_DRAG.addEventListener('dblclick', () => {
  if (!ghosting) {
    AUDIO.BOO.play()
    set('.ghost__boo-features', { display: 'block' })
    if (booTimer) clearTimeout(booTimer)
    booTimer = setTimeout(() => {
      set('.ghost__boo-features', { display: 'none' })
    }, BOO_TIMER)
  }
})

Draggable.create(GHOST_DRAG, {
  type: 'x,y',
  trigger: GHOST_DRAG,
  inertia: true,
  bounds: document.body,
  dragResistance: 0.25,
  onDrag: function() {
    set(GHOST_DRAG, {
      x: this.x,
      y: this.y,
    })
    const INE_X = InertiaPlugin.getVelocity(GHOST_DRAG, 'x')
    const MOM_X = clamp(-BOUNDS, BOUNDS, INE_X)
    const rotation = mapRange(-BOUNDS, BOUNDS, ROTATE, -ROTATE, MOM_X)

    to(GHOST_DRAG, {
      rotation,
      duration: 0.25,
    })
    to('.ghost__body-wrapper', {
      skewX: rotation * 0.5,
      duration: 0.25,
    })

    // Handle changing tails direction
    const currentDirection = this.getDirection()
    if (
      (currentDirection !== direction && currentDirection === 'left') ||
      currentDirection === 'right'
    ) {
      direction = currentDirection
      if (direction === 'right') {
        tailsTl.reverse()
        checkReverseLoop()
      } else tailsTl.play()
    }

    if (
      ((INE_X < -SHAKE_BOUND || INE_X > SHAKE_BOUND) && !SHAKING) ||
      (SHAKING && comparator(INE_X))
    ) {
      SHAKING = true
      INERT_COUNT += 1
      if (INERT_COUNT > 2) {
        set('.ghost__features--shook', { display: 'block' })
        set(['.ghost__eyes', '.ghost__mouth-group', '.ghost__boo-features'], { display: 'none' })
      }
      // Need to create a way of mapping the opposite
      if (INE_X < -SHAKE_BOUND) {
        // The next value must be INE_X > SHAKE_BOUND
        // Create a function
        comparator = val => val > SHAKE_BOUND
      } else {
        comparator = val => val < -SHAKE_BOUND
      }

      if (shakeReset) clearTimeout(shakeReset)
      shakeReset = setTimeout(() => {
        INERT_COUNT = 0
        SHAKING = false
        shakeReset = null
      }, SHAKE_TIMEOUT)
    }
  },
  onDragEnd: () => {
    set('.ghost__features--shook', { display: 'none' })
    set(['.ghost__eyes', '.ghost__mouth-group'], { display: 'block' })
    if (INERT_COUNT > SHAKE_THRESHOLD) {
      timeline({
        onStart: () => {
          set('.ghost', {'--glow': '#2121DE' })
          ghosting = true
          AUDIO.PAC.play()
          set('.ghost__boo-features', { display: 'none' })
          set('.ghost__body', {
            fill: '#2121DE',
          })
        },
      })
        .to(document.documentElement, {
          '--light': 0,
        })
        .to(
          tailsTl,
          {
            timeScale: 2,
          },
          0
        )
      set(['.ghost__tongue', '.ghost__cheeks'], { display: 'none' })
      set(['.ghost__eyes', '.ghost__mouth'], { fill: 'yellow' })
      set('.ghost__mouth-group', { rotation: 180 })
    }

    // RESET
    to(GHOST_DRAG, {
      duration: 0.25,
      rotation: 0,
    })
    to('.ghost__body-wrapper', {
      skewX: 0,
      duration: 0.25,
    })
  },
})

AUDIO.PAC.addEventListener('ended', () => {
  timeline({
    onStart: () => {
      set('.ghost', {'--glow': 'white' })
    },
    onComplete: () => {
      ghosting = false
    }
  })
    .to(
      document.documentElement,
      {
        '--light': 10,
      },
      0
    )
    .to(
      tailsTl,
      {
        timeScale: 1,
      },
      0
    )
  set('.ghost__body', {
    fill: 'white',
  })
  set(['.ghost__tongue', '.ghost__cheeks'], { display: 'block' })
  set(['.ghost__eyes', '.ghost__mouth'], { fill: 'black' })
  set('.ghost__mouth-group', { rotation: 0 })
})

set('.ghost__drag', {
  display: 'block'
})
View Compiled

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://unpkg.co/gsap@3/dist/gsap.min.js
  2. https://assets.codepen.io/16327/InertiaPlugin.min.js
  3. https://unpkg.com/gsap@3/dist/Draggable.min.js