Skip to example

AnimateView: Skeleton

A shimmering skeleton card that wipes into loaded content with an AnimateView update animation driven by a custom CSS property.

Vue

Source code

<script setup lang="ts">
import { AnimateView, motion, startTransition } from 'motion-v'
import { onMounted, onUnmounted, ref } from 'vue'

/**
 * ==============   Animation   ================
 */

const wipeTransition = {
  '--wipe': ['100%', '-100%'],
  transition: { duration: 0.6, ease: 'easeInOut' as const },
}

// Spread onto skeleton bones via v-bind for the shimmering pulse
const shimmer = {
  animate: { backgroundPosition: ['-200% 0', '200% 0'] },
  transition: { duration: 1.5, ease: 'easeInOut' as const, repeat: Infinity },
}

/**
 * ==============   State   ================
 */

const loaded = ref(false)
let timer: ReturnType<typeof setTimeout> | undefined

function scheduleLoad() {
  clearTimeout(timer)
  timer = setTimeout(() => {
    startTransition(() => {
      loaded.value = true
    })
  }, 2500)
}

function reload() {
  startTransition(() => {
    loaded.value = false
  })
  scheduleLoad()
}

onMounted(scheduleLoad)
onUnmounted(() => clearTimeout(timer))

/**
 * ==============   Data   ================
 */

const PROFILE = {
  name: 'Motion',
  handle: '@motiondotdev',
  bio: 'Free and open source. Create stunning web animations for React, JavaScript and Vue.',
}

const STATS = [
  { value: '127', label: 'Posts' },
  { value: '11K', label: 'Followers' },
  { value: '5', label: 'Following' },
]
</script>

<template>
  <div class="skeleton-example">
    <AnimateView
      name="skeleton-card"
      :update="wipeTransition"
    >
      <div
        v-if="loaded"
        class="card"
      >
        <div class="cover" />
        <div class="profile">
          <div class="avatar-wrap">
            <div class="avatar">
              <svg
                xmlns="http://www.w3.org/2000/svg"
                viewBox="0 0 1260 454"
                fill="currentColor"
                style="width: 65%; height: 65%"
              >
                <path d="M475.753 0L226.8 453.6L0 453.6L194.392 99.4116C224.526 44.5081 299.724 0 362.353 0L475.753 0Z" />
                <path d="M1031.93 113.4C1031.93 50.7709 1082.7 0 1145.33 0C1207.96 0 1258.73 50.7709 1258.73 113.4C1258.73 176.029 1207.96 226.8 1145.33 226.8C1082.7 226.8 1031.93 176.029 1031.93 113.4Z" />
                <path d="M518.278 0L745.078 0L496.125 453.6L269.325 453.6L518.278 0Z" />
                <path d="M786.147 0L1012.95 0L818.555 354.188C788.422 409.092 713.223 453.6 650.594 453.6L537.194 453.6L786.147 0Z" />
              </svg>
            </div>
          </div>
          <div class="info">
            <h3 class="name">
              {{ PROFILE.name }}
            </h3>
            <p class="handle">
              {{ PROFILE.handle }}
            </p>
          </div>
          <p class="bio">
            {{ PROFILE.bio }}
          </p>
          <div class="stats">
            <div
              v-for="stat in STATS"
              :key="stat.label"
              class="stat"
            >
              <span class="stat-value">{{ stat.value }}</span>
              <span class="stat-label">{{ stat.label }}</span>
            </div>
          </div>
          <button class="follow-button">
            Follow
          </button>
        </div>
      </div>
      <div
        v-else
        class="card"
      >
        <motion.div
          class="bone cover-bone"
          v-bind="shimmer"
        />
        <div class="profile">
          <div class="avatar-wrap">
            <div class="avatar-ring">
              <motion.div
                class="bone avatar-bone"
                v-bind="shimmer"
              />
            </div>
          </div>
          <motion.div
            class="bone shimmer fit-content"
            v-bind="shimmer"
          >
            <div class="sizer info">
              <h3 class="name">
                {{ PROFILE.name }}
              </h3>
              <p class="handle">
                {{ PROFILE.handle }}
              </p>
            </div>
          </motion.div>
          <motion.div
            class="bone shimmer"
            v-bind="shimmer"
          >
            <p class="sizer bio">
              {{ PROFILE.bio }}
            </p>
          </motion.div>
          <div class="stats">
            <motion.div
              v-for="stat in STATS"
              :key="stat.label"
              class="bone shimmer stat-shimmer"
              v-bind="shimmer"
            >
              <div class="sizer stat">
                <span class="stat-value">{{ stat.value }}</span>
                <span class="stat-label">{{ stat.label }}</span>
              </div>
            </motion.div>
          </div>
          <motion.div
            class="bone shimmer follow-shimmer"
            v-bind="shimmer"
          >
            <div class="sizer follow-sizer">
              Follow
            </div>
          </motion.div>
        </div>
      </div>
    </AnimateView>
    <button
      class="reload-button"
      @click="reload"
    >
      Reload
    </button>
  </div>
</template>

<style>
/* @property and view-transition pseudo-elements are document-level,
   so they can't be scoped */

@property --wipe {
  syntax: '<percentage>';
  inherits: true;
  initial-value: -100%;
}

::view-transition-group(skeleton-card) {
  border-radius: 16px;
  overflow: hidden;
}

::view-transition-image-pair(skeleton-card) {
  mix-blend-mode: normal;
}

::view-transition-old(skeleton-card) {
  /* AnimateView update currently animates old layers */
  /* so we need to set the z-index to 2 to ensure it appears above the new layer */
  z-index: 2;
  mask-image: linear-gradient(
    to right,
    black var(--wipe),
    transparent calc(var(--wipe) + 100%)
  );
}
</style>

<style scoped>
.skeleton-example {
  width: 100%;
  min-height: 100dvh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 16px;
  padding: 20px;
}

.card {
  width: 100%;
  max-width: 360px;
  border-radius: 16px;
  border: 1px solid var(--border);
  background-color: var(--layer);
  overflow: hidden;
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
}

.cover {
  width: 100%;
  height: 120px;
  background: linear-gradient(135deg, var(--hue-4) 0%, var(--hue-3) 100%);
}

.profile {
  padding: 0 20px 20px;
  display: flex;
  flex-direction: column;
  gap: 14px;
}

.avatar-wrap {
  margin-top: -28px;
}

.avatar {
  width: 56px;
  height: 56px;
  border-radius: 50%;
  background: #f5e725;
  border: 3px solid var(--layer);
  display: flex;
  align-items: center;
  justify-content: center;
  color: #000;
}

.avatar-ring {
  width: 56px;
  height: 56px;
  border-radius: 50%;
  background-color: var(--layer);
  display: flex;
  align-items: center;
  justify-content: center;
}

.info {
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.name {
  margin: 0;
  font-size: 16px;
  font-weight: 600;
  color: var(--white);
  line-height: 1.2;
}

.handle {
  margin: 0;
  font-size: 13px;
  color: rgba(255, 255, 255, 0.45);
  line-height: 1.2;
}

.bio {
  margin: 0;
  font-size: 13px;
  line-height: 1.5;
  color: rgba(255, 255, 255, 0.55);
}

.stats {
  display: flex;
  gap: 8px;
}

.stat {
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 8px 4px;
  border-radius: 8px;
  background-color: rgba(255, 255, 255, 0.04);
  flex: 1;
  gap: 2px;
}

.stat-value {
  font-size: 15px;
  font-weight: 600;
  color: var(--white);
  line-height: 1.3;
}

.stat-label {
  font-size: 11px;
  color: rgba(255, 255, 255, 0.35);
  line-height: 1.3;
}

.follow-button,
.follow-sizer {
  width: 100%;
  padding: 10px 0;
  font-size: 14px;
  font-weight: 600;
  font-family: inherit;
}

.follow-button {
  border-radius: 10px;
  border: none;
  background: var(--hue-4);
  color: var(--white);
  cursor: pointer;
}

.follow-sizer {
  text-align: center;
}

.reload-button {
  border: 1px solid var(--border);
  border-radius: 10px;
  background: transparent;
  color: var(--white);
  padding: 8px 20px;
  font-size: 13px;
  font-weight: 500;
  font-family: inherit;
  cursor: pointer;
}

/* Skeleton bones share one shimmering gradient */
.bone {
  background: linear-gradient(
    90deg,
    rgba(255, 255, 255, 0.06) 25%,
    rgba(255, 255, 255, 0.12) 50%,
    rgba(255, 255, 255, 0.06) 75%
  );
  background-size: 200% 100%;
  flex-shrink: 0;
}

.cover-bone {
  width: 100%;
  height: 120px;
}

.avatar-bone {
  width: 50px;
  height: 50px;
  border-radius: 50%;
}

.shimmer {
  border-radius: 6px;
  overflow: hidden;
}

.fit-content {
  align-self: flex-start;
}

.stat-shimmer {
  flex: 1;
  border-radius: 8px;
}

.follow-shimmer {
  border-radius: 10px;
}

/* Real content rendered invisibly to size each shimmer */
.sizer {
  visibility: hidden;
}
</style>