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.
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-innerhas 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
widthandheightattributes. Bootstrap's.img-fluidclass setsmax-width:100%; height:auto, which correctly scales images responsively, but relies on the browser knowing the intrinsic dimensions. WithoutwidthandheightHTML 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-openclass is applied tobody, which setsoverflow: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-facerule that defaults tofont-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.
.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.
<!-- No height reservation — browser reflows on load -->
<iframe
src="https://www.youtube.com/embed/dQw4w9WgXcQ"
frameborder="0"
allowfullscreen>
</iframe>
<!-- 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.
<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>
<!-- 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.
/* 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.
/* 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.
// 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.
/* 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.
// 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 });
// 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.
/* 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.
// 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-heighton the wrong carousel element. Themin-heightmust go on.carousel-inner, not on.carouselor.carousel-item. Bootstrap's CSS positions.carousel-itemelements absolutely inside.carousel-inner, so the inner element is what determines the container height. - Using
overflow: hiddenonhtmlinstead ofbodyfor modal fix. Settingoverflow: hiddenon thehtmlelement also hides the scrollbar, but it prevents thescrollbar-gutterproperty from taking effect. Keepscrollbar-gutter: stableonhtmland let Bootstrap applyoverflow: hiddentobodyonly. - Forgetting
fetchpriority="high"on the first carousel image. Addingwidthandheightattributes 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. Addfetchpriority="high"to the active slide'simgelement to bring it forward in the preload queue. - Overriding Bootstrap's modal padding compensation without
scrollbar-gutter. Some developers overridebody.modal-open { padding-right: 0 !important; }to fix visual issues without first addingscrollbar-gutter: stable. This re-introduces the full scrollbar shift. Always addscrollbar-gutter: stabletohtmlbefore removing Bootstrap's padding compensation. - Applying
font-display: swapto Bootstrap Icons without preloading the font file.font-display: swapreduces 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
iframeandvideoembeds wrapped in.ratio.ratio-16x9or appropriate variant -
All
imgelements have explicitwidthandheightattributes matching intrinsic dimensions -
First carousel image has
<link rel="preload">in<head>andfetchpriority="high"on theimg -
.carousel-innerhas an explicitmin-heightand all slide images use consistent dimensions withobject-fit: cover -
html { scrollbar-gutter: stable; }applied;body.modal-open { padding-right: 0 !important; }if needed -
Form validation elements use
visibility: hidden(notdisplay: none) so space is pre-reserved -
All Bootstrap JS components (Tooltip, Popover, Dropdown) initialized at
DOMContentLoaded, not on scroll -
Bootstrap Icons
@font-faceoverridden withfont-display: swapand font preloaded in<head> -
Auto-dismissing alerts delay at least 1,500ms after
DOMContentLoadedbefore collapsing
Related resources
Fix CLS in WordPress
Eliminate layout shift from WordPress themes, plugins, and block editor content.
FixFix CLS in Shopify
Stop Shopify section swaps, cart drawers, and app embeds from shifting your layout.
GuideComplete CLS Guide
The comprehensive reference for understanding and measuring Cumulative Layout Shift.
FixFont Loading and CLS
Control FOIT, FOUT, and font-display strategies to eliminate web font layout shifts.
Continue learning
Animation Performance and CLS
Use transform and opacity for Bootstrap transitions without triggering layout shift.
GuideCSS Performance Guide
Reduce render-blocking CSS, critical path extraction, and layout containment strategies.
FixFix CLS in WordPress
Many WordPress themes use Bootstrap. Combine both guides for comprehensive CLS coverage.
GuideCLS Measurement Techniques
Field vs. lab data, session windows, and how Chrome attributes shift scores to elements.