<div id="app">
  <div class="center">
    Loading Vue app...
  </div>
</div>

<template id="tpl">
  <div class="center">
    <div>
      <h1>Ending Loading animation with VueJS</h1>
      <p>Based on <a href="https://codepen.io/nkCreation/full/BaWomqa" target="_blank">this pen</a>.</p>
      <p>Below is a loader which has an animation loop and an end animation. When the loading is finished, it waits for the current loop to be finished before triggering.</p>
      <p>The fake request has a minimal duration of 200ms, but it will last at least 1s (animation time).</p>
      <button
              :disabled="isLoading"
              type="button"
              @click="onButtonClick">
        Start random loading</button>
    </div>
    
    <teleport to="body">
      <div class="overlay" :class="{'is-loading': isLoading}">
        <transition name="fade">
          <div
               v-if="isLoading"
               class="loader"
               @animationiteration="onAnimationIteration"
               @animationend="onAnimationEnd"
               :class="{'is-finishing': isFinishing}">
            <div class="loader__circle"></div>
            <div class="loader__dot loader__dot--init"></div>
            <div class="loader__dot loader__dot--finished"></div>
            <div class="loader__check">
              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-check">
                <polyline points="20 6 9 17 4 12"></polyline>
              </svg>
            </div>
          </div>
        </transition>
      </div>
    </teleport>
  </div>
</template>
:root {
  --primary-color-rgb: 202, 42, 93;
  --secondary-color-rgb: 52, 208, 179;
}

body {
  -webkit-font-smoothing: antialiased;
  text-rendering: geometricPrecision;
  font-family: Montserrat;
  line-height: 1.5;
}
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.2s linear;
}

.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}
.center {
  display: flex;
  height: 100vh;
  width: 100vw;
  align-items: center;
  justify-content: center;
  text-align: center;

  > div {
    max-width: 768px;
    padding: 2em;
  }
}

.overlay {
  opacity: 0;
  pointer-events: none;
  position: fixed;
  top: 0;
  left: 0;
  z-index: 10;
  display: flex;
  height: 100vh;
  width: 100vw;
  align-items: center;
  justify-content: center;
  background: rgba(white, 0.88);
  transition: opacity 0.2s linear;
  backdrop-filter: blur(16px);

  &.is-loading {
    opacity: 1;
    pointer-events: all;
  }

  .loader {
    --dot-color: rgb(var(--primary-color-rgb));
    --check-color: rgba(var(--secondary-color-rgb));
    --size: 88px;
  }
}

button {
  cursor: pointer;
  font: inherit;
  appearance: none;
  margin: 1em 0;
  background: rgb(var(--primary-color-rgb));
  color: white;
  padding: 0.5em 1.5em;
  border: 0;
  font-weight: 600;
  border-radius: 2em;
  box-shadow: 0 2.8px 2.2px rgba(var(--primary-color-rgb), 0.02),
    0 6.7px 5.3px rgba(var(--primary-color-rgb), 0.028),
    0 12.5px 10px rgba(var(--primary-color-rgb), 0.035),
    0 22.3px 17.9px rgba(var(--primary-color-rgb), 0.042),
    0 41.8px 33.4px rgba(var(--primary-color-rgb), 0.05),
    0 100px 80px rgba(var(--primary-color-rgb), 0.07);
}

// css loader

.loader {
  --size: 60px;
  --border: 0.04em;
  --dot-size: 0.23em;
  --dot-color: inherit;
  --check-color: green;
  --easing: cubic-bezier(0.74, 0.17, 0.29, 0.82);

  position: relative;
  display: inline-block;
  font-size: var(--size);
  width: 1em;
  height: 1em;
  transform: translateZ(0);
  color: inherit;

  * {
    &::before,
    &::after {
      box-sizing: border-box;
    }
  }

  &__circle {
    position: absolute;
    --calculated-position: calc(var(--dot-size) / 2 - var(--border));
    border-radius: 50%;
    top: var(--calculated-position);
    left: var(--calculated-position);
    height: calc(100% - var(--dot-size));
    width: calc(100% - var(--dot-size));
    border: var(--border) solid currentColor;
    opacity: 0.16;
  }

  &__dot {
    --background: var(--dot-color);

    position: absolute;
    top: 0;
    left: calc(50% - var(--dot-size) / 2);
    width: var(--dot-size);
    height: var(--dot-size);
    background: var(--background);
    border-radius: 50%;

    &--init {
      animation: spin 1s infinite var(--easing);
      transform-origin: 50% 0.5em;
    }

    &--finished {
      --background: var(--check-color);
      display: none;
    }
  }

  &__check {
    --check-size: calc(var(--dot-size) * 2);
    display: none;
    width: var(--check-size);
    height: var(--check-size);
    position: absolute;
    top: calc(50% - var(--check-size) / 2);
    left: calc(50% - var(--check-size) / 2);
    color: var(--check-color);

    svg {
      color: inherit;
      display: block;
      width: 100%;
      height: 100%;
    }
  }

  &.is-finishing {
    .loader__circle {
      animation: endCircle 1s both var(--easing);
    }
    .loader__dot {
      &--init {
        animation: endInit 1s both var(--easing);
      }
      &--finished {
        display: block;
        animation: end 1s both var(--easing);
      }
    }
    .loader__check {
      display: block;
      animation: check 1s both var(--easing);
    }
  }
}

@keyframes spin {
  0% {
    transform: rotate(0);
  }
  100% {
    transform: rotate(360deg);
  }
}

@keyframes endInit {
  0% {
    opacity: 1;
  }
  50%,
  100% {
    opacity: 0;
    transform: translateY(calc(0.5em - var(--dot-size) / 2));
  }
}

@keyframes end {
  0% {
    opacity: 0;
  }
  50%,
  100% {
    transform: translateY(calc(0.5em - var(--dot-size) / 2));
    opacity: 1;
  }
  100% {
    transform: translateY(calc(0.5em - var(--dot-size) / 2)) scale(10);
    opacity: 0;
  }
}

@keyframes endCircle {
  0%,
  50% {
    opacity: 0.16;
    transform: scale(1);
  }
  100% {
    opacity: 0;
    transform: scale(4);
  }
}

@keyframes check {
  0%,
  50% {
    transform: scale(0.25);
    opacity: 0;
  }
  100% {
    transform: scale(1);
    opacity: 1;
  }
}
View Compiled
const App = {
  template: "#tpl",
  data() {
    return {
      isLoading: false,
      isPending: false,
      isFinishing: false
    };
  },
  methods: {
    onAnimationIteration() {
      this.isFinishing = !this.isPending;
    },
    async onAnimationEnd() {
      await delay(1000);

      this.isLoading = false;
      this.isFinishing = false;
    },
    async onButtonClick() {
      this.isLoading = true;
      this.isPending = true;

      await delay(200, 10000);

      this.isPending = false;
    }
  }
};

function delay(min, max) {
  const delay = max ? Math.floor(Math.random() * max) + min : min;

  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(delay);
    }, delay);
  });
}

Vue.createApp(App).mount("#app");
View Compiled

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://cdn.jsdelivr.net/npm/vue@3.0.11/dist/vue.global.prod.js