Fix de HubSpot Chat Widget Layout Shift
Een workaround die een 0.91 CLS verlaagt naar 0.02 door gebruik te maken van twee uitsluitingen voor layout-shifts in de browser

Hoe je de layout shift veroorzaakt door HubSpot chat oplost
De HubSpot chat widget kan een enorme 0.91 CLS op een pagina veroorzaken. Dit is een bekend probleem. Dus ik heb het voor je gefixt. Je kunt mijn drop-in script hieronder gebruiken, dat de CLS p75 van 0.91 naar 0.00 brengt. In een perfecte wereld zou HubSpot dit zelf oplossen. Maar dat doen ze niet en ik kan de code van HubSpot niet aanpassen. Dus het is een hack, dat weet ik, maar het werkt wel.
Table of Contents!
HubSpot Chat CLS is een veelvoorkomende klacht
De HubSpot Community heeft meerdere meerjarige threads over de chat widget die de CLS verpest. De belangrijkste daarvan is Live Chat Causing CLS Issues, gemarkeerd als Opgelost, maar die tot in 2024 nog steeds posts van klanten ontvangt. Eén gebruiker meldt dat de widget hun CLS van 0 naar 0.407 duwde, vier keer boven de grenswaarde. Een andere thread, Low CLS performance issue, in het SEO-subforum volgt hetzelfde probleem vanuit het perspectief van zoekmachinerangschikkingen, en een oudere thread, Live Chat App slows page down, loopt nog veel langer.
Het is niet alleen het forum van HubSpot. Er is een Google Search Console help thread die "Hubspot Chat" in de titel zet, en analyses van derden zoals Cronyx Digital's Is HubSpot Chat Slowing Down Your Website? nemen de prestatieschade door.
HubSpot heeft na druk vanuit de community wijzigingen doorgevoerd in de payload en time-on-page, maar geen enkel officieel document erkent de chat widget als een bron van CLS en er is geen instelling in de app die dit oplost.
Waarom gebeurt de CLS
De HubSpot chat widget wordt gerenderd in een iframe met de id hubspot-messages-iframe-container. Wanneer een bezoeker op de launcher tikt, animeert de container van de grootte van de launcher (ongeveer 60 bij 60 pixels) naar de grootte van een volledig paneel (ongeveer 380 bij 600 pixels). Wanneer de bezoeker deze sluit, animeert de container weer terug.
De Layout Instability API koppelt hadRecentInput aan het document waar het input event werd afgevuurd. Klik-events van binnen het iframe landen op het document van het iframe. De layout shift landt op het parent document, waar de iframe-container van grootte verandert. Twee verschillende documenten. De hadRecentInput van de parent blijft false.
Er zijn drie shift-momenten om af te handelen. Bij het laden mount HubSpot de launcher klein (~100x96), en laat hem een seconde of twee later groeien naar ~242x245 om de welkomstbubbel te tonen. Geen user input. Telt volledig mee. Bij het openen vuurt de klik af binnen het iframe, waardoor de parent deze nooit ziet. Telt volledig mee. Bij het sluiten is er hetzelfde cross-frame probleem, plus dat de sluitingsanimatie langer dan 500ms duurt, dus frames na het uitsluitingsvenster tellen mee, zelfs als de attributie correct is.
Ik heb dit gemeten op meerdere klantensites. Een enkele open- en sluitcyclus kan een CLS-bijdrage van 0.91 opleveren. Dat is op zichzelf al genoeg om een pagina van een voldoende score in de slechte categorie te duwen.

De workaround
De fix bestaat uit drie delen, één voor elk shift-moment.
Truc één: verberg de groei van de launcher tijdens het laden. We zetten opacity: 0 op de iframe-container op het moment dat deze verschijnt en onthullen hem zodra de launcher is gestopt met het veranderen van grootte. Chrome 89 negeert rect wijzigingen op elk element (of ancestor) waarvan de berekende opacity 0 is in zowel het huidige als het vorige frame. De groei van de launcher naar welkomstbubbel gebeurt onzichtbaar. Nul shift-invoeren.
Truc twee: vang de open-klik op de parent. We monteren een onzichtbare knop over de HubSpot launcher. Wanneer de bezoeker tikt, ontvangt onze knop het event op het parent document, en roept dan HubSpotConversations.widget.open() aan. De parent heeft nu een recente input. De widget animeert open, het iframe verschuift, Chrome markeert de shift met hadRecentInput=true. Uitgesloten.
Truc drie: verberg de sluitingsanimatie achter opacity. Dezelfde opacity 0 truc als Truc één. We zetten opacity: 0 synchroon binnen de click handler, en roepen dan widget.close() aan. Het sluiten gebeurt onzichtbaar. Nadat het is voltooid, herstellen we de opacity. Nul shift-invoeren.
Dat is het hele idee. De volledige code staat hieronder.
De code
Zet dit script op je site. Het draait eenmaal, monteert een transparante knop over de HubSpot launcher, en handelt openen en sluiten zelf af. Geen build-stap, geen framework, geen dependency. Vanilla JavaScript.
(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);
})(); Wat elk onderdeel doet
De cookie-kill bovenaan stopt de auto-open van HubSpot. De cookie hs-messages-is-open onthoudt dat de widget open was in een eerdere sessie en heropent deze bij de volgende paginalading. Auto-open vuurt af zonder recente input, dus het telt volledig mee voor CLS. Het doden van de cookie stopt dat.
cloakOnLoad zet opacity: 0 op de iframe-container op het moment dat deze mount. Het gebruikt een ResizeObserver om te kijken naar veranderingen in grootte en onthult de container zodra de afmetingen 2 seconden stabiel blijven, met een harde limiet van 7 seconden als vangnet voor trage netwerken. De groei van launcher naar welkomstbubbel gebeurt achter de mantel. Als de bezoeker tikt voordat de mantel verdwijnt, onthult showChat onmiddellijk omdat door input aangedreven shifts al zijn uitgesloten door hadRecentInput.
De knop herpositioneert zichzelf met een ResizeObserver, een MutationObserver, en een 1.5-seconden requestAnimationFrame poll. HubSpot remount het iframe af en toe, vooral in single-page apps. Wanneer de chat gesloten is, bedekt de knop de launcher. Wanneer deze open is, bedekt de knop alleen de sluit X zodat het geen klikken binnen de chat blokkeert.
De volgorde in hideChat is belangrijk. Eerst opacity 0, dan widget.close(). Als je ze omdraait, start de sluitingsanimatie voordat de opacity nul raakt en de eerste frames van de shift worden geregistreerd.
Het resultaat
Op de klantensite waar ik dit voor het eerst heb uitgerold, daalde de CLS p75 van 0.91 naar 0.02 binnen twee dagen na het verzamelen van veldgegevens. De widget opent en sluit nog steeds op dezelfde visuele manier. Bezoekers merken er niets van. CrUX pikt de nieuwe veldgegevens op in zijn maandelijkse cyclus.

HubSpot Chat CLS FAQ
De fix zelf
Zal dit HubSpot tracking of chat functionaliteiten breken?
Nee. De widget zelf is ongewijzigd. We onderscheppen slechts de klik die deze opent en de klik die deze sluit. Gespreksgeschiedenis, agent-routering, contact-identificatie, geen van allen worden beïnvloed.
Werkt dit op mobiel?
Ja. De transparante knop is gepositioneerd met position: fixed en volgt de bounding rectangle van het iframe. Het werkt hetzelfde op mobiel als op desktop.
Heeft dit invloed op INP?
De click handler doet zeer weinig synchroon werk: een opacity-schrijfopdracht, een cookie-schrijfopdracht, een synchrone DOM-leesopdracht voor getBoundingClientRect. INP-impact is verwaarloosbaar. De HubSpot widget zelf is op de meeste sites het grotere INP risico, en dat is een apart probleem.
Browsers en CLS-meting
Hoe zit het met Safari en Firefox?
Safari en Firefox implementeren de Layout Instability API niet, dus CLS wordt alleen gemeten in Chromium-browsers. Het script werkt nog steeds in Safari en Firefox in termen van het openen en sluiten van de widget. Hun bezoekers dragen hoe dan ook niet bij aan je CrUX CLS-data.
Afstemming en stabiliteit
Waarom een timeout van 1200ms voor het herstellen van de opacity?
De sluitingsanimatie van HubSpot loopt enkele honderden milliseconden. 1200ms geeft voldoende speling. Als je dit te kort instelt, herstelt de opacity zich terwijl het iframe nog bezig is met animeren; de resterende frames worden dan meegeteld.
Wat als HubSpot het iframe-ID verandert?
Dan breekt dit script. De id hubspot-messages-iframe-container is al jaren stabiel, maar het is een implementatiedetail van een derde partij. Als HubSpot een redesign uitbrengt, moet de selector worden bijgewerkt. Dit is de prijs voor het bouwen bovenop andermans widget.
Ik krijg sites door de Core Web Vitals.
500K+ pagina's voor grote Europese uitgevers en e-commerce. Ik schrijf de fixes en check ze in de echte data.
Zo werk ik
