بهینه‌سازی CLS (Cumulative Layout Shift): راهنمای کامل پایداری بصری وب در ۲۰۲۶

راهنمای جامع بهینه‌سازی CLS در ۲۰۲۶ — از تعیین ابعاد تصاویر و فونت تا CSS Containment، bfcache و View Transitions API. آخرین بخش مجموعه Core Web Vitals.

مقدمه: CLS — آخرین تکه پازل Core Web Vitals

اگه مقالات قبلی ما رو دنبال کرده باشید، تا الان با سه معیار کلیدی عملکرد وب آشنا شدید: بهینه‌سازی TTFB با Edge Computing که سرعت پاسخ سرور رو پوشش داد، بهینه‌سازی INP که تعامل‌پذیری صفحه رو بررسی کرد، و بهینه‌سازی LCP که سرعت نمایش محتوای اصلی رو تشریح کرد. حالا وقتشه آخرین تکه پازل رو سر جاش بذاریم: CLS — Cumulative Layout Shift.

CLS پایداری بصری صفحه رو اندازه می‌گیره. یعنی چی؟ تصور کنید دارید یه مقاله رو روی موبایلتون می‌خونید و تازه رسیدید به یه بخش جالب. ناگهان یه بنر تبلیغاتی بالای صفحه بارگذاری میشه، کل محتوا به پایین پرش می‌کنه، و شما جای خوندنتون رو گم می‌کنید. یا بدتر — دارید روی دکمه «لغو» کلیک می‌کنید ولی درست همون لحظه صفحه جابجا میشه و انگشتتون روی دکمه «تأیید خرید» فرود میاد.

صادقانه بگم، کمتر چیزی توی وب به اندازه این تجربه عصبی‌کننده‌ست. و دقیقاً همین چیزیه که CLS قراره اندازه‌گیریش کنه.

نکته جالب درباره CLS اینه که بین چهار معیار Core Web Vitals، بیشتر از همه بد فهمیده میشه. خیلی از توسعه‌دهنده‌ها فکر می‌کنن CLS مجموع تمام جابجایی‌هاست (نیست!)، خیلی‌ها نمی‌دونن انیمیشن‌های CSS transform روش تأثیر نمی‌ذارن، و تعداد قابل‌توجهی از سایت‌ها هنوز تصاویر بدون ابعاد مشخص دارن — که ساده‌ترین عامل CLS بد محسوب میشه.

خبر خوب اینه که طبق داده‌های CrUX (Chrome User Experience Report)، حدود ۷۲ درصد سایت‌ها CLS خوبی دارن — بهترین آمار بین تمام معیارهای Core Web Vitals. ولی اگه سایت شما جزو اون ۲۸ درصد باقی‌مونده‌ست، دارید هم کاربر و هم رتبه سئو از دست میدید.

توی این مقاله قراره عمیق بریم توی CLS: از نحوه محاسبه‌ش و علل رایج جابجایی layout، تا تکنیک‌های عملی رفع مشکل با کدهای واقعی. آخرشم یه چک‌لیست کامل میدم دستتون که بتونید مستقیماً تو پروژه‌هاتون ازش استفاده کنید.

CLS دقیقاً چیه و چطور اندازه‌گیری میشه؟

قبل از هر کاری، بیاید مطمئن بشیم درک درستی از CLS داریم. برخلاف TTFB که زمان رو بر حسب میلی‌ثانیه اندازه می‌گیره یا LCP و INP که هر دو واحد زمانی دارن، CLS بدون واحده. یه عدد محض‌ه که نشون‌دهنده میزان بی‌ثباتی بصری صفحه‌ست.

آستانه‌های گوگل برای CLS

گوگل سه سطح برای CLS تعریف کرده:

  • خوب (Good): ≤ ۰.۱ — کاربر اصلاً متوجه جابجایی نمیشه
  • نیاز به بهبود (Needs Improvement): بین ۰.۱ تا ۰.۲۵ — جابجایی‌ها قابل توجه ولی هنوز قابل تحمل
  • ضعیف (Poor): بیشتر از ۰.۲۵ — کاربر واضحاً احساس بی‌ثباتی می‌کنه

این آستانه‌ها در صدک ۷۵ (75th percentile) داده‌های واقعی کاربران ارزیابی میشن. یعنی حداقل ۷۵ درصد بازدیدهای صفحه شما باید CLS زیر ۰.۱ داشته باشن تا گوگل اون رو «خوب» حساب کنه.

الگوریتم Session Window — بخشی که اکثراً اشتباه می‌فهمن

اسم «Cumulative Layout Shift» یه‌کم گمراه‌کننده‌ست. CLS مجموع تمام جابجایی‌های صفحه نیست. در عوض از یه الگوریتم به نام Session Window استفاده می‌کنه.

بذارید ساده توضیح بدم:

  1. جابجایی‌های layout در پنجره‌های زمانی (session windows) گروه‌بندی میشن
  2. یه پنجره زمانی با اولین جابجایی شروع میشه و تا زمانی ادامه پیدا می‌کنه که فاصله هر جابجایی با قبلی کمتر از ۱ ثانیه باشه
  3. حداکثر طول یه پنجره زمانی ۵ ثانیه هست — حتی اگه جابجایی‌ها با فاصله کمتر از ۱ ثانیه اتفاق بیفتن
  4. امتیاز CLS صفحه = بزرگ‌ترین مجموع امتیاز بین تمام پنجره‌های زمانی

خب این یعنی چی در عمل؟ فرض کنید صفحه‌تون دو بار جابجایی داره: یکی در ثانیه ۲ با امتیاز ۰.۰۵ و یکی در ثانیه ۱۵ با امتیاز ۰.۰۴. چون فاصله‌شون بیش از ۱ ثانیه‌ست، تو دو پنجره جداگانه قرار می‌گیرن. CLS صفحه ۰.۰۵ میشه (بزرگ‌ترین پنجره)، نه ۰.۰۹ (مجموع).

ولی اگه سه جابجایی پشت سر هم تو نیم ثانیه اتفاق بیفتن با مجموع ۰.۳، CLS صفحه ۰.۳ خواهد بود — حتی اگه بقیه بازدید کاملاً پایدار باشه.

محاسبه امتیاز هر جابجایی

هر جابجایی layout یه امتیاز داره که از ضرب دو عامل به دست میاد:

layout shift score = impact fraction × distance fraction

کسر تأثیر (Impact Fraction): چند درصد از viewport تحت تأثیر قرار گرفته. مثلاً اگه یه المان جابجا بشه و کل ناحیه‌ای که اشغال کرده (قبل و بعد از جابجایی) ۶۰ درصد viewport باشه، کسر تأثیر ۰.۶ میشه.

کسر فاصله (Distance Fraction): المان چقدر جابجا شده، نسبت به ارتفاع viewport. اگه المانی ۱۵۰ پیکسل حرکت کنه و ارتفاع viewport هشتصد پیکسل باشه، کسر فاصله میشه ۰.۱۸۷۵.

بذارید یه مثال عملی بزنم. تصور کنید یه تصویر hero بدون ابعاد مشخص دارید که وقتی بارگذاری میشه، ۵۰ درصد viewport رو اشغال می‌کنه و محتوای زیرش رو ۲۵۰ پیکسل (۳۱ درصد viewport) به پایین هل میده. امتیاز این جابجایی: ۰.۵ × ۰.۳۱ = ۰.۱۵۵ — فقط با یه تصویر بدون ابعاد، از آستانه «خوب» رد شدید!

چه چیزهایی جابجایی layout حساب نمیشن؟

این بخش خیلی مهمه و نفهمیدنش باعث سردرگمی زیادی میشه:

  • جابجایی‌های ناشی از تعامل کاربر: اگه کاربر کلیک یا ضربه‌ای بزنه و تا ۵۰۰ میلی‌ثانیه بعدش layout تغییر کنه، این جابجایی حساب نمیشه. مرورگر فرض می‌کنه کاربر انتظار این تغییر رو داشته.
  • المان‌های جدید: اگه المانی به DOM اضافه بشه ولی المان‌های قبلی رو جابجا نکنه، CLS تولید نمیشه.
  • انیمیشن‌های transform: تغییرات CSS transform (مثل translateX، scale، rotate) اصلاً جابجایی layout محسوب نمیشن — چون در لایه compositor اعمال میشن، نه در مرحله layout.

درک این استثناها برای انتخاب راه‌حل درست حیاتیه. مثلاً اگه می‌خواید موقعیت یه المان رو متحرک کنید، همیشه از transform: translate() استفاده کنید، نه تغییر top یا left. اولی برای CLS نامرئیه، دومی نیست.

علل رایج جابجایی Layout

حالا که می‌دونیم CLS چطور محاسبه میشه، بیاید ببینیم چه چیزهایی معمولاً باعث جابجایی layout میشن. این لیست رو بر اساس شیوع‌شون مرتب کردم — از رایج‌ترین شروع می‌کنیم.

۱. تصاویر و ویدیوها بدون ابعاد مشخص

این رایج‌ترین علت CLS بد در سطح وبه و من شخصاً توی پروژه‌هام بارها باهاش دست و پنجه نرم کردم. وقتی تگ <img> بدون width و height باشه، مرورگر در ابتدا هیچ فضایی براش رزرو نمی‌کنه. وقتی تصویر بارگذاری میشه و مرورگر ابعادش رو می‌فهمه، تصویر باز میشه و هر چیزی زیرش رو به پایین هل میده.

۲. فونت‌های وب و FOUT/FOIT

وقتی فونت سفارشی بارگذاری میشه، متنی که با فونت جایگزین (fallback) رندر شده بود دوباره با فونت جدید رندر میشه. اگه متریک‌های دو فونت متفاوت باشن — عرض کاراکتر، ارتفاع خط، فاصله حروف — خطوط متن متفاوت شکسته میشن، پاراگراف‌ها ارتفاعشون عوض میشه و همه‌چیز زیرشون جابجا میشه.

به این پدیده FOUT (Flash of Unstyled Text) میگن و مخصوصاً تو سایت‌های فارسی که از وزیرمتن یا فونت‌های مشابه استفاده می‌کنن، تأثیرش خیلی محسوسه.

۳. محتوای تزریق‌شده پویا

بنرهای تبلیغاتی، اعلامیه‌ها، نوارهای cookie consent، و هر محتوایی که بعد از رندر اولیه به DOM اضافه بشه و محتوای موجود رو جابجا کنه. اینا مقصرهای شناخته‌شده CLS هستن.

۴. CSS بارگذاری‌شده دیرهنگام

اگه فایل‌های CSS به‌صورت غیرهمزمان بارگذاری بشن و بعد از رندر اولیه اعمال بشن، ممکنه ابعاد و موقعیت المان‌ها تغییر کنه و layout shift اتفاق بیفته.

۵. ویجت‌ها و embed‌های شخص ثالث

ویجت‌های شبکه‌های اجتماعی، نقشه‌های گوگل، ویدیوهای YouTube و سایر embed‌ها معمولاً با ابعاد نامشخص بارگذاری میشن و فضای اطرافشون رو تحت تأثیر قرار میدن. (اگه تا حالا دکمه لایک اینستاگرام رو embed کرده باشید، احتمالاً می‌دونید چی میگم.)

تکنیک ۱: همیشه ابعاد رسانه رو مشخص کنید

خب، بریم سراغ راه‌حل‌ها. ساده‌ترین و مؤثرترین کاری که برای بهبود CLS می‌تونید انجام بدید، تنظیم ابعاد تصاویر و ویدیوهاست. مرورگرهای مدرن از ویژگی‌های width و height برای محاسبه نسبت ابعاد پیش‌فرض استفاده می‌کنن — حتی قبل از بارگذاری تصویر.

روش ۱: ویژگی‌های width و height

<!-- Always specify width and height -->
<img
  src="/images/hero-banner.webp"
  width="1200"
  height="675"
  alt="بنر اصلی سایت"
  loading="lazy"
  decoding="async"
>

<!-- For video elements too -->
<video
  width="1280"
  height="720"
  poster="/images/video-poster.jpg"
  controls
>
  <source src="/videos/intro.mp4" type="video/mp4">
</video>

با تنظیم width="1200" و height="675"، مرورگر نسبت ابعاد ۱۶:۹ رو محاسبه می‌کنه و فضای مناسب رو قبل از بارگذاری تصویر رزرو می‌کنه. حتی اگه تصویر ریسپانسیو باشه و عرض واقعی‌ش تغییر کنه، مرورگر از این نسبت برای محاسبه ارتفاع استفاده می‌کنه.

روش ۲: خاصیت CSS aspect-ratio

برای مواقعی که ابعاد دقیق تصویر رو نمی‌دونید یا می‌خواید انعطاف‌پذیری بیشتری داشته باشید، aspect-ratio واقعاً عالیه:

<style>
/* For responsive images with known aspect ratio */
.hero-image {
  width: 100%;
  height: auto;
  aspect-ratio: 16 / 9;
}

/* For user-uploaded images where ratio varies */
.gallery-image {
  width: 100%;
  height: auto;
  aspect-ratio: 4 / 3;
  object-fit: cover; /* Crop to fill without distortion */
}

/* For embedded iframes (YouTube, maps, etc.) */
.embed-container {
  width: 100%;
  aspect-ratio: 16 / 9;
}
.embed-container iframe {
  width: 100%;
  height: 100%;
  border: 0;
}
</style>

<img class="hero-image" src="/images/hero.webp" alt="Hero image">

<div class="embed-container">
  <iframe src="https://www.youtube.com/embed/VIDEO_ID"
          allowfullscreen></iframe>
</div>

ترکیب width/height با aspect-ratio بهترین نتیجه رو میده. ویژگی‌های HTML به عنوان fallback عمل می‌کنن و CSS برای استایل‌دهی دقیق‌تر:

<img
  src="/images/product.webp"
  width="800"
  height="600"
  alt="تصویر محصول"
  style="width: 100%; height: auto; aspect-ratio: 4 / 3;"
>

یه آمار جالب: طبق داده‌های کروم، حدود ۷۰ درصد سایت‌هایی که مشکل CLS دارن، تصاویر بدون ابعاد مشخص دارن. یعنی فقط با اضافه کردن width و height به تصاویر، بخش بزرگی از مشکلات CLS حل میشه. ساده‌تر از این نمیشد، نه؟

تکنیک ۲: بهینه‌سازی بارگذاری فونت

فونت‌های وب یکی از عوامل پنهان CLS هستن. وقتی فونت سفارشی دیر بارگذاری میشه، متن از فونت fallback به فونت اصلی تغییر می‌کنه و اگه متریک‌های دو فونت متفاوت باشن، layout shift اتفاق میفته.

بیاید ببینیم چطور حلش کنیم.

font-display: swap در مقابل optional

اول باید تصمیم بگیرید از کدوم استراتژی استفاده کنید:

  • font-display: swap: متن فوراً با فونت fallback نمایش داده میشه و وقتی فونت اصلی بارگذاری شد، جایگزین میشه. کاربر متن رو زود می‌بینه ولی ممکنه layout shift اتفاق بیفته.
  • font-display: optional: اگه فونت اصلی خیلی سریع (معمولاً زیر ۱۰۰ میلی‌ثانیه) بارگذاری بشه، استفاده میشه. در غیر این صورت، مرورگر از فونت fallback استفاده می‌کنه و اصلاً swap نمی‌کنه. هیچ layout shift ای رخ نمیده ولی ممکنه در بعضی بازدیدها فونت سفارشی نمایش داده نشه.

توصیه من: اگه CLS مشکل اصلیتونه، font-display: optional بهترین انتخابه. ولی اگه نمایش فونت سفارشی براتون مهمه، از swap استفاده کنید و با تکنیک‌های زیر layout shift رو به حداقل برسونید.

Font Face Descriptors: تنظیم متریک‌های فونت fallback

اینجا جادوی واقعی اتفاق میفته. با استفاده از size-adjust، ascent-override و descent-override می‌تونید متریک‌های فونت fallback رو طوری تنظیم کنید که تقریباً هم‌اندازه فونت اصلی باشه.

نتیجه؟ وقتی فونت اصلی بارگذاری میشه، تغییر بصری به حداقل می‌رسه و CLS تقریباً صفر میشه.

/* Step 1: Define your custom font */
@font-face {
  font-family: 'Vazirmatn';
  src: url('/fonts/vazirmatn-variable.woff2') format('woff2');
  font-display: swap;
  font-weight: 100 900;
}

/* Step 2: Create a size-adjusted fallback */
@font-face {
  font-family: 'Vazirmatn-Fallback';
  src: local('Tahoma'), local('Arial');
  size-adjust: 102%;
  ascent-override: 95%;
  descent-override: 28%;
  line-gap-override: 0%;
}

/* Step 3: Use both in your font stack */
body {
  font-family: 'Vazirmatn', 'Vazirmatn-Fallback', Tahoma, sans-serif;
}

مقادیر size-adjust و override‌ها بسته به فونت متفاوته. ابزارهایی مثل Fallback Font Generator یا ماژول next/font در Next.js می‌تونن این مقادیر رو خودکار محاسبه کنن. اگه از فونت فارسی مثل وزیرمتن استفاده می‌کنید، Tahoma به عنوان fallback معمولاً بهترین تطابق رو داره.

Preload کردن فونت‌ها

ترکیب preload با font-display: optional بهترین نتیجه رو برای CLS میده. با preload، فونت خیلی زودتر شروع به دانلود می‌کنه و احتمال اینکه قبل از رندر اول آماده بشه خیلی بالاتره:

<!-- Preload the primary font -->
<link
  rel="preload"
  href="/fonts/vazirmatn-variable.woff2"
  as="font"
  type="font/woff2"
  crossorigin
>

<style>
@font-face {
  font-family: 'Vazirmatn';
  src: url('/fonts/vazirmatn-variable.woff2') format('woff2');
  font-display: optional; /* Zero CLS - uses font only if ready */
  font-weight: 100 900;
}
</style>

نکته مهم: ویژگی crossorigin رو فراموش نکنید — حتی اگه فونت از همون دامنه سرو میشه. فونت‌ها همیشه با CORS بارگذاری میشن و بدون crossorigin، مرورگر فونت preload شده رو نادیده می‌گیره و دوباره دانلودش می‌کنه. من خودم یه بار ساعت‌ها وقت صرف دیباگ این مورد کردم!

تکنیک ۳: رزرو فضا برای محتوای پویا

بنرهای تبلیغاتی، اعلامیه‌ها، و هر محتوایی که بعد از رندر اولیه بارگذاری میشه، اگه فضاشون از قبل رزرو نشده باشه، CLS تولید می‌کنن.

بیاید چند استراتژی مختلف رو بررسی کنیم.

min-height برای اسلات‌های تبلیغاتی

ساده‌ترین روش، تعیین ارتفاع حداقلی برای فضاهایی که قراره محتوای پویا در اون‌ها بارگذاری بشه:

<style>
/* Reserve space for ad slots */
.ad-slot-leaderboard {
  min-height: 90px;  /* Standard leaderboard: 728x90 */
  width: 100%;
  background-color: #f5f5f5;
  display: flex;
  align-items: center;
  justify-content: center;
}

.ad-slot-rectangle {
  min-height: 250px; /* Medium rectangle: 300x250 */
  width: 300px;
  background-color: #f5f5f5;
}

/* For cookie consent bars - use fixed position */
.cookie-bar {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  z-index: 1000;
  /* Fixed positioning = zero CLS */
}

/* For notification toasts */
.toast-container {
  position: fixed;
  top: 16px;
  right: 16px;
  z-index: 1100;
}
</style>

<!-- Ad slot with reserved space -->
<div class="ad-slot-leaderboard">
  <!-- Ad loads here dynamically -->
</div>

نکته کلیدی رو بذارید ساده بگم: هر وقت المانی قراره بالای محتوای موجود تزریق بشه، یا فضاش رو از قبل رزرو کنید، یا از position: fixed/absolute استفاده کنید. المان‌های با موقعیت ثابت یا مطلق از جریان عادی document خارج میشن و CLS تولید نمی‌کنن.

CSS Containment: محدود کردن تأثیر تغییرات

خاصیت contain در CSS یکی از ابزارهای قدرتمند ولی کم‌شناخته‌ایه که می‌تونه CLS رو بهبود بده. این خاصیت به مرورگر میگه که تغییرات داخلی یه المان تأثیری روی بقیه صفحه ندارن:

<style>
/* Contain layout changes within the ad slot */
.ad-slot {
  contain: layout style;
  min-height: 250px;
  overflow: hidden;
}

/*
  contain: layout  → Internal layout changes won't affect siblings
  contain: style   → Counters and fonts won't leak out
  contain: size    → Element size is independent of children (use carefully!)
  contain: paint   → Children won't be visible outside bounds
*/

/* For sidebar widgets */
.widget-container {
  contain: layout style paint;
  min-height: 200px;
}
</style>

با contain: layout، حتی اگه محتوای داخلی تبلیغات تغییر کنه، تأثیرش به خارج از اون container سرایت نمی‌کنه. این خیلی مفیده مخصوصاً برای ویجت‌های شخص ثالث که کنترلی روی رفتارشون ندارید.

content-visibility و contain-intrinsic-size

این جفت خاصیت CSS نسبتاً جدید هستن و هم برای عملکرد و هم برای CLS خیلی مفیدن. content-visibility: auto به مرورگر اجازه میده المان‌هایی که خارج از viewport هستن رو رندر نکنه، و contain-intrinsic-size ابعاد تخمینی المان رو برای زمانی که هنوز رندر نشده مشخص می‌کنه:

<style>
/* Skip rendering off-screen sections */
.article-section {
  content-visibility: auto;
  contain-intrinsic-size: auto 500px;
  /* auto = use remembered size if available, 500px as initial estimate */
}

/* For long lists */
.comment-item {
  content-visibility: auto;
  contain-intrinsic-size: auto 120px;
}

/* For footer sections that users rarely scroll to */
.site-footer {
  content-visibility: auto;
  contain-intrinsic-size: auto 300px;
}
</style>

<main>
  <section class="article-section">
    <!-- Long content here -->
  </section>
  <section class="article-section">
    <!-- More content -->
  </section>
</main>

چرا این به CLS کمک می‌کنه؟ بدون contain-intrinsic-size، وقتی content-visibility: auto فعاله و المان وارد viewport میشه، مرورگر تازه ارتفاع واقعی‌ش رو محاسبه می‌کنه و ممکنه scrollbar جابجا بشه. با تعیین سایز تخمینی، فضای مناسب از قبل رزرو میشه.

Skeleton Screens: بهتر از صفحه خالی

Skeleton Screen‌ها placeholder‌هایی هستن که شکل کلی محتوای در حال بارگذاری رو نشون میدن. اگه درست پیاده‌سازی بشن، CLS رو عملاً از بین می‌برن چون فضا از ابتدا رزرو شده:

<style>
/* Skeleton loading animation */
.skeleton {
  background: linear-gradient(
    90deg,
    #f0f0f0 25%,
    #e0e0e0 50%,
    #f0f0f0 75%
  );
  background-size: 200% 100%;
  animation: skeleton-shimmer 1.5s ease-in-out infinite;
  border-radius: 4px;
}

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

/* Card skeleton */
.card-skeleton {
  padding: 16px;
}
.card-skeleton .skeleton-image {
  width: 100%;
  aspect-ratio: 16 / 9;
}
.card-skeleton .skeleton-title {
  width: 70%;
  height: 24px;
  margin-top: 12px;
}
.card-skeleton .skeleton-text {
  width: 100%;
  height: 16px;
  margin-top: 8px;
}
</style>

<!-- Skeleton placeholder -->
<div class="card-skeleton">
  <div class="skeleton skeleton-image"></div>
  <div class="skeleton skeleton-title"></div>
  <div class="skeleton skeleton-text"></div>
  <div class="skeleton skeleton-text"></div>
</div>

یه نکته که خیلی‌ها فراموش می‌کنن: ابعاد skeleton باید تا حد امکان نزدیک به محتوای واقعی باشه. اگه skeleton شما ۲۰۰ پیکسل ارتفاع داره ولی محتوای واقعی ۴۰۰ پیکسل میشه، باز هم layout shift خواهید داشت — فقط از نوع متفاوتش.

تکنیک ۴: انیمیشن‌هایی که Layout Shift تولید نمی‌کنن

بعضی خاصیت‌های CSS وقتی متحرک میشن، باعث محاسبه مجدد layout و در نتیجه CLS میشن. بعضی دیگه نه.

فرقشون چیه؟

خاصیت‌هایی که Layout تحریک می‌کنن (اجتناب کنید)

تغییر این خاصیت‌ها باعث میشه مرورگر layout کل صفحه رو دوباره محاسبه کنه:

  • width، height، padding، margin
  • top، right، bottom، left
  • border-width، font-size

خاصیت‌هایی که فقط Composite هستن (امن)

این خاصیت‌ها فقط در لایه compositor اعمال میشن و هیچ تأثیری روی layout ندارن:

  • transform (translate، scale، rotate)
  • opacity
  • filter
<style>
/* BAD: Animating layout-triggering properties */
.slide-in-bad {
  animation: slideInBad 0.3s ease-out;
}
@keyframes slideInBad {
  from {
    margin-left: -100%;  /* Triggers layout! CLS! */
  }
  to {
    margin-left: 0;
  }
}

/* GOOD: Using transform instead */
.slide-in-good {
  animation: slideInGood 0.3s ease-out;
}
@keyframes slideInGood {
  from {
    transform: translateX(-100%);  /* Composite only, zero CLS */
  }
  to {
    transform: translateX(0);
  }
}

/* BAD: Expanding height for accordion */
.accordion-bad .content {
  transition: height 0.3s ease;
  height: 0;
  overflow: hidden;
}
.accordion-bad.open .content {
  height: auto; /* Triggers layout shift! */
}

/* GOOD: Using transform and opacity for accordion effect */
.accordion-good .content {
  transition: transform 0.3s ease, opacity 0.3s ease;
  transform: scaleY(0);
  transform-origin: top;
  opacity: 0;
}
.accordion-good.open .content {
  transform: scaleY(1);
  opacity: 1;
}

/* GOOD: Using grid for smooth height animation */
.accordion-grid .content {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 0.3s ease;
  overflow: hidden;
}
.accordion-grid.open .content {
  grid-template-rows: 1fr;
}
.accordion-grid .content-inner {
  min-height: 0;
}
</style>

راستش تکنیک grid-template-rows که بالاتر نشون دادم، یکی از بهترین روش‌ها برای انیمیشن ارتفاع در سال ۲۰۲۶ هست. مرورگرهای مدرن این انتقال رو نرم و بدون layout shift اجرا می‌کنن.

البته یادتون باشه که انیمیشن‌هایی که در پاسخ به تعامل کاربر (مثل کلیک) اتفاق میفتن، تا ۵۰۰ میلی‌ثانیه از CLS معاف هستن. با این حال، استفاده از خاصیت‌های composite همیشه یه عادت خوبه.

تکنیک ۵: بهینه‌سازی bfcache

حالا بیاید درباره یه تکنیکی صحبت کنیم که خیلی‌ها بهش توجه نمی‌کنن: bfcache (Back/Forward Cache). این مکانیزم مرورگر، صفحه رو هنگام خروج کاربر در حافظه نگه می‌داره و وقتی کاربر دکمه Back یا Forward رو می‌زنه، صفحه رو فوری از حافظه بازیابی می‌کنه — بدون هیچ بارگذاری مجدد.

ارتباطش با CLS چیه؟ وقتی صفحه از bfcache بازیابی میشه، تمام المان‌ها دقیقاً در همون جایی هستن که کاربر ترکشون کرده بود. هیچ تصویر بدون ابعادی نیست، هیچ فونتی نیاز به بارگذاری مجدد نداره، و هیچ محتوای پویایی دوباره تزریق نمیشه.

نتیجه؟ CLS صفر.

مشکل اینجاست که بعضی الگوهای کدنویسی مانع فعال شدن bfcache میشن:

// THINGS THAT BREAK bfcache:

// 1. unload event listener - the #1 bfcache killer
window.addEventListener('unload', () => {
  // This PREVENTS bfcache! Never use unload.
  sendAnalytics();
});

// FIX: Use pagehide instead
window.addEventListener('pagehide', (event) => {
  if (!event.persisted) {
    // Page is truly being unloaded (not cached)
    sendAnalytics();
  }
});

// 2. Using Cache-Control: no-store
// If your server sends this header, bfcache won't work
// FIX: Use no-cache instead of no-store when possible

// 3. Open WebSocket or WebRTC connections
// FIX: Close connections in pagehide, reopen in pageshow
let socket = null;

window.addEventListener('pagehide', () => {
  if (socket) {
    socket.close();
    socket = null;
  }
});

window.addEventListener('pageshow', (event) => {
  if (event.persisted) {
    // Page was restored from bfcache
    socket = new WebSocket('wss://example.com/ws');
  }
});

تست واجد شرایط بودن bfcache

Chrome DevTools ابزار مخصوصی برای تست bfcache داره:

// Programmatic bfcache test
window.addEventListener('pageshow', (event) => {
  if (event.persisted) {
    console.log('Page was restored from bfcache!');
    console.log('CLS for this navigation: 0');
  } else {
    console.log('Page was loaded normally');
  }
});

// Check NotRestoredReasons API (Chrome 123+)
// This tells you WHY bfcache failed
window.addEventListener('pageshow', (event) => {
  if (performance.getEntriesByType) {
    const navEntry = performance.getEntriesByType('navigation')[0];
    if (navEntry && navEntry.notRestoredReasons) {
      console.log('bfcache blocked by:', navEntry.notRestoredReasons);
      // Example output:
      // { reasons: ["unload-handler"], children: [] }
    }
  }
});

علاوه بر کد، می‌تونید در Chrome DevTools به تب Application برید و بخش Back/forward cache رو ببینید. اونجا دکمه‌ای هست که بهتون اجازه میده bfcache رو تست کنید و دلایل عدم واجد شرایط بودن رو مشاهده کنید.

طبق داده‌های گوگل، فعال‌سازی bfcache می‌تونه CLS رو برای ۱۵ تا ۲۰ درصد ناوبری‌ها (یعنی ناوبری‌های back/forward) به صفر برسونه. صادقانه بگم، این عدد کوچکی نیست — مخصوصاً اگه مثل من وسواس اعداد Core Web Vitals داشته باشید.

تکنیک ۶: View Transitions API

View Transitions API یکی از هیجان‌انگیزترین قابلیت‌های مرورگرها در سال ۲۰۲۶ هست. این API به شما اجازه میده انتقال بصری نرم بین حالات مختلف صفحه ایجاد کنید — بدون اینکه layout shift اتفاق بیفته.

ایده‌ش ساده‌ست: به جای اینکه محتوا ناگهان عوض بشه و layout بپره، مرورگر یه snapshot از حالت قبلی می‌گیره، تغییر رو اعمال می‌کنه و بعد یه انیمیشن نرم بین دو حالت اجرا می‌کنه.

// Basic View Transition for DOM updates
async function updateContent(newData) {
  // Check if View Transitions API is supported
  if (!document.startViewTransition) {
    // Fallback: just update directly
    renderNewContent(newData);
    return;
  }

  // Start a view transition
  const transition = document.startViewTransition(() => {
    renderNewContent(newData);
  });

  // Wait for transition to finish
  await transition.finished;
}

function renderNewContent(data) {
  document.getElementById('content').innerHTML = `
    <h2>${data.title}</h2>
    <p>${data.body}</p>
  `;
}

// SPA-style navigation with View Transitions
async function navigateTo(url) {
  const response = await fetch(url);
  const html = await response.text();

  const transition = document.startViewTransition(() => {
    document.getElementById('main-content').innerHTML = html;
  });

  await transition.finished;
}

و برای استایل‌دهی انتقال:

<style>
/* Customize the view transition animation */
::view-transition-old(root) {
  animation: fade-out 0.2s ease-out;
}

::view-transition-new(root) {
  animation: fade-in 0.3s ease-in;
}

@keyframes fade-out {
  from { opacity: 1; }
  to { opacity: 0; }
}

@keyframes fade-in {
  from { opacity: 0; }
  to { opacity: 1; }
}

/* Named transitions for specific elements */
.hero-image {
  view-transition-name: hero;
}

::view-transition-old(hero) {
  animation: scale-down 0.3s ease-out;
}

::view-transition-new(hero) {
  animation: scale-up 0.3s ease-in;
}

@keyframes scale-down {
  from { transform: scale(1); }
  to { transform: scale(0.8); opacity: 0; }
}

@keyframes scale-up {
  from { transform: scale(0.8); opacity: 0; }
  to { transform: scale(1); opacity: 1; }
}
</style>

برای ناوبری بین صفحات (MPA — Multi Page Application)، کروم از Cross-document View Transitions هم پشتیبانی می‌کنه. فقط کافیه این meta tag رو به صفحاتتون اضافه کنید:

<!-- Enable cross-document view transitions -->
<meta name="view-transition" content="same-origin">

چرا View Transitions برای CLS مهمه؟ چون بدون این API، وقتی محتوای صفحه عوض میشه (مثلاً در SPA)، تغییر ناگهانی layout اتفاق میفته. با View Transitions، مرورگر تغییرات رو از طریق انیمیشن‌های compositor-only مدیریت می‌کنه که CLS تولید نمی‌کنن.

اندازه‌گیری و دیباگ CLS

بهینه‌سازی بدون اندازه‌گیری مثل رانندگی با چشم بسته‌ست. بیاید ببینیم چه ابزارهایی برای پیدا کردن و رفع مشکلات CLS در اختیار داریم.

Chrome DevTools — Performance Panel

بهترین ابزار برای دیباگ CLS، پنل Performance در Chrome DevTools هست. مراحل کار:

  1. DevTools رو باز کنید (F12 یا Ctrl+Shift+I)
  2. به تب Performance برید
  3. ضبط رو شروع کنید و صفحه رو رفرش کنید
  4. بعد از بارگذاری کامل، ضبط رو متوقف کنید
  5. در بخش Experience، دنبال مارکرهای «Layout Shift» بگردید
  6. روی هر مارکر کلیک کنید تا ببینید کدوم المان جابجا شده و چقدر

یه نکته مفید: در تنظیمات ضبط، گزینه Web Vitals رو فعال کنید تا مارکرهای CLS واضح‌تر نمایش داده بشن.

Layout Instability API

برای اندازه‌گیری CLS در محیط واقعی (RUM)، از Layout Instability API استفاده کنید. این API هر جابجایی layout رو به صورت جداگانه گزارش میده:

// Monitor all layout shifts in real-time
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    // Only count shifts that are NOT user-initiated
    if (!entry.hadRecentInput) {
      console.log('Layout shift detected:', {
        value: entry.value,
        sources: entry.sources?.map(source => ({
          node: source.node,
          previousRect: source.previousRect,
          currentRect: source.currentRect
        }))
      });
    }
  }
});

observer.observe({ type: 'layout-shift', buffered: true });

// Calculate CLS using session window approach
let clsValue = 0;
let clsEntries = [];
let sessionValue = 0;
let sessionEntries = [];
let lastEntryTime = 0;

const clsObserver = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (!entry.hadRecentInput) {
      const gap = entry.startTime - lastEntryTime;
      const sessionDuration = entry.startTime -
        (sessionEntries.length ? sessionEntries[0].startTime : 0);

      // Start new session if gap > 1s or session > 5s
      if (gap > 1000 || sessionDuration > 5000) {
        // Check if previous session was the worst
        if (sessionValue > clsValue) {
          clsValue = sessionValue;
          clsEntries = [...sessionEntries];
        }
        sessionValue = 0;
        sessionEntries = [];
      }

      sessionValue += entry.value;
      sessionEntries.push(entry);
      lastEntryTime = entry.startTime;
    }
  }

  // Update CLS if current session is the worst
  if (sessionValue > clsValue) {
    clsValue = sessionValue;
    clsEntries = [...sessionEntries];
  }

  console.log('Current CLS:', clsValue.toFixed(4));
});

clsObserver.observe({ type: 'layout-shift', buffered: true });

این کد دقیقاً همون الگوریتم session window رو پیاده‌سازی می‌کنه و CLS واقعی صفحه رو محاسبه می‌کنه. خاصیت sources هم بهتون میگه کدوم المان‌ها جابجا شدن و مختصات قبل و بعدشون چی بوده — واقعاً برای دیباگ طلاست.

کتابخانه web-vitals

اگه نمی‌خواید خودتون الگوریتم session window رو پیاده‌سازی کنید (و صادقانه بگم، معمولاً لازم نیست)، کتابخانه web-vitals گوگل همه‌چیز رو براتون مدیریت می‌کنه:

import { onCLS } from 'web-vitals';

// Simple usage
onCLS((metric) => {
  console.log('CLS:', metric.value);
  console.log('Rating:', metric.rating); // 'good', 'needs-improvement', 'poor'
});

// Detailed usage with attribution
import { onCLS } from 'web-vitals/attribution';

onCLS((metric) => {
  const attribution = metric.attribution;

  console.log('CLS:', metric.value);
  console.log('Largest shift target:', attribution.largestShiftTarget);
  console.log('Largest shift value:', attribution.largestShiftValue);
  console.log('Largest shift time:', attribution.largestShiftTime);
  console.log('Load state:', attribution.loadState);
  // loadState can be: 'loading', 'dom-interactive',
  // 'dom-content-loaded', 'complete'

  // Send to your analytics
  navigator.sendBeacon('/api/vitals', JSON.stringify({
    name: 'CLS',
    value: metric.value,
    rating: metric.rating,
    attribution: {
      target: attribution.largestShiftTarget,
      value: attribution.largestShiftValue,
      loadState: attribution.loadState
    }
  }));
});

نسخه attribution بهتون میگه بزرگ‌ترین جابجایی مربوط به چه المانی بوده و در چه مرحله‌ای از بارگذاری صفحه رخ داده. برای شناسایی علت ریشه‌ای مشکل، این اطلاعات فوق‌العاده مفیدن.

Lighthouse و PageSpeed Insights

Lighthouse در Chrome DevTools و PageSpeed Insights هر دو CLS رو اندازه‌گیری می‌کنن. ولی یه نکته مهم هست که باید بدونید: این ابزارها Lab data ارائه میدن — یعنی شبیه‌سازی بارگذاری در شرایط کنترل‌شده. CLS واقعی کاربران (Field data) ممکنه متفاوت باشه.

چرا؟

  • Lighthouse صفحه رو فقط تا لحظه بارگذاری کامل بررسی می‌کنه. جابجایی‌هایی که بعد از اسکرول اتفاق میفتن رو نمی‌بینه.
  • تبلیغات و محتوای شخص ثالث ممکنه در محیط آزمایشگاهی متفاوت عمل کنن.
  • ابعاد viewport و سرعت شبکه ممکنه با شرایط واقعی کاربران فرق داشته باشه.

پس همیشه هم Lab data و هم Field data (از CrUX یا RUM خودتون) رو بررسی کنید. هر کدوم داستان متفاوتی تعریف می‌کنن.

چک‌لیست نهایی بهینه‌سازی CLS

خب، بیاید همه تکنیک‌هایی که بررسی کردیم رو در یه چک‌لیست عملی خلاصه کنیم. پیشنهاد می‌کنم این لیست رو برای هر صفحه کلیدی سایتتون مرور کنید.

تصاویر و رسانه

  1. تمام تصاویر <img> دارای width و height هستن
  2. تمام ویدیوها <video> دارای ابعاد مشخص هستن
  3. از aspect-ratio برای المان‌های ریسپانسیو استفاده شده
  4. iframe‌ها و embed‌ها در container با ابعاد مشخص قرار دارن

فونت‌ها

  1. فونت‌های وب با font-display: swap یا optional بارگذاری میشن
  2. فونت fallback با size-adjust و override descriptors تنظیم شده
  3. فونت اصلی با <link rel="preload"> پیش‌بارگذاری میشه

محتوای پویا

  1. اسلات‌های تبلیغاتی min-height مناسب دارن
  2. بنرها و اعلامیه‌ها از position: fixed استفاده می‌کنن
  3. از contain: layout برای ایزوله کردن محتوای شخص ثالث استفاده شده
  4. skeleton screen‌ها ابعاد نزدیک به محتوای واقعی دارن

انیمیشن‌ها

  1. انیمیشن‌ها از transform و opacity استفاده می‌کنن (نه width/height/margin)
  2. هیچ انیمیشنی خاصیت‌های layout-triggering رو متحرک نمی‌کنه

عملکرد مرورگر

  1. هیچ event listener برای unload وجود نداره (برای bfcache)
  2. سرور header Cache-Control: no-store ارسال نمی‌کنه (مگه برای داده‌های حساس)
  3. از content-visibility: auto برای بخش‌های خارج viewport استفاده شده
  4. View Transitions API برای انتقال بین حالات فعال شده

مانیتورینگ

  1. CLS با کتابخانه web-vitals در محیط واقعی اندازه‌گیری میشه
  2. Lab و Field data هر دو بررسی میشن
  3. هشدار (alert) برای بدتر شدن CLS تنظیم شده

جمع‌بندی: تکمیل پازل Core Web Vitals

با خوندن این مقاله، حالا تصویر کاملی از هر چهار معیار کلیدی Core Web Vitals دارید:

  • TTFB: سرور چقدر سریع پاسخ میده — پایه و زیربنای همه‌چیز. تکنیک‌های Edge Computing و CDN.
  • LCP: محتوای اصلی چقدر سریع نمایش داده میشه — اولین برداشت کاربر. اولویت‌بندی بارگذاری، فرمت‌های مدرن تصاویر و Streaming SSR.
  • INP: صفحه چقدر سریع به تعاملات پاسخ میده — تجربه مداوم کاربر. شکستن Long Tasks، Web Workers و بهینه‌سازی event handler‌ها.
  • CLS: صفحه چقدر پایدار و بی‌لرزشه — اعتماد بصری کاربر. ابعاد رسانه، بهینه‌سازی فونت، رزرو فضا و انیمیشن‌های compositor-only.

نکته‌ای که می‌خوام تأکید کنم اینه: این معیارها مستقل از هم نیستن. بهینه‌سازی TTFB مستقیماً LCP رو بهبود میده. بهینه‌سازی فونت‌ها هم روی LCP و هم روی CLS تأثیر داره. کاهش اسکریپت‌های سنگین هم INP رو بهتر می‌کنه و هم احتمال layout shift‌های ناشی از late-loading محتوا رو کم می‌کنه.

رویکرد بهینه‌سازی باید جامع باشه.

یه آمار نهایی: طبق داده‌های CrUX، فقط حدود ۴۷ درصد سایت‌ها تمام آستانه‌های Core Web Vitals رو پاس می‌کنن. بیش از نصف وب هنوز جای بهبود جدی داره. ولی خبر خوب اینه که با پیاده‌سازی تکنیک‌هایی که توی این مجموعه مقالات بررسی کردیم، می‌تونید سایتتون رو به اون نیمه بالای جدول ببرید.

پیشنهاد من اینه: از CLS شروع کنید. معمولاً ساده‌ترین بهبودها (اضافه کردن ابعاد تصاویر، تنظیم فونت fallback) بزرگ‌ترین تأثیر رو دارن. بعد LCP، بعد INP و بعد TTFB. ولی مهم‌تر از ترتیب، اندازه‌گیری مداوم هست — بدون مانیتورینگ Real User، نمی‌تونید بفهمید کجا هستید و کجا باید برید.

عملکرد وب یه مسیر مداومه، نه یه مقصد. هر هفته ابزارهای جدید، APIهای مرورگر جدید و تکنیک‌های جدید معرفی میشن. ولی اصول پایه‌ای که توی این مجموعه مقالات یاد گرفتید — اندازه‌گیری، تحلیل، بهینه‌سازی و تکرار — همیشه ثابت خواهند بود.

حالا برید و سایتتون رو سریع‌تر، پاسخگوتر و پایدارتر کنید. موفق باشید!

درباره نویسنده Editorial Team

Our team of expert writers and editors.