Skip to main content
Frontend6 min readJuly 28, 2025

Skeleton Loading Patterns for Better Perceived Performance

Implement skeleton loading screens that make your app feel faster — design principles, Vue implementation patterns, and when skeletons beat spinners.

James Ross Jr.
James Ross Jr.

Strategic Systems Architect & Enterprise Software Developer

A loading spinner tells the user "wait." A skeleton screen tells the user "content is on its way, and here is roughly what it will look like." The difference is subtle but measurable — studies consistently show that skeleton screens reduce perceived loading time compared to spinners, even when the actual loading time is identical. The brain processes a skeleton as a partially loaded page rather than an empty one, and that framing changes the user's patience threshold.

Implementing skeleton loading well requires more than replacing a spinner with gray boxes. The skeleton needs to match the content layout, animate in a way that communicates progress, and transition smoothly to the real content without jarring shifts.

Designing Effective Skeletons

A skeleton should mirror the layout of the content it represents. If the loaded state shows a user card with a circular avatar, a name, and two lines of description text, the skeleton should show a circular shape, a wider rectangle, and two narrower rectangles in the same positions.

<template>
 <div v-if="loading" class="flex items-start gap-4 p-4">
 <div class="h-12 w-12 rounded-full bg-neutral-200 animate-pulse" />
 <div class="flex-1 space-y-2">
 <div class="h-4 w-1/3 rounded bg-neutral-200 animate-pulse" />
 <div class="h-3 w-full rounded bg-neutral-100 animate-pulse" />
 <div class="h-3 w-2/3 rounded bg-neutral-100 animate-pulse" />
 </div>
 </div>
 <UserCard v-else :user="user" />
</template>

The widths of skeleton lines should vary. Real text does not fill the same width on every line — the last line is typically shorter. Uniform-width skeleton bars look artificial and fail to create the "almost loaded" illusion that makes skeletons effective.

Do not skeleton every element on the page. Navigation, headers, and static UI elements that do not depend on async data should render immediately. The skeleton applies only to the content area that is waiting for data. This creates a frame of stability around the loading region, which reinforces the impression that the page is functional and nearly ready.

Building a Reusable Skeleton Component

Rather than creating custom skeletons for every content type, build a small set of composable skeleton primitives:

<!-- components/SkeletonLine.vue -->
<script setup lang="ts">
interface Props {
 width?: string
 height?: string
 rounded?: 'sm' | 'md' | 'full'
}

WithDefaults(defineProps<Props>(), {
 width: '100%',
 height: '16px',
 rounded: 'md',
})
</script>

<template>
 <div
 class="animate-pulse bg-neutral-200"
 :class="{
 'rounded-sm': rounded === 'sm',
 'rounded': rounded === 'md',
 'rounded-full': rounded === 'full',
 }"
 :style="{ width, height }"
 aria-hidden="true"
 />
</template>

Then compose these into content-specific skeletons:

<!-- components/ProductCardSkeleton.vue -->
<template>
 <div class="rounded-lg border p-4 space-y-3">
 <SkeletonLine height="192px" rounded="md" />
 <SkeletonLine width="60%" height="20px" />
 <SkeletonLine width="40%" height="16px" />
 <div class="flex justify-between pt-2">
 <SkeletonLine width="80px" height="24px" />
 <SkeletonLine width="100px" height="36px" rounded="md" />
 </div>
 </div>
</template>

The aria-hidden="true" attribute is important — screen readers should not announce skeleton elements. Instead, use an ARIA live region to announce when content has finished loading so screen reader users know data is available.

Animation and Transition

The standard pulse animation (animate-pulse in Tailwind) works but is the minimum viable approach. A shimmer effect — a gradient that sweeps from left to right — better communicates the concept of content loading in a direction:

@keyframes shimmer {
 0% { background-position: -200% 0; }
 100% { background-position: 200% 0; }
}

.skeleton-shimmer {
 background: linear-gradient(
 90deg,
 theme('colors.neutral.200') 25%,
 theme('colors.neutral.100') 50%,
 theme('colors.neutral.200') 75%
 );
 background-size: 200% 100%;
 animation: shimmer 1.5s infinite;
}

The transition from skeleton to content should be smooth. An abrupt swap where skeleton elements disappear and real content pops in undermines the perceived performance benefit. A short fade transition (150-200ms) bridges the gap:

<Transition name="fade" mode="out-in">
 <ProductCardSkeleton v-if="loading" key="skeleton" />
 <ProductCard v-else :product="product" key="content" />
</Transition>

Ensure the skeleton and the real content have the same dimensions. If the skeleton card is 280 pixels tall and the loaded card is 320 pixels, the page will shift during transition. This layout shift hurts Core Web Vitals scores and creates a janky visual experience. Match dimensions by using the same padding, gap, and sizing patterns in both the skeleton and the real component.

When to Use Skeletons vs Spinners

Skeletons work best when the content layout is predictable. A product grid, a user list, a dashboard widget — these have consistent structures that skeletons can mirror accurately. When the content structure varies significantly between loads (like search results with mixed media types), a skeleton might mislead users about what is coming.

Spinners are appropriate for actions rather than page loads. Submitting a form, deleting an item, processing a payment — these are user-initiated actions where the user is waiting for confirmation, not scanning content. A button with a spinner inside communicates "I am working on it" better than a skeleton replacement would.

For initial page loads, consider whether the content is above or below the fold. Above-the-fold content should use skeletons because the user is staring at it. Below-the-fold content can load lazily without any loading indicator — the user will scroll to it after it has loaded, ideally through an approach that aligns with your image optimization strategy for heavy assets.

The best loading experience is no visible loading at all. Prefetching data before the user navigates to a page, using stale-while-revalidate caching, and keeping API response times under 200ms eliminate the need for loading states in most interactions. Skeletons are for the cases where loading time is unavoidable — make those cases feel as brief as possible.