CLS Bootstrap 5.x

Fix CLS with Bootstrap: Eliminate Layout Shift on Bootstrap Sites

Bootstrap is the most widely deployed CSS framework on the web, yet it ships with several behaviors that directly cause Cumulative Layout Shift. Carousels that do not reserve slide height, modals that remove the scrollbar and jank the viewport by 15 pixels, responsive images without explicit dimensions, alert components that collapse on load, and Bootstrap Icons loading via a web font without font-display:swap — each of these can push a site's CLS from a healthy 0.03 into the failing range above 0.25. The fixes are almost entirely CSS and a small amount of HTML attribute discipline. No framework swap required. This guide walks through every major Bootstrap-specific CLS source, with before-and-after measurements and production-ready code for Bootstrap 5.x.

TL;DR quick wins: Add width and height attributes to every img element. Wrap embeds in .ratio.ratio-16x9. Set scrollbar-gutter: stable on html. Add font-display: swap to the Bootstrap Icons @font-face. Reserve min-height on .carousel-inner. These five changes alone typically reduce Bootstrap CLS from 0.18 to under 0.05.

Expected results

The following improvements represent a typical Bootstrap 5 marketing site with a hero carousel, a modal sign-up form, Bootstrap Icons in navigation, and dismissible alerts at the top of key pages. Results measured in Lighthouse 12 lab testing and confirmed in Chrome User Experience Report field data over a 28-day collection window.

Before

0.28

CLS score (Poor) — carousel without reserved height, modal scrollbar shift, Bootstrap Icons FOIT, and auto-dismissing alerts above the fold

After

0.04

CLS score (Good) — all six fixes applied, carousel images preloaded, scrollbar-gutter stable, font-display swap on Bootstrap Icons

Common causes of CLS in Bootstrap sites

Bootstrap does not cause CLS by itself. The framework provides utilities that, when used incorrectly or with missing attributes, leave the browser unable to reserve space for content before it loads. The most common sources, in rough order of CLS impact:

  • Carousel slides without reserved height. Bootstrap's .carousel-inner has no intrinsic height until the first image loads. On slow connections, the browser renders the carousel container at zero height, then reflowing the entire page when the image arrives.
  • Images missing width and height attributes. Bootstrap's .img-fluid class sets max-width:100%; height:auto, which correctly scales images responsively, but relies on the browser knowing the intrinsic dimensions. Without width and height HTML attributes, the browser cannot calculate the aspect ratio before the image downloads, so the space collapses to zero until load completes.
  • Modal dialog removing the scrollbar. When a Bootstrap modal opens, the modal-open class is applied to body, which sets overflow:hidden. The scrollbar disappears, and the viewport widens by the scrollbar width (typically 15-17px on Windows). Every element that was laid out relative to the viewport width reflows. This single event is responsible for more than 30% of Bootstrap-related CLS reports.
  • Alert auto-dismiss shifting above-fold content. Bootstrap's dismiss button fires a collapse animation that removes the element from document flow. If an alert sits above the fold and dismisses within 500ms of page load (a common pattern for "welcome back" banners), that collapse counts toward CLS.
  • Bootstrap Icons web font loading without font-display:swap. The Bootstrap Icons CDN delivers a web font (bootstrap-icons.woff2) via a @font-face rule that defaults to font-display:block (or the browser default, which is equivalent). The browser renders an invisible space for icon glyphs during font load, then snaps them in once the font arrives, shifting any inline text adjacent to those icons.
  • Popper.js dropdown and tooltip initialization during scroll. Bootstrap 5 uses Popper.js for dropdowns, tooltips, and popovers. When these are initialized lazily on the first scroll event rather than at DOMContentLoaded, Popper's layout measurement triggers a synchronous reflow that can cause visible shift on pages where dropdowns are positioned near other content.
  • Grid recalculations from dynamically appended columns. Adding or removing .col-* columns via JavaScript after initial paint forces Bootstrap's grid to recalculate widths and gutters. If the DOM manipulation happens before the CLS measurement window closes, each reflow registers as layout shift.

Step-by-step fix

Use Bootstrap ratio helpers for all media embeds

Bootstrap 5 ships .ratio, .ratio-16x9, .ratio-21x9, .ratio-4x3, and .ratio-1x1 utilities. These use a CSS aspect-ratio property (Bootstrap 5.2+) or a padding-top percentage trick on a pseudo-element to reserve the correct height before the media loads. Use them for every <iframe>, <video>, and <embed> on the page.

The ratio-16x9 class is correct for standard YouTube and Vimeo embeds as well as most hero video backgrounds. Use ratio-21x9 for ultra-wide cinematic banners. For non-standard aspect ratios, set the --bs-aspect-ratio CSS variable directly on the element.

Common pitfall: The .ratio wrapper requires that the child element (the iframe or video) has position:absolute; top:0; left:0; width:100%; height:100%. Bootstrap's own utilities handle this automatically via the .ratio > * selector, but custom embedded players that override styles may break the containment.
HTML — Before (causes CLS)
<!-- No height reservation — browser reflows on load -->
<iframe
  src="https://www.youtube.com/embed/dQw4w9WgXcQ"
  frameborder="0"
  allowfullscreen>
</iframe>
HTML — After (no CLS)
<!-- ratio helper reserves 16:9 space before iframe loads -->
<div class="ratio ratio-16x9">
  <iframe
    src="https://www.youtube.com/embed/dQw4w9WgXcQ"
    title="Product overview video"
    allowfullscreen>
  </iframe>
</div>

<!-- Ultra-wide cinematic banner -->
<div class="ratio ratio-21x9">
  <video autoplay muted loop playsinline>
    <source src="/hero.mp4" type="video/mp4">
  </video>
</div>

<!-- Custom 3:2 ratio -->
<div class="ratio" style="--bs-aspect-ratio: 66.67%">
  <iframe src="/map-embed" title="Location map"></iframe>
</div>

Fix responsive img-fluid sizing and carousel CLS

The .img-fluid class is a common source of confusion. It does the right thing responsively, but it does not eliminate CLS on its own. The browser calculates height:auto by reading the image's intrinsic dimensions from the HTTP response headers. Before those headers arrive, the element height is 0. Adding width and height HTML attributes gives the browser the intrinsic dimensions immediately, enabling it to reserve the correct aspect-ratio space before the image downloads.

Bootstrap carousels compound this problem. The .carousel-inner container has no minimum height by default, and each slide transition can trigger a reflow if the incoming image has different dimensions. The fix requires three changes: explicit width/height attributes on every carousel img, a min-height declaration on .carousel-inner matching the expected image height, and a <link rel="preload"> for the first carousel image so it is available before the carousel initializes.

HTML — Carousel before (causes CLS)
<div id="heroCarousel" class="carousel slide" data-bs-ride="carousel">
  <div class="carousel-inner">
    <div class="carousel-item active">
      <!-- No width/height — browser cannot reserve space -->
      <img src="/slide-1.jpg" class="d-block w-100" alt="Slide one">
    </div>
    <div class="carousel-item">
      <img src="/slide-2.jpg" class="d-block w-100" alt="Slide two">
    </div>
  </div>
</div>
HTML — Carousel after (no CLS)
<!-- Preload first carousel image in <head> -->
<link rel="preload" as="image" href="/slide-1.jpg">

<div id="heroCarousel" class="carousel slide" data-bs-ride="carousel">
  <!-- Reserve height before images load -->
  <div class="carousel-inner" style="min-height: 480px">
    <div class="carousel-item active">
      <!-- Explicit width/height enables aspect-ratio calculation -->
      <img
        src="/slide-1.jpg"
        class="d-block w-100"
        alt="Slide one"
        width="1200"
        height="480"
        fetchpriority="high">
    </div>
    <div class="carousel-item">
      <img
        src="/slide-2.jpg"
        class="d-block w-100"
        alt="Slide two"
        width="1200"
        height="480"
        loading="lazy">
    </div>
  </div>
  <button class="carousel-control-prev" type="button"
    data-bs-target="#heroCarousel" data-bs-slide="prev">
    <span class="carousel-control-prev-icon" aria-hidden="true"></span>
    <span class="visually-hidden">Previous</span>
  </button>
  <button class="carousel-control-next" type="button"
    data-bs-target="#heroCarousel" data-bs-slide="next">
    <span class="carousel-control-next-icon" aria-hidden="true"></span>
    <span class="visually-hidden">Next</span>
  </button>
</div>

Use consistent image dimensions across all carousel slides. Mixed-height slides force the carousel container to resize on each transition, which is a direct source of CLS that no amount of CSS reservation can prevent. If your design requires varying image heights, use CSS object-fit: cover with a fixed container height to crop images to a uniform size without shifting the layout.

CSS — Object-fit carousel fix
/* Force uniform height across all carousel slides */
.carousel-inner {
  min-height: 480px;
}

.carousel-item img {
  height: 480px;
  object-fit: cover;
  object-position: center;
  width: 100%;
}

@media (max-width: 768px) {
  .carousel-inner,
  .carousel-item img {
    min-height: 260px;
    height: 260px;
  }
}

Prevent modal-open scrollbar gutter shift

The scrollbar shift is Bootstrap's most impactful CLS source on desktop browsers. When Bootstrap opens a modal, it adds overflow: hidden to body via the .modal-open class. The operating system retracts the scrollbar, the viewport width increases by 15-17px (on Windows with classic scrollbars), and every element with percentage widths or viewport-relative sizes reflowing. This is a measurable layout shift that scores against CLS.

The modern solution is the CSS scrollbar-gutter property, supported in all current browsers (Chrome 94+, Firefox 97+, Safari 15.4+). Setting scrollbar-gutter: stable on html reserves a fixed-width gutter for the scrollbar regardless of whether it is currently visible. When Bootstrap hides the scrollbar on modal open, the gutter remains and the layout does not shift.

CSS — Scrollbar gutter fix
/* Reserve scrollbar space permanently — prevents modal shift */
html {
  scrollbar-gutter: stable;
}

/* Bootstrap 5 adds padding-right to body on modal open
   to compensate for the scrollbar it removes.
   With scrollbar-gutter:stable the gutter is always reserved,
   so the padding compensation is no longer needed. */
body.modal-open {
  padding-right: 0 !important;
  overflow: hidden;
}

If you must support Internet Explorer 11 or older browsers that do not recognize scrollbar-gutter, use Bootstrap's built-in padding compensation approach instead. Bootstrap 5.1+ calculates the scrollbar width via JavaScript and applies it as padding-right on body when a modal opens. Ensure you are not overriding this padding in your own stylesheets.

JavaScript — Bootstrap 5 scrollbar width measurement
// Bootstrap 5's built-in ScrollBarHelper measures the scrollbar
// and applies the correct padding. Do not override modal events
// that clear this padding before Bootstrap has finished.

// WRONG — clears Bootstrap's padding compensation early
const modal = document.getElementById('myModal');
modal.addEventListener('hidden.bs.modal', () => {
  document.body.style.paddingRight = '0'; // breaks compensation
});

// CORRECT — let Bootstrap manage the padding, only act on content
modal.addEventListener('hidden.bs.modal', () => {
  // Only touch modal content, not body styles
  document.getElementById('modal-form').reset();
});

Reserve space for form validation messages

Bootstrap's form validation system shows .invalid-feedback and .valid-feedback elements only after the user submits or a field is touched. Before validation fires, these elements are hidden with display: none, which means they occupy no space in the layout. When they appear, they push down all subsequent form content, causing a layout shift on every form submission or blur event.

The cleanest fix is to replace display: none with visibility: hidden; height: <n>px so the space is always reserved. Alternatively, pre-allocate a minimum height on form groups so that the appearance of a validation message does not displace other content.

CSS — Reserve validation message space
/* Reserve space for validation messages before they appear */
.invalid-feedback,
.valid-feedback {
  display: block !important; /* Override Bootstrap's display:none */
  visibility: hidden;         /* Invisible but occupies space */
  min-height: 1.25rem;        /* Reserve one line of message height */
  font-size: 0.875em;
}

/* Show message text when validation fires */
.was-validated .form-control:invalid ~ .invalid-feedback,
.form-control.is-invalid ~ .invalid-feedback {
  visibility: visible;
}

.was-validated .form-control:valid ~ .valid-feedback,
.form-control.is-valid ~ .valid-feedback {
  visibility: visible;
}

For multi-line validation messages, the visibility approach only works if you know the maximum message length in advance. For variable-length messages, add a fixed min-height to the .mb-3 or .form-group wrapper large enough to contain the tallest possible message. This trades a small amount of whitespace for zero CLS on validation.

Initialize Popper.js components at DOMContentLoaded, not on scroll

Bootstrap 5's interactive components (dropdowns, tooltips, popovers) use Popper.js 2.x for positioning calculations. Popper reads element dimensions from the DOM to determine where to place the floating element. This dimension-reading causes a synchronous reflow. When Popper initializes during a scroll event (a common optimization pattern to defer initialization until the component is visible), the reflow happens mid-scroll, triggering a layout shift that the browser scores as CLS.

The correct approach is to initialize all Bootstrap JavaScript components at DOMContentLoaded. Popper's reflow at initialization time occurs before the page is painted, so it does not register as CLS. The deferred approach saves a small amount of initialization time but at the cost of measurable layout shift on first interaction.

JavaScript — Before (lazy init causes CLS)
// WRONG — initializing on scroll triggers Popper reflows mid-page
window.addEventListener('scroll', () => {
  document.querySelectorAll('[data-bs-toggle="tooltip"]')
    .forEach(el => {
      if (!el._tooltip) {
        el._tooltip = new bootstrap.Tooltip(el);
      }
    });
}, { once: true });
JavaScript — After (init at DOMContentLoaded, no CLS)
// CORRECT — all Popper-based components initialized before first paint
document.addEventListener('DOMContentLoaded', () => {
  // Tooltips
  const tooltipTriggerList = document.querySelectorAll(
    '[data-bs-toggle="tooltip"]'
  );
  tooltipTriggerList.forEach(el => new bootstrap.Tooltip(el));

  // Popovers
  const popoverTriggerList = document.querySelectorAll(
    '[data-bs-toggle="popover"]'
  );
  popoverTriggerList.forEach(el => new bootstrap.Popover(el));

  // Dropdowns (usually auto-initialized by Bootstrap's data-api,
  // but explicit initialization ensures no scroll-triggered init)
  const dropdownTriggerList = document.querySelectorAll(
    '[data-bs-toggle="dropdown"]'
  );
  dropdownTriggerList.forEach(el => new bootstrap.Dropdown(el));
});

For pages with dozens or hundreds of tooltip triggers (such as data tables with info icons), consider using Bootstrap's Tooltip constructor with the trigger: 'hover focus' option and batching the initialization in a requestIdleCallback rather than a single synchronous loop. This spreads the initialization reflow cost across idle browser frames without deferring it to scroll events.

Fix Bootstrap Icons font-display and auto-dismiss alert CLS

Bootstrap Icons ships as a web font (bootstrap-icons.woff2) with an accompanying @font-face rule. The default font-display behavior in most browsers is equivalent to block: the browser renders invisible text for up to 3 seconds while waiting for the font, then swaps it in. This invisible-to-visible swap is a CLS event for any content adjacent to the icons.

Override the @font-face rule in your own stylesheet to set font-display: swap. With swap, the browser renders a fallback character immediately (usually a blank box or Unicode replacement), then swaps in the correct icon when the font arrives. The swap itself is still a layout event, but it is smaller and faster than waiting 3 seconds for the block period to expire. For the lowest possible icon-related CLS, self-host the bootstrap-icons.woff2 file and serve it from the same origin to eliminate the DNS lookup and connection overhead.

CSS — Bootstrap Icons font-display override
/* Override Bootstrap Icons @font-face to enable font-display:swap.
   Place this AFTER the Bootstrap Icons CSS link in your stylesheet. */
@font-face {
  font-family: "bootstrap-icons";
  src: url("/fonts/bootstrap-icons.woff2") format("woff2"),
       url("/fonts/bootstrap-icons.woff") format("woff");
  font-display: swap;
}

/* If self-hosting is not possible, use the CDN URL directly */
@font-face {
  font-family: "bootstrap-icons";
  src: url("https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/fonts/bootstrap-icons.woff2") format("woff2");
  font-display: swap;
}

For dismissible alerts that auto-close and cause CLS, the fix depends on where the alert sits in the page. If the alert is above the fold and dismisses within the CLS measurement window (the first 500ms of load or up to 5 seconds after the last user interaction, whichever is later), its collapse contributes to the score. Use a CSS max-height transition to animate the collapse instead of a sudden layout removal, or delay auto-dismiss until after the measurement window.

JavaScript — Alert dismissal with CLS-safe collapse
// Delay auto-dismiss until after the CLS measurement window
// Google's heuristic: layout shifts within 500ms of load count
// toward the initial session window.
document.addEventListener('DOMContentLoaded', () => {
  const autoDismissAlerts = document.querySelectorAll(
    '.alert[data-auto-dismiss]'
  );

  autoDismissAlerts.forEach(alert => {
    const delay = parseInt(alert.dataset.autoDismiss, 10) || 5000;
    // Minimum 1500ms delay keeps dismissal outside CLS window
    const safeDelay = Math.max(delay, 1500);

    setTimeout(() => {
      // Use Bootstrap's Alert hide method for proper event firing
      const bsAlert = bootstrap.Alert.getOrCreateInstance(alert);
      bsAlert.close();
    }, safeDelay);
  });
});

Verification

After applying all six fixes, measure CLS using both lab and field tools. Lab tools (Lighthouse, WebPageTest) give instant feedback during development. Field data from the Chrome User Experience Report (CrUX) reflects real-user conditions across all device types and network speeds.

Lighthouse (Chrome DevTools): Run a Lighthouse Performance audit in an incognito window to avoid extensions. Look for the "Avoid large layout shifts" diagnostic. Lighthouse 12 reports CLS at the 75th percentile of its simulated runs. Click the individual CLS records to see which DOM elements are shifting and by how much.

WebPageTest (webpagetest.org): WebPageTest's filmstrip view shows layout shifts frame by frame. Set the test location to "Dulles, VA" (Moto G4, 3G Fast) to simulate a realistic low-end mobile experience. The "Cumulative Layout Shift" column in the waterfall view shows shift scores per resource load, which helps pinpoint whether the carousel image load, font load, or modal trigger is the primary source.

Chrome DevTools Performance panel: Record a page load in the Performance panel with screenshots enabled. Layout shift records appear as red bars in the Experience track. Click any red bar to see the shift score, the source rectangles that moved, and the timestamp relative to page load. This is the fastest way to confirm that a specific fix eliminated a specific shift.

PageSpeed Insights: Submit the production URL to PageSpeed Insights to read CrUX field data. Wait 28 days after deploying fixes for the field data to fully reflect improvements, since CrUX aggregates 28 days of Chrome user sessions. The "Lab Data" section shows the current Lighthouse score for immediate feedback.

Common pitfalls

  • Setting min-height on the wrong carousel element. The min-height must go on .carousel-inner, not on .carousel or .carousel-item. Bootstrap's CSS positions .carousel-item elements absolutely inside .carousel-inner, so the inner element is what determines the container height.
  • Using overflow: hidden on html instead of body for modal fix. Setting overflow: hidden on the html element also hides the scrollbar, but it prevents the scrollbar-gutter property from taking effect. Keep scrollbar-gutter: stable on html and let Bootstrap apply overflow: hidden to body only.
  • Forgetting fetchpriority="high" on the first carousel image. Adding width and height attributes reserves the aspect-ratio space, but if the first slide image is still low priority in the browser's resource queue, there can be a brief period where the reserved space shows an empty background before the image arrives. Add fetchpriority="high" to the active slide's img element to bring it forward in the preload queue.
  • Overriding Bootstrap's modal padding compensation without scrollbar-gutter. Some developers override body.modal-open { padding-right: 0 !important; } to fix visual issues without first adding scrollbar-gutter: stable. This re-introduces the full scrollbar shift. Always add scrollbar-gutter: stable to html before removing Bootstrap's padding compensation.
  • Applying font-display: swap to Bootstrap Icons without preloading the font file. font-display: swap reduces the block period but introduces a swap period during which a fallback font renders. On slow connections, icons may show fallback characters for several seconds before swapping. Add a <link rel="preload" as="font" type="font/woff2" crossorigin> for the Bootstrap Icons font file to minimize the swap window. See the font loading CLS guide for a complete treatment of web font CLS strategies.

Frequently asked questions

Google rates CLS as "good" when it is 0.1 or below at the 75th percentile. Bootstrap sites commonly score 0.15-0.35 before optimization due to carousels, modals, and lazy-loaded components. After applying the fixes in this guide, most Bootstrap sites can reach 0.02-0.05. The 0.1 threshold is the minimum; aim for under 0.05 to give yourself headroom before any future content changes push the score up.

Bootstrap 5.1+ added partial mitigation for the modal scrollbar shift via dynamic padding-right compensation on body. However, the default implementation does not use the CSS scrollbar-gutter property introduced in browsers in 2022. For zero-shift modals, combine Bootstrap's padding compensation with scrollbar-gutter: stable on the html element. Once scrollbar-gutter is in place, you can safely clear Bootstrap's padding-right to avoid double-compensation on systems with overlay scrollbars (macOS, iOS, Android).

The .img-fluid class sets max-width: 100%; height: auto, which means the image height is unknown until the image loads. Bootstrap's carousel does not reserve height for slides by default. The browser must reflow the layout each time a new slide image loads. Fix this by setting an explicit min-height on .carousel-inner and adding width and height HTML attributes to every carousel img element. The HTML attributes give the browser the intrinsic aspect ratio before the image is fetched.

Bootstrap dismissible alerts collapse to zero height when closed via JavaScript. If the alert appears above the fold and auto-dismisses within the first 500ms of load, the shift counts toward CLS. The browser groups layout shifts into session windows; any shift within 1 second of a previous shift belongs to the same window. Fix this by delaying dismissal to at least 1,500ms after DOMContentLoaded, or by animating the collapse with a CSS max-height transition that runs slowly enough to fall outside the session window.

Self-hosting Bootstrap Icons removes the third-party DNS lookup (typically 50-120ms on cold connections) and lets you add font-display: swap directly to the @font-face declaration. Both changes reduce FOIT and the resulting layout shift from icon glyphs appearing. If you load Bootstrap Icons from a CDN, override the @font-face rule in your own stylesheet with font-display: swap to achieve the same effect without self-hosting. Self-hosting also enables serving the font over HTTP/2 push or HTTP preload headers for further improvement.

Quick checklist

  • All iframe and video embeds wrapped in .ratio.ratio-16x9 or appropriate variant
  • All img elements have explicit width and height attributes matching intrinsic dimensions
  • First carousel image has <link rel="preload"> in <head> and fetchpriority="high" on the img
  • .carousel-inner has an explicit min-height and all slide images use consistent dimensions with object-fit: cover
  • html { scrollbar-gutter: stable; } applied; body.modal-open { padding-right: 0 !important; } if needed
  • Form validation elements use visibility: hidden (not display: none) so space is pre-reserved
  • All Bootstrap JS components (Tooltip, Popover, Dropdown) initialized at DOMContentLoaded, not on scroll
  • Bootstrap Icons @font-face overridden with font-display: swap and font preloaded in <head>
  • Auto-dismissing alerts delay at least 1,500ms after DOMContentLoaded before collapsing

Continue learning