<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>