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 fixed it for you. You can use my drop-in script below that takes CLS p75 from 0.91 to 0.00. In a perfect world HubSpot would fix it themselves. But they won't, and I can't change HubSpot's code. So it is a hack, I know, but it works.
Table of Contents!
HubSpot Chat CLS is a common complaint
People have been complaining for years. HubSpot Community threads here, here and here, a Google Search Console help thread, and a Cronyx Digital write-up. HubSpot has not fixed it.
Why is 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.
Here is the trap. The Layout Instability API ties hadRecentInput to the document where the input event fired. The click on the HubSpot launcher fires inside the iframe document. The layout shift lands on the parent document, because that is where the iframe container resizes. Two different documents. The parent's hadRecentInput stays false. The shift counts.
You actually have three shift moments to deal with. 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 at all. 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 the 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 alone is enough to push a page from a passing score into the poor bucket.

The code
Here is the script. Drop it on your site. Runs once, no build step, no framework, no dependency. Vanilla JS. It mounts a transparent button over the HubSpot launcher, handles open and close, and cloaks the load-time growth.
(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);
})();
The workaround
My script does three things, one for each shift moment, plus one defensive piece for the auto-open path.
Trick one: cloak the launcher growth on load. cloakOnLoad sets opacity: 0 on the iframe container the moment it mounts, watches it with a ResizeObserver, and reveals once dimensions stay stable for 2 seconds. There is a 7 second hard cap as a safety net for slow networks. Chrome 89 ignores rect changes on any element whose computed opacity is 0 in both the current and previous frame, so the launcher to welcome-bubble growth happens invisibly. Zero shift entries. If the visitor taps before the cloak lifts, showChat reveals immediately because the input itself excludes the shift.
Trick two: catch the open click on the parent. I mount an invisible button over the HubSpot launcher and keep it pinned to the iframe's bounding rectangle with a ResizeObserver, a MutationObserver, and a 1.5 second requestAnimationFrame poll. HubSpot remounts the iframe occasionally, especially in single-page apps, so the observers re-attach when that happens. When the visitor taps, the button receives the event on the parent document and calls HubSpotConversations.widget.open(). The parent now has a recent input. Chrome flags the shift with hadRecentInput=true. Excluded. When the chat is open, the button covers only the close X so it does not block clicks inside the chat.
Trick three: hide the close animation behind opacity. Same opacity 0 trick as Trick one. The script sets opacity: 0 synchronously inside the click handler, then calls widget.close(). Order matters: opacity 0 first, then close. Flip them and the close animation starts before opacity hits zero, and the first frames of the shift get recorded.
The cookie kill at the top is the defensive piece. The hs-messages-is-open cookie remembers the widget was open in a previous session and reopens it automatically on the next page load, no user input required. Killing the cookie stops that path entirely.
The result
On the client site where I first deployed this, CLS p75 dropped from 0.91 to 0.00 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.
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 write the code, not the report.
I join your team for 1 to 2 sprints. I set up the monitoring and make sure your team keeps the metrics green after I leave.
Get in touch
