Corrija o Layout Shift do Widget de Chat do HubSpot

Uma solução alternativa que reduz um CLS de 0,91 para 0,02 explorando duas exclusões de layout-shift do navegador

Arjen Karel Core Web Vitals Consultant
Arjen Karel - linkedin
Last update: 2026-04-27

Como corrigir o layout shift causado pelo chat do HubSpot

O widget de chat do HubSpot pode causar um CLS massivo de 0,91 em uma página. Este é um problema bem conhecido. Então eu o corrigi para você. Você pode usar o meu script drop-in abaixo que leva o p75 do CLS de 0,91 para 0,00. Em um mundo perfeito, o próprio HubSpot resolveria isso. Mas eles não vão, e eu não posso mudar o código do HubSpot. Então é uma gambiarra, eu sei, mas funciona.

O CLS do Chat do HubSpot é uma reclamação comum

As pessoas têm reclamado por anos. Tópicos da Comunidade do HubSpot aqui, aqui e aqui, uma thread de ajuda do Google Search Console e um artigo da Cronyx Digital. O HubSpot não corrigiu o problema.

Por que o CLS está acontecendo

O widget de chat do HubSpot renderiza dentro de um iframe com o id hubspot-messages-iframe-container. Quando um visitante toca no launcher, o contêiner é animado do tamanho do launcher (cerca de 60 por 60 pixels) para o tamanho do painel completo (cerca de 380 por 600 pixels). Quando o visitante o fecha, o contêiner é animado de volta.

Aqui está a armadilha. A Layout Instability API vincula hadRecentInput ao documento onde o evento de input foi disparado. O clique no launcher do HubSpot é disparado dentro do documento do iframe. O layout shift ocorre no documento pai, porque é lá que o contêiner do iframe redimensiona. Dois documentos diferentes. O hadRecentInput do pai permanece falso. O shift conta.

Na verdade, você tem três momentos de shift para lidar. No load, o HubSpot monta o launcher pequeno (~100x96), depois o aumenta para ~242x245 um ou dois segundos depois para mostrar o welcome bubble. Sem nenhum user input. Conta integralmente. Ao abrir, o clique dispara dentro do iframe, então o pai nunca o vê. Conta integralmente. Ao fechar, o mesmo problema cross-frame, além de a animação de fechamento durar mais do que 500ms, então os frames após a janela de exclusão contam mesmo quando a atribuição está correta.

Eu medi isso em vários sites de clientes. Um único ciclo de abrir e fechar pode produzir uma contribuição de 0,91 no CLS. Isso por si só é suficiente para empurrar uma página de uma pontuação de aprovação para o balde de ruim.

hubspot chat cls trace

O código

Aqui está o script. Coloque no seu site. Roda uma vez, sem build step, sem framework, sem dependência. Vanilla JS. Ele monta um botão transparente sobre o launcher do HubSpot, lida com a abertura e o fechamento, e oculta o crescimento no momento do load.

(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);
})();

A solução alternativa

Meu script faz três coisas, uma para cada momento do shift, mais uma parte defensiva para o caminho de auto-open.

Truque um: ocultar o crescimento do launcher no load. cloakOnLoad define opacity: 0 no contêiner do iframe no momento em que ele é montado, o observa com um ResizeObserver e o revela assim que as dimensões permanecem estáveis por 2 segundos. Existe um limite rígido de 7 segundos como uma rede de segurança para redes lentas. O Chrome 89 ignora mudanças de rect em qualquer elemento cuja opacidade computada seja 0 tanto no frame atual quanto no anterior, então o crescimento do launcher para o welcome-bubble acontece de forma invisível. Zero shift entries. Se o visitante tocar antes de a ocultação ser removida, showChat revela imediatamente porque o próprio input exclui o shift.

Truque dois: capturar o clique de abertura no pai. Eu monto um botão invisível sobre o launcher do HubSpot e o mantenho fixo no retângulo delimitador do iframe com um ResizeObserver, um MutationObserver e um poll de requestAnimationFrame de 1,5 segundo. O HubSpot remonta o iframe ocasionalmente, especialmente em single-page apps, então os observers se anexam novamente quando isso acontece. Quando o visitante toca, o botão recebe o evento no documento pai e chama HubSpotConversations.widget.open(). O pai agora tem um input recente. O Chrome marca o shift com hadRecentInput=true. Excluído. Quando o chat está aberto, o botão cobre apenas o X de fechar para que não bloqueie os cliques dentro do chat.

Truque três: esconder a animação de fechamento atrás da opacidade. O mesmo truque de opacidade 0 do Truque um. O script define opacity: 0 sincronicamente dentro do click handler, e então chama widget.close(). A ordem importa: opacidade 0 primeiro, depois fechar. Inverta isso e a animação de fechamento começará antes que a opacidade atinja zero, e os primeiros frames do shift serão gravados.

A eliminação do cookie no topo é a parte defensiva. O cookie hs-messages-is-open lembra que o widget estava aberto em uma sessão anterior e o reabre automaticamente no próximo load da página, sem a necessidade de user input. Matar o cookie interrompe totalmente esse caminho.

O resultado

No site do cliente onde eu implantei isso pela primeira vez, o CLS p75 caiu de 0,91 para 0,00 dentro de dois dias de acúmulo de field data. O widget ainda abre e fecha da mesma forma visualmente. Os visitantes não notam nada. O CrUX capta o novo field data em seu ciclo mensal.

hubspot cls fixed coredash

FAQ do CLS do Chat do HubSpot

A correção em si

Isso vai quebrar o rastreamento do HubSpot ou as funcionalidades de chat?

Não. O widget em si não sofreu alterações. Nós estamos apenas interceptando o clique que o abre e o clique que o fecha. O histórico de conversas, o roteamento de agentes, a identificação do contato, nada disso é afetado.

Isso funciona em mobile?

Sim. O botão transparente é posicionado com position: fixed e acompanha o retângulo delimitador do iframe. Funciona da mesma maneira no mobile e no desktop.

Isso afeta o INP?

O click handler faz muito pouco trabalho síncrono: uma escrita de opacidade, uma escrita de cookie, uma leitura síncrona do DOM para getBoundingClientRect. O impacto no INP é insignificante. O widget do HubSpot em si é o maior risco de INP na maioria dos sites, e esse é um problema separado.

Ajustes e estabilidade

Por que um timeout de 1200ms para restaurar a opacidade?

A animação de fechamento do HubSpot dura várias centenas de milissegundos. 1200ms dá bastante margem. Defina como muito curto e a opacidade será restaurada enquanto o iframe ainda estiver no meio da animação; os frames restantes serão contados.

E se o HubSpot alterar o ID do iframe?

Então este script quebra. O id hubspot-messages-iframe-container tem sido estável por anos, mas é um detalhe de implementação de terceiros. Se o HubSpot lançar um redesign, o seletor precisará ser atualizado. Este é o custo de se construir em cima do widget de outra pessoa.

About the author

Arjen Karel is a web performance consultant and the creator of CoreDash, a Real User Monitoring platform that tracks Core Web Vitals data across hundreds of sites. He also built the Core Web Vitals Visualizer Chrome extension. He has helped clients achieve passing Core Web Vitals scores on over 925,000 mobile URLs.

Escrevo código, não relatório.

Entro no seu time por 1 ou 2 sprints. Monto o monitoramento e garanto que o time mantém as métricas no verde depois que eu saio.

Entra em contato
Corrija o Layout Shift do Widget de Chat do HubSpotCore Web Vitals Corrija o Layout Shift do Widget de Chat do HubSpot