Fix CLS in Vue 3
CLS in Vue 3 apps has several framework-specific causes: images rendered without dimensions by Vue template directives, v-if inserting elements that push siblings, asynchronously-loaded component data that causes containers to grow from zero, Vue Transition animations on layout-triggering CSS properties, and web font swap reflow. This guide covers the most impactful fixes with working Vue 3 template and script examples.
Expected results
Before
0.27
CLS (Poor) -- images without dimensions, v-if shifts, async content expanding
After
0.04
CLS (Good) -- dimensioned images, v-show, matching skeletons, transform transitions
Step-by-step fix
Add width and height to all template images
Images without explicit dimensions render as 0x0 until they load, then expand to their intrinsic size, shifting everything below. Add width and height attributes to every <img> in your Vue templates. Combine with style="width:100%;height:auto" to maintain responsiveness while preserving the browser's layout reservation.
<template>
<!-- Bad: collapses to 0x0, shifts content when loaded -->
<img :src="product.imageUrl" :alt="product.name" />
<!-- Good: browser reserves 400x300 space before fetch -->
<img
:src="product.imageUrl"
:alt="product.name"
width="400"
height="300"
style="width: 100%; height: auto;"
/>
<!-- For dynamic aspect ratios: use CSS aspect-ratio -->
<div style="aspect-ratio: 4/3; width: 100%; overflow: hidden;">
<img
:src="product.imageUrl"
:alt="product.name"
style="width: 100%; height: 100%; object-fit: cover;"
/>
</div>
</template>
Use v-show for toggled content
v-if removes the element from the DOM when false and inserts it when true. That insertion shifts sibling elements and adds to CLS. v-show keeps the element in the DOM with display:none, preserving its layout footprint so siblings never move. Switch to v-show for banners, tooltips, dropdowns, and any content that toggles visibility without changing surrounding layout.
<template>
<!-- Bad: v-if removes/re-inserts, shifting the content below -->
<div v-if="showBanner" class="promo-banner">
Sale ends tonight -- 20% off sitewide
</div>
<main class="page-content">...</main>
<!-- Good: v-show keeps layout stable -->
<div v-show="showBanner" class="promo-banner">
Sale ends tonight -- 20% off sitewide
</div>
<main class="page-content">...</main>
<!-- When v-if is unavoidable: reserve the space with a wrapper -->
<div style="min-height: 56px;">
<div v-if="showBanner" class="promo-banner">...</div>
</div>
<main class="page-content">...</main>
</template>
Reserve space for async-loaded content
When a Vue component fetches data in onMounted, the container is initially empty (zero height). When the data arrives and the template renders, the container expands and pushes everything below -- a classic CLS source. Use skeleton components that match the loaded content's dimensions, or set a min-height on the container approximating the expected content height.
<template>
<section>
<!-- Skeleton matches ProductCard height -- no shift when data loads -->
<template v-if="!products.length">
<ProductCardSkeleton v-for="n in 6" :key="n" />
</template>
<template v-else>
<ProductCard
v-for="product in products"
:key="product.id"
:product="product"
/>
</template>
</section>
</template>
<script setup>
const products = ref([]);
onMounted(async () => {
products.value = await fetchProducts();
});
</script>
<!-- ProductCardSkeleton.vue: same dimensions as ProductCard -->
<template>
<div class="product-card-skeleton" style="height: 320px; width: 100%;">
<!-- animated shimmer, same layout as real card -->
</div>
</template>
Use transform in Vue Transition CSS
Vue's <Transition> component applies enter/leave CSS classes. If your transition CSS animates height, top, margin, or width, the browser recalculates layout on every animation frame and reports those changes as CLS. Replace these with transform and opacity -- compositor-only properties that cause no layout impact.
/* Bad: margin-top causes layout recalculation per frame */
.slide-enter-active, .slide-leave-active {
transition: margin-top 0.3s ease, opacity 0.3s ease;
}
.slide-enter-from, .slide-leave-to {
margin-top: -20px;
opacity: 0;
}
/* Good: transform is compositor-only, zero CLS impact */
.slide-enter-active, .slide-leave-active {
transition: transform 0.3s ease, opacity 0.3s ease;
}
.slide-enter-from, .slide-leave-to {
transform: translateY(-20px);
opacity: 0;
}
/* In the Vue template */
/* <Transition name="slide">
<div v-show="isVisible">...</div>
</Transition> */
/* For list transitions: same principle applies */
/* <TransitionGroup name="slide" tag="ul">...</TransitionGroup> */
Handle font loading with size-adjusted fallbacks
When a web font loads and replaces the fallback, text reflows if the two fonts have different metrics. font-display: swap shows the fallback immediately, then swaps when the web font arrives. To prevent the swap from shifting layout, add size-adjust, ascent-override, and descent-override to your fallback @font-face so it matches the web font's metrics exactly.
/* Web font */
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-var.woff2') format('woff2');
font-display: swap;
font-weight: 100 900;
}
/* Fallback matched to Inter's metrics (use fontpie to calculate) */
@font-face {
font-family: 'Inter-fallback';
src: local('Arial');
size-adjust: 107%;
ascent-override: 90%;
descent-override: 22%;
line-gap-override: 0%;
}
/* Use both in font stack */
:root {
font-family: 'Inter', 'Inter-fallback', system-ui, sans-serif;
}
Quick checklist
-
All
<img>elements havewidthandheightattributes -
Toggled content uses
v-showinstead ofv-ifwhere possible - Skeleton components match loaded content dimensions exactly
-
Vue
<Transition>CSS usestransformandopacityonly -
Web fonts use
font-display: swapwith size-adjusted fallbacks -
Async containers have reserved space (
min-heightor skeletons)
Frequently asked questions
The main causes in Vue 3 are: images without width/height attributes expanding as they load, v-if inserting elements that push siblings, async-loaded content growing from zero height, Vue Transition animations using layout-triggering properties, and web fonts causing text reflow on swap.
Navigation itself does not cause CLS. CLS from navigation comes from the new route's content loading asynchronously with no reserved space, or from fonts re-rendering. Use route-level data prefetching in your router config to have content ready before the transition ends.
Open Chrome DevTools Rendering settings and enable Layout Shift Regions, then record a performance trace. Shifts appear as blue-green flashes on the page. Run Lighthouse in terminal for reproducible lab scores. Add the web-vitals package with onCLS() in main.js for real-user field data.
Yes. Pinia stores cause CLS when store state affecting layout changes after the initial paint. This is common with localStorage-hydrated state or auth state resolved in onMounted. Ensure the initial store state renders the same layout as the settled state, or use server-side hydration to avoid the mismatch.
The Composition API itself does not cause CLS. The risk is in watchEffect and watch callbacks that change layout-affecting reactive data after the initial paint. Audit any watch calls that modify element count, dimensions, or visibility -- these are functionally equivalent to useEffect CLS in React.
Set up real-user monitoring using the web-vitals JavaScript library (1.5KB). Send CLS data to your analytics platform (Google Analytics 4, custom endpoint). The attribution build identifies exactly which element caused each layout shift. For Vue, also monitor CLS after route transitions, as client-side navigation can trigger additional shifts not captured in initial page load.
Related resources
Complete CLS Guide
Deep dive into CLS calculation, session windows, and cross-framework optimization strategies.
FixFix CLS in React
React-specific CLS fixes: image dimensions, useEffect stability, and CSS-in-JS SSR extraction.
FixFix CLS in Next.js
Next.js fixes using next/image, next/font, and Suspense skeleton matching.
Continue learning
Complete CLS Guide
Deep dive into CLS -- thresholds, measurement, and optimization strategies.
FixFix LCP in Vue
Related performance optimization for the same framework.
FixFix INP in Vue
Related performance optimization for the same framework.
ToolCWV Score Explainer
Enter your scores for personalized fix recommendations.