Fix the HubSpot Chat Widget Layout Shift
A workaround that drops a 0.91 CLS to 0.02 by exploiting two browser layout-shift exclusions

How to fix the layout shift caused by HubSpot chat
The HubSpot chat widget can cause a massive 0.91 CLS on a page. This is a well-known issue. SO i fexed it for you. You can use my drop-in script below that takes CLS p75 from 0.91 to 0.00. In a perfect wordt hubspot would fix it themselves. nbut they wont and I can change hubspot's code. So It is a hack, I know, But it works.
Table of Contents!
HubSpot Chat CLS is a common complaint
The HubSpot Community has multiple multi-year threads on the chat widget tanking CLS. The flagship one is Live Chat Causing CLS Issues, marked Solved but still receiving customer posts into 2024. One user reports the widget pushed their CLS from 0 to 0.407, four times over the threshold. A separate Low CLS performance issue thread in the SEO sub-forum tracks the same problem from a search-ranking angle, and an older Live Chat App slows page down thread runs even longer.
It is not just HubSpot's forum. There is a Google Search Console help thread that puts "Hubspot Chat" in the title, and third-party write-ups like Cronyx Digital's Is HubSpot Chat Slowing Down Your Website? go through the performance damage.
HubSpot has shipped payload and time-on-page changes after community pressure, but no official doc acknowledges the chat widget as a CLS source and no in-app setting fixes it.
Why it the CLS happening
The HubSpot chat widget renders inside an iframe with the id hubspot-messages-iframe-container. When a visitor taps the launcher, the container animates from launcher size (around 60 by 60 pixels) to full panel size (around 380 by 600 pixels). When the visitor closes it, the container animates back.
The Layout Instability API ties hadRecentInput to the document where the input event fired. Click events from inside the iframe land on the iframe's document. The layout shift lands on the parent document, where the iframe container resizes. Two different documents. The parent's hadRecentInput stays false.
Three shift moments to handle. On load, HubSpot mounts the launcher small (~100x96), then grows it to ~242x245 a second or two later to show the welcome bubble. No user input. Counts in full. On open, the click fires inside the iframe, so the parent never sees it. Counts in full. On close, same cross-frame issue, plus the close animation runs longer than 500ms so frames after the exclusion window count even when attribution is correct.
I have measured this on multiple client sites. A single open and close cycle can produce a 0.91 CLS contribution. That is enough to push a page from a passing score into the poor bucket all by itself.

The workaround
The fix has three parts, one for each shift moment.
Trick one: cloak the launcher growth on load. We set opacity: 0 on the iframe container the moment it appears and reveal it once the launcher has stopped resizing. Chrome 89 ignores rect changes on any element (or ancestor) whose computed opacity is 0 in both the current and previous frame. The launcher to welcome-bubble growth happens invisibly. Zero shift entries.
Trick two: catch the open click on the parent. We mount an invisible button over the HubSpot launcher. When the visitor taps, our button receives the event on the parent document, then calls HubSpotConversations.widget.open(). The parent now has a recent input. The widget animates open, the iframe shifts, Chrome flags the shift with hadRecentInput=true. Excluded.
Trick three: hide the close animation behind opacity. Same opacity 0 trick as Trick one. We set opacity: 0 synchronously inside the click handler, then call widget.close(). The close runs invisibly. After it finishes, we restore opacity. Zero shift entries.
That is the whole idea. The full code is below.
The code
Drop this script on your site. It runs once, mounts a transparent button over the HubSpot launcher, and handles open and close itself. No build step, no framework, no dependency. Vanilla JS.
(function () {
if (window.__cwvHsClsFix) return;
window.__cwvHsClsFix = true;
// Strategy:
// LOAD: cloak the iframe-container with opacity:0 the moment it appears,
// then reveal once HubSpot has finished its launcher to welcome-bubble
// expansion. Chrome's layout-shift algorithm skips rect changes on
// elements whose computed opacity is 0 in both frames, so the growth
// generates ZERO shift entries.
//
// OPEN: an invisible same-origin overlay catches the tap and calls
// widget.open(). The shift fires within the 500ms input grace window,
// so hadRecentInput=true and the shift is excluded from CLS.
//
// CLOSE: we set opacity:0 on the container SYNCHRONOUSLY (in the click
// handler), THEN call widget.close(). Same opacity-0 exclusion masks
// HubSpot's slow close animation. Once HubSpot finishes (back at
// launcher size), we restore opacity so the next click works.
//
// AUTO-OPEN: kill the hs-messages-is-open cookie before HubSpot reads
// it, so a previous "open" session never fires a no-input shift on
// page load.
function clearOpenCookie() {
var paths = ['/', location.pathname];
var domains = ['', location.hostname, '.' + location.hostname];
paths.forEach(function (p) {
domains.forEach(function (d) {
document.cookie = 'hs-messages-is-open=false; path=' + p +
(d ? '; domain=' + d : '') + '; max-age=86400; SameSite=Lax';
});
});
}
clearOpenCookie();
var Z = '2147483647';
var BASE = 'position:fixed;background:transparent;border:0;padding:0;' +
'margin:0;cursor:pointer;pointer-events:auto;' +
'z-index:' + Z + ';';
var tap = document.createElement('button');
tap.type = 'button';
tap.id = '__cwv_tap';
tap.setAttribute('aria-label', 'Open chat');
tap.style.cssText = BASE + 'right:0;bottom:0;width:100px;height:100px;';
document.body.appendChild(tap);
function getContainer() {
return document.getElementById('hubspot-messages-iframe-container');
}
function isOpen() {
var c = getContainer();
if (!c) return false;
var r = c.getBoundingClientRect();
return r.width >= 280 && r.height >= 280;
}
function place(rect, asClose) {
if (!rect) {
tap.style.cssText = BASE + 'right:0;bottom:0;width:100px;height:100px;';
tap.setAttribute('aria-label', 'Open chat');
return;
}
if (asClose) {
tap.style.cssText = BASE +
'left:' + (rect.right - 60) + 'px;top:' + rect.top + 'px;' +
'width:60px;height:60px;';
tap.setAttribute('aria-label', 'Close chat');
} else {
tap.style.cssText = BASE +
'left:' + rect.left + 'px;top:' + rect.top + 'px;' +
'width:' + rect.width + 'px;height:' + rect.height + 'px;';
tap.setAttribute('aria-label', 'Open chat');
}
}
function reposition() {
var c = getContainer();
if (!c) return place(null, false);
place(c.getBoundingClientRect(), isOpen());
}
// CLOAK ON LOAD: HubSpot mounts the launcher at ~100x96, sits there for
// ~1-2s, then grows to ~242x245 to show the welcome bubble. The launcher
// looks "stable" during that gap, so a short stability threshold reveals
// too early. We need a threshold longer than that gap. 2000ms covers it
// with margin; hard cap 7s in case of slow networks.
function cloakOnLoad() {
var c = getContainer();
if (!c) return setTimeout(cloakOnLoad, 50);
if (c.dataset.cwvCloaked) return;
c.dataset.cwvCloaked = '1';
c.style.opacity = '0';
var lastSig = '';
var stableTimer = null;
var revealed = false;
var revealRO = null;
var reveal = function () {
if (revealed) return;
revealed = true;
if (revealRO) try { revealRO.disconnect(); } catch (e) {}
var cur = getContainer();
if (cur) cur.style.opacity = '1';
reposition();
};
var check = function () {
if (revealed) return;
var cur = getContainer();
if (!cur) return;
var r = cur.getBoundingClientRect();
var sig = r.width + 'x' + r.height;
if (sig !== lastSig) {
lastSig = sig;
if (stableTimer) clearTimeout(stableTimer);
stableTimer = setTimeout(reveal, 2000);
}
};
if (window.ResizeObserver) {
revealRO = new ResizeObserver(check);
revealRO.observe(c);
}
check();
setTimeout(reveal, 7000);
}
cloakOnLoad();
var ro;
function watch() {
if (ro) try { ro.disconnect(); } catch (e) {}
var c = getContainer();
if (!c) return setTimeout(watch, 100);
if (window.ResizeObserver) {
ro = new ResizeObserver(reposition);
ro.observe(c);
}
reposition();
var t0 = Date.now();
var tick = function () {
reposition();
if (Date.now() - t0 < 1500) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}
watch();
if (window.MutationObserver) {
new MutationObserver(function () {
var c = getContainer();
if (c && (!ro || (ro._target && ro._target !== c))) watch();
}).observe(document.body, { childList: true });
}
function whenReady(fn) {
if (window.HubSpotConversations) return fn(window.HubSpotConversations);
(window.hsConversationsOnReady = window.hsConversationsOnReady || [])
.push(function () { fn(window.HubSpotConversations); });
}
function showChat() {
// If the cloak is still active, reveal now. Input-driven shifts are
// already excluded by hadRecentInput, and the user needs to see the chat.
var c = getContainer();
if (c) c.style.opacity = '1';
whenReady(function (HS) {
var status = HS.widget.status ? HS.widget.status() : null;
if (status && status.loaded === false) {
HS.widget.load({ widgetOpen: true });
} else {
HS.widget.open();
}
});
requestAnimationFrame(reposition);
}
function hideChat() {
var c = getContainer();
if (!c) return;
// CRITICAL: opacity:0 BEFORE widget.close() so the layout-shift API
// skips the close animation's rect change.
c.style.opacity = '0';
c.style.pointerEvents = 'none';
whenReady(function (HS) { HS.widget.close(); });
clearOpenCookie();
reposition();
setTimeout(function () {
var c2 = getContainer();
if (c2) {
c2.style.opacity = '1';
c2.style.pointerEvents = 'auto';
}
reposition();
}, 1200);
}
tap.addEventListener('click', function () {
if (isOpen()) hideChat();
else showChat();
});
whenReady(function (HS) {
if (HS.on) {
HS.on('widgetOpened', reposition);
HS.on('widgetClosed', function () { clearOpenCookie(); reposition(); });
}
window.addEventListener('resize', reposition);
window.addEventListener('scroll', reposition, { passive: true });
});
window.addEventListener('pagehide', clearOpenCookie);
window.addEventListener('beforeunload', clearOpenCookie);
})(); What each part does
The cookie kill at the top stops HubSpot's auto-open. The hs-messages-is-open cookie remembers the widget was open in a previous session and reopens it on the next page load. Auto-open fires with no recent input, so it counts toward CLS in full. Killing the cookie stops that.
cloakOnLoad sets opacity: 0 on the iframe container the moment it mounts. It uses a ResizeObserver to watch for size changes and reveals the container once dimensions stay stable for 2 seconds, with a 7-second hard cap as a safety net for slow networks. The launcher to welcome-bubble growth happens behind the cloak. If the visitor taps before the cloak lifts, showChat reveals immediately because input-driven shifts are already excluded by hadRecentInput.
The button repositions itself with a ResizeObserver, a MutationObserver, and a 1.5-second requestAnimationFrame poll. HubSpot remounts the iframe occasionally, especially in single-page apps. When the chat is closed, the button covers the launcher. When it is open, the button covers only the close X so it does not block clicks inside the chat.
The order in hideChat matters. Opacity 0 first, then widget.close(). If you flip them, the close animation starts before opacity hits zero and the first frames of the shift get recorded.
The result
On the client site where I first deployed this, CLS p75 dropped from 0.91 to 0.02 inside two days of field data accumulating. The widget still opens and closes the same way visually. Visitors do not notice anything. CrUX picks up the new field data on its monthly cycle.

HubSpot Chat CLS FAQ
The fix itself
Will this break HubSpot tracking or chat features?
No. The widget itself is unchanged. We are just intercepting the click that opens it and the click that closes it. Conversation history, agent routing, contact identification, none of it is affected.
Does this work on mobile?
Yes. The transparent button is positioned with position: fixed and follows the iframe's bounding rectangle. It works the same on mobile as on desktop.
Does this affect INP?
The click handler does very little synchronous work: an opacity write, a cookie write, a synchronous DOM read for getBoundingClientRect. INP impact is negligible. The HubSpot widget itself is the larger INP risk on most sites, and that is a separate problem.
Browsers and CLS measurement
What about Safari and Firefox?
Safari and Firefox do not implement the Layout Instability API, so CLS is only measured in Chromium browsers. The script still works in Safari and Firefox in terms of opening and closing the widget. Their visitors are not contributing to your CrUX CLS data anyway.
Tuning and stability
Why a 1200ms timeout for restoring opacity?
HubSpot's close animation runs for several hundred milliseconds. 1200ms gives plenty of headroom. Set it too short and opacity restores while the iframe is still mid-animation; the remaining frames get counted.
What if HubSpot changes the iframe ID?
Then this script breaks. The id hubspot-messages-iframe-container has been stable for years, but it is a third party implementation detail. If HubSpot ships a redesign, the selector needs updating. This is the cost of building on top of someone else's widget.
I make sites pass Core Web Vitals.
500K+ pages for major European publishers and e-commerce platforms. I write the fixes and verify them with field data.
How I work
