#app(
  @mousemove="chargeFollow",
  @mouseover="chargeShow",
  @mouseleave="chargeHide",
  @mousedown="chargeBubbles",
  @mouseup="releaseBubbles",
  @touchcancel="chargeHideTouch",
  @touchmove="chargeFollowTouch",
  @touchstart="chargeBubblesTouch",
  @touchend="releaseBubblesTouch"
)
  .text
    h1(ref="textTitle") BUBBLE LAUNCHER
    p(ref="textDirection") Hold and click the screen to launch bubbles.
    p(ref="textDescription") There are 4 steps in releasing bubbles.
  .bubble(v-for="bubble, i in bubbles"
  :class="{" +
         "'popping' : bubble.popping," +
         "'popped' : bubble.popped" +
         "}"
  :ref="'bubble' + i"
  v-if="bubble !== null")
  transition(name="charge")
    .charge(
      :class="{" +
             "  'step-1' : step === 1" +
             ", 'step-2' : step === 2" +
             ", 'step-3' : step === 3" +
             ", 'step-4' : step === 4" +
             "}",
      :style="{ top: Charge.Y, left: Charge.X }",
      @mouseout="",
      ref="charge",
      v-if="show === true"
    )
      svg(viewBox="0 0 100 100" ref="chargeImage")
        circle(r="45", cx="50", cy="50")
View Compiled
$pi: 3.14159;
$radius: 45;
$circumference: 2 * $pi * $radius;

@import url("https://fonts.googleapis.com/css2?family=Fredoka+One&display=swap");

#app {
  background-image: linear-gradient(to bottom right, #5eb7ff, #428eff, #003dc2);
  width: 100vw;
  height: 100vh;
  position: relative;
  overflow: hidden;
}

.text {
  color: #ffffff;
  font-size: 180%;
  text-align: center;
  width: 100%;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  flex-direction: column;
  user-select: none;
  h1,
  p {
    font-family: "Fredoka One", cursive;
    padding: 0;
    margin: 10px 25px;
    filter: blur(1px);
    opacity: 0.7;
    text-shadow: 0 -1px #000000;
  }
}

.bubble {
  background-color: rgba(#ffffff, 0.3);
  width: 50px;
  height: 50px;
  border: solid 4px rgba(#ffffff, 0.5);
  box-sizing: border-box;
  position: absolute;
  top: 0;
  left: 0;
  border-radius: 50%;
  filter: blur(1px);
  &:after {
    content: "";
    background-color: rgba(#ffffff, 0.7);
    width: 20px;
    height: 10px;
    position: absolute;
    top: 7px;
    left: 3px;
    border-radius: 50% 50% 50% 50% / 75% 75% 50% 50%;
    transform: rotate(-35deg);
    filter: blur(3px);
  }
  &:not(.popping) {
    animation: bubble 300ms infinite;
  }
}

.charge {
  stroke-dasharray: 0 $circumference;
  background-color: rgba(#303030, 0.5);
  width: 50px;
  height: 50px;
  position: absolute;
  border-radius: 50%;
  box-shadow: 0 0 0 0 rgba(#f23030, 0.5);
  transition: top 300ms ease-out, left 300ms ease-out,
    background-color 300ms ease-out, box-shadow 300ms ease-out;
  &.step-1 {
    animation: step-small 300ms infinite;
  }
  &.step-2 {
    animation: step-small 150ms infinite;
  }
  &.step-3 {
    animation: step-large 120ms infinite;
  }
  &.step-4 {
    background-color: rgba(#f23030, 0.7);
    box-shadow: 0 0 12px 8px rgba(#f23030, 0.7);
    animation: step-large 90ms infinite;
    svg {
      stroke: transparent;
    }
  }
  svg {
    fill: transparent;
    stroke: rgba(#ffffff, 0.7);
    stroke-width: 8;
    stroke-dasharray: inherit;
    width: calc(100% + 8px);
    height: calc(100% + 8px);
    margin-top: -4px;
    margin-left: -4px;
    transform: rotate(-90deg);
    transition: stroke 300ms ease-out;
  }
}

.charge-enter-active {
  transition: opacity 150ms ease-out;
  animation: show 300ms forwards;
}

.charge-leave-active {
  transition: opacity 150ms ease-out;
}

.charge-enter,
.charge-leave-to {
  opacity: 0;
}

@keyframes show {
  0% {
    transform: scale(0);
  }
  40% {
    transform: scale(1.25);
  }
  60% {
    transform: scale(0.8);
  }
  80% {
    transform: scale(1.1);
  }
  100% {
    transform: scale(1);
  }
}

@keyframes step-small {
  0% {
    transform: scale(1);
  }
  50% {
    transform: scale(1.1);
  }
  100% {
    transform: scale(1);
  }
}

@keyframes step-large {
  0% {
    transform: scale(1);
  }
  50% {
    transform: scale(1.2);
  }
  100% {
    transform: scale(1);
  }
}

@keyframes bubble {
  0% {
    transform: translateX(0);
  }
  25% {
    transform: translateX(-1px);
  }
  50% {
    transform: translateX(0);
  }
  75% {
    transform: translateX(1px);
  }
  100% {
    transform: translateX(0);
  }
}
View Compiled
const Circumference = 2 * Math.PI * 45;
const DistanceFrom = 200;
const DistanceTo = 400;
const DelayTime = 30;

let Charge = {
  value: 0
};

function randomDistance() {
  return random(DistanceFrom, DistanceTo);
}

function random(from, to) {
  return Math.random() * (to - from) + from;
}

function animateBubbles() {
  for (let i = 0; i < app.bubbles.length; i++) {
    let bubble = app.bubbles[i];

    if (bubble === null) {
      continue;
    }

    if (bubble.isAnimating === true) {
      continue;
    }

    let bubbleElm = app.$refs["bubble" + i];

    if (typeof bubbleElm === "undefined") {
      continue;
    }

    gsap.set(bubbleElm, { top: bubble.y, left: bubble.x });
    gsap.to(bubbleElm, {
      duration: bubble.duration,
      top:
        bubble.y - Math.sin((bubble.angle * Math.PI) / 180) * randomDistance(),
      left:
        bubble.x + Math.cos((bubble.angle * Math.PI) / 180) * randomDistance(),
      ease: Power2.easeOut,
      onComplete() {
        app.bubbles[i].popping = true;

        gsap.to(bubbleElm, {
          duration: 1,
          css: { scale: 2, opacity: 0, filter: "blur(8px)" },
          onComplete() {
            app.bubbles[i].popped = true;

            try {
              app.bubbles[i] = null;
            } catch (ex) {
              // Do nothing
            }
          }
        });
      }
    });

    app.bubbles[i].isAnimating = true;
  }
}

function createBubble(a, d, x, y) {
  app.bubbles.push({
    angle: a,
    duration: d,
    x: x - 25,
    y: y - 25,
    popping: false,
    popped: false,
    isAnimating: false
  });
}

function bubbles1(x, y) {
  createBubble(90, random(8, 10), x, y);
}

function bubbles2(x, y, i, j) {
  setTimeout(function() {
    if (i === j) {
      return;
    }
    
    createBubble(90 + random(-45, 45), random(6, 8), x, y);
    
    if (i < j) {
      bubbles2(x, y, ++i, j);
    }
  }, random(0, DelayTime));
}

function bubbles3(x, y, i, j) {
  setTimeout(function() {
    if (i === j) {
      return;
    }
    
    createBubble(90 + random(-60, 60), random(4, 6), x, y);
    
    if (i < j) {
      bubbles3(x, y, ++i, j);
    }
  }, random(0, DelayTime));
}

function bubbles4(x, y, i, j) {
  setTimeout(function() {
    if (i === j) {
      return;
    }
    
    createBubble(random(0, 360), random(1, 4), x, y);
    
    if (i < j) {
      bubbles4(x, y, ++i, j);
    }
  }, random(0, DelayTime));
}

let app = new Vue({
  el: "#app",
  data: {
    show: false,
    Charge: {
      X: 0,
      Y: 0
    },
    step: 0,
    bubbles: []
  },
  methods: {
    chargeFollow(evt) {
      this.Charge.X = evt.clientX - 25 + "px";
      this.Charge.Y = evt.clientY - 25 + "px";
    },
    chargeShow() {
      this.show = true;
    },
    chargeHide() {
      this.show = false;
    },
    chargeBubbles() {
      let self = this;
      let circ = Circumference;

      Charge.value = 0;

      gsap.killTweensOf(Charge);
      gsap.to(Charge, 10, {
        value: 100,
        ease: Power1.easeOut,
        onUpdate() {
          let charge = Charge.value;

          gsap.to(self.$refs.charge, 0.1, {
            strokeDasharray: Math.floor(circ * (charge / 100)) + " " + circ,
            ease: Power2.easeOut
          });

          if (Math.floor(charge) > 0 && Math.floor(charge) < 33) {
            self.step = 1;
          } else if (Math.floor(charge) >= 33 && Math.floor(charge) < 66) {
            self.step = 2;
          } else if (Math.floor(charge) >= 66 && Math.floor(charge) < 100) {
            self.step = 3;
          } else if (Math.floor(charge) >= 100) {
            self.step = 4;
          }
        }
      });
    },
    releaseBubbles(evt) {
      this.shootBubbles(evt.clientX, evt.clientY);
    },
    shootBubbles(x, y) {
      gsap.killTweensOf(Charge);
      gsap.to(this.$refs.charge, 0.2, {
        strokeDasharray: "0 " + Circumference
      });

      switch (this.step) {
        case 1:
          bubbles1(x, y);

          break;
        case 2:
          bubbles2(x, y, 0, random(4, 8));

          break;
        case 3:
          bubbles3(x, y, 0, random(8, 16));

          break;
        case 4:
          bubbles4(x, y, 0, random(32, 64));

          break;
      }

      animateBubbles();

      this.step = 0;
    },
    chargeFollowTouch(evt) {
      if (this.show === true) {
        this.Charge.X = evt.touches[0].clientX - 25 + "px";
        this.Charge.Y = evt.touches[0].clientY - 25 + "px";
      }
    },
    chargeHideTouch() {
      this.show = false;
    },
    chargeBubblesTouch(evt) {
      this.show = true;

      this.Charge.X = evt.touches[0].clientX - 25 + "px";
      this.Charge.Y = evt.touches[0].clientY - 25 + "px";

      this.chargeBubbles();
    },
    releaseBubblesTouch() {
      this.shootBubbles(
        parseInt(this.Charge.X.replace(/px/, "")),
        parseInt(this.Charge.Y.replace(/px/, ""))
      );

      this.chargeHideTouch();
    }
  },
  mounted() {
    let textTitle = this.$refs.textTitle;
    let textDirection = this.$refs.textDirection;
    let textDescription = this.$refs.textDescription;

    let titleSplit = new SplitText(textTitle);
    let directionSplit = new SplitText(textDirection);
    let descriptionSplit = new SplitText(textDescription);

    gsap.from(titleSplit.chars, {
      duration: 2,
      y: 100,
      autoAlpha: 0,
      ease: "elastic",
      stagger: 0.08
    });
    gsap.from(directionSplit.chars, {
      duration: 3,
      y: 50,
      autoAlpha: 0,
      ease: "elastic",
      stagger: 0.08
    });
    gsap.from(descriptionSplit.chars, {
      duration: 3,
      y: 50,
      autoAlpha: 0,
      ease: "elastic",
      stagger: 0.08
    });
  },
  updated() {
    animateBubbles();
  }
});
View Compiled

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://cdnjs.cloudflare.com/ajax/libs/vue/2.6.11/vue.min.js
  2. https://cdnjs.cloudflare.com/ajax/libs/gsap/3.3.1/gsap.min.js
  3. https://s3-us-west-2.amazonaws.com/s.cdpn.io/16327/SplitText3.min.js