CLS Vue 3 + Vite

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.

Vue template -- Image dimensions
<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.

Vue template -- v-show vs v-if
<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.

Vue -- Skeleton placeholder for async content
<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.

CSS -- CLS-safe Vue Transition
/* 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.

CSS -- Size-adjusted font fallback
/* 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 have width and height attributes
  • Toggled content uses v-show instead of v-if where possible
  • Skeleton components match loaded content dimensions exactly
  • Vue <Transition> CSS uses transform and opacity only
  • Web fonts use font-display: swap with size-adjusted fallbacks
  • Async containers have reserved space (min-height or 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.

Continue learning