Corrigir 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 despejar um CLS de 0.91 em uma página quando um visitante o abre ou fecha. O lançador vive dentro do iframe do HubSpot, então o toque do visitante é registrado no documento do iframe e nunca chega à página pai. O pai é onde o layout shift é registrado e, como o pai nunca viu um clique, o Chrome sinaliza a mudança como não iniciada pelo usuário. A animação lenta de abrir e fechar piora a situação, mas o limite do iframe é a causa raiz. Abaixo está a solução alternativa que uso em sites de clientes. É uma gambiarra (hack). Funciona. A correção real tem que vir do HubSpot.

O que está acontecendo

O widget de chat do HubSpot é renderizado dentro de um iframe com o id hubspot-messages-iframe-container. Quando um visitante toca no lançador, o container anima do tamanho do lançador (cerca de 60 por 60 pixels) para o tamanho do painel completo (cerca de 380 por 600 pixels). Quando o visitante o fecha, o container anima de volta.

A Layout Instability API marca cada entrada de mudança com uma flag hadRecentInput. A flag só é verdadeira se um evento de entrada discreto (clique, toque, pressionamento de tecla) foi disparado no mesmo documento da mudança nos 500 milissegundos anteriores. A mudança aqui atinge o documento pai, porque é lá que o elemento container do iframe redimensiona. O clique que o disparou atinge o documento do iframe. Dois documentos diferentes. O hadRecentInput do pai permanece falso. Todo o movimento de abertura ou fechamento é contado para o CLS.

A animação lenta é um problema secundário em cima disso. Mesmo se o clique passasse para o pai, a animação de fechamento do HubSpot dura várias centenas de milissegundos, então os quadros após a janela de exclusão de 500ms ainda contariam. Dois problemas empilhados: um clique entre frames que quebra a atribuição de entrada, e uma animação longa o suficiente para ultrapassar a janela de exclusão 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 de CLS. Isso é o suficiente para empurrar uma página de uma pontuação de aprovação para o balde de ruim por conta própria.

Painel de Performance do Chrome DevTools mostrando o widget de chat do HubSpot produzindo um layout shift grande fora da janela de tolerância de entrada

Por que o HubSpot simplesmente não corrige isso

Dois motivos.

O primeiro é estrutural. O lançador está dentro de um iframe, então o evento de clique vive no documento do iframe. A Layout Instability API vincula hadRecentInput ao documento onde a entrada foi disparada, não ao local visual do clique na tela. O HubSpot não pode fazer um clique in-iframe contar no pai. A única correção limpa do lado deles é renderizar o lançador como um elemento do documento pai, com o iframe do painel de chat permanecendo separado. Essa não é uma reescrita trivial de como o widget é montado.

O segundo é a própria animação, que é útil. Ela atrai o olho, sinaliza que o painel está abrindo e parece menos abrupta do que um ajuste de tamanho instantâneo (snap-to-size). Remover a animação tornaria a experiência pior para todos, não apenas para os sites que se importam com o CLS. E encurtá-la para menos de 500ms não ajudaria a menos que o clique cross-frame também seja resolvido.

Então você tem um script de terceiros causando CLS no seu domínio que você não pode corrigir por dentro do iframe e não pode corrigir diretamente de fora.

A solução alternativa

A correção tem duas partes, uma para cada problema.

O truque um resolve o clique cross-frame. Nós montamos um botão invisível sobre o lançador do HubSpot na página pai. Quando o visitante toca, nosso botão recebe o evento no documento pai, em seguida, chama HubSpotConversations.widget.open(). O pai agora tem uma entrada recente. O widget anima aberto, o container do iframe muda e o Chrome sinaliza as entradas de mudança do pai com hadRecentInput=true. A abertura é excluída do CLS.

Painel de Performance do Chrome DevTools mostrando a entrada de layout shift do clique de abertura com hadRecentInput definido como true após a implementação da solução alternativa

O truque dois lida com a animação lenta de fechamento. A animação de fechamento dura mais de 500ms, então mesmo com o clique do lado do pai no lugar, os quadros após a janela de exclusão de entrada ainda contam. O Chrome 89 mudou o CLS para ignorar mudanças de retângulo (rect changes) em qualquer elemento (ou ancestral) cuja opacidade calculada é 0 tanto no quadro atual quanto no anterior. Então nós definimos opacity: 0 no container do iframe de forma síncrona dentro do handler de clique, em seguida, chamamos HubSpotConversations.widget.close(). A animação de fechamento do HubSpot roda de forma invisível. Assim que ela termina, nós restauramos a opacidade. O container já voltou ao tamanho do lançador a essa altura, então restaurar a opacidade é uma mudança de pintura (paint change), não uma mudança de layout. Zero entradas de mudança registradas.

Essa é a ideia toda. O código completo está abaixo.

Gráfico de dados de campo do CoreDash mostrando o CLS caindo após a implementação da correção do HubSpot

O código

Coloque este script no seu site. Ele roda uma vez, monta um botão transparente sobre o lançador do HubSpot e lida ele mesmo com o abrir e fechar. Sem etapa de build, sem framework, sem dependência. Vanilla JS.

(function () {
  if (window.__cwvHsClsFix) return;
  window.__cwvHsClsFix = true;

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

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

O que cada parte faz

A chamada clearOpenCookie no topo destrói o cookie hs-messages-is-open do HubSpot antes que o HubSpot o leia. O HubSpot usa esse cookie para lembrar dentro de uma janela de 30 minutos se o widget estava aberto e reabri-lo automaticamente no próximo carregamento da página. Uma abertura automática no carregamento da página dispara sem nenhuma entrada recente, então conta integralmente para o CLS. Destruir o cookie interrompe esse caminho.

Aba Cookies do painel Application do Chrome DevTools mostrando o cookie hs-messages-is-open definido pelo HubSpot para lembrar o estado aberto do widget

O botão tap é posicionado sobre onde o iframe do HubSpot estiver no momento. Quando o iframe está no tamanho do lançador (fechado), o botão cobre todo o lançador. Quando o iframe está no tamanho do painel (aberto), o botão cobre apenas o X de fechar no canto superior direito para que não bloqueie os cliques dentro do chat aberto.

O ResizeObserver e o MutationObserver reposicionam o botão quando o HubSpot redimensiona o iframe ou o remonta. O HubSpot remonta o iframe ocasionalmente, especialmente após a navegação em single page apps. Os listeners widgetOpened e widgetClosed na parte inferior são defensivos; eles disparam de forma inconsistente na prática, então o rAF polling e o ResizeObserver fazem o trabalho real.

A função showChat lida com o caso em que o widget do HubSpot ainda não foi carregado. Se HS.widget.status().loaded for falso, ela chama HS.widget.load({ widgetOpen: true }), o que carrega o widget em um estado já aberto. Se estiver carregado, ela chama HS.widget.open(). Ambos os caminhos rodam em resposta ao clique, então a janela de exclusão de entrada se aplica.

A função hideChat é o truque. A ordem das operações importa:

  1. Defina opacity: 0 no container de forma síncrona.
  2. Em seguida, chame HS.widget.close().
  3. Aguarde a animação de fechamento terminar (1200ms é seguro).
  4. Restaure a opacidade.

Se você inverter as etapas 1 e 2, a animação de fechamento começa antes que a opacidade vá para 0, e os primeiros quadros da mudança são registrados. Mudança síncrona de opacidade primeiro.

O resultado

No site do cliente onde eu implementei isso pela primeira vez, o CLS p75 caiu de 0.91 para 0.02 em dois dias de acúmulo de dados de campo. O widget ainda abre e fecha da mesma maneira visualmente. Os visitantes não notam nada. O CrUX capta os novos dados de campo em seu ciclo mensal.

Dados de campo do CrUX do PageSpeed Insights mostrando a métrica CLS passando de ruim para boa após a implementação da correção

Isto é uma gambiarra. Avise o HubSpot.

Quero ser claro sobre o que é isso: É uma gambiarra (hack), não é uma correção. É uma solução alternativa que corrige o layout shift com dois comportamentos específicos da API de layout shift:

  1. A janela de exclusão de entrada de 500ms para cliques discretos.
  2. A exclusão de opacidade 0 que chegou no Chrome 89.

Ambos podem mudar. A exclusão de opacidade 0 já foi debatida discretamente no WICG. Se o Chrome decidir começar a contar as mudanças em elementos de opacidade 0 (porque alguém abusa do truque para esconder anúncios ou outro conteúdo manipulador), isso para de funcionar da noite para o dia.

A correção certa tem que vir do HubSpot. O widget deles está em dezenas de milhares de sites. Cada um desses sites está pagando um imposto de CLS que o proprietário do site não introduziu e não pode corrigir na fonte. O HubSpot poderia:

  1. Renderizar o lançador de chat como um elemento do documento pai. O iframe do painel de chat pode ficar; apenas o lançador tem que viver no pai para que o clique seja registrado no documento pai.
  2. Encurtar as animações de abrir e fechar para menos de 500ms, ou fornecer uma opção de configuração para desativar a animação inteiramente. Combinado com a correção do lançador, isso tornaria a contribuição de CLS efetivamente zero. Alguns proprietários de sites ficariam felizes em aceitar um widget de ajuste de tamanho instantâneo (snap-to-size) ao invés de uma contribuição de 0.9 de CLS de qualquer maneira.
  3. Documentar o problema claramente nos developer docs para que os proprietários de sites saibam que ele existe antes de lançar um dashboard CrUX com o widget em todas as páginas.

Se você encontrar isso no seu site, implemente o script. Então, abra um ticket de suporte do HubSpot e coloque o link para esta página. Quanto mais proprietários de sites relatarem isso, mais provável será que seja priorizado. Eu mesmo enviei feedback através dos canais deles e, até agora, a única resposta foi apontar para os developer docs, que não mencionam CLS em nenhum lugar.

FAQ de CLS do HubSpot Chat

A correção em si

Isso vai quebrar o rastreamento ou os recursos de chat do HubSpot?

Não. O widget em si não é alterado. Nós estamos apenas interceptando o clique que o abre e o clique que o fecha. Histórico de conversa, roteamento de agente, identificação de contato, nada disso é afetado.

Isso funciona no mobile?

Sim. O botão transparente é posicionado com position: fixed e segue o retângulo delimitador (bounding rectangle) do iframe. Ele funciona da mesma forma no mobile e no desktop.

Isso afeta o INP?

O handler de clique 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.

Navegadores e medição de CLS

E quanto ao Safari e Firefox?

Safari e Firefox não implementam a Layout Instability API, então o CLS só é medido em navegadores Chromium. O script ainda funciona no Safari e Firefox em termos de abrir e fechar o widget. Os visitantes deles não estão contribuindo para os seus dados de CLS do CrUX de qualquer maneira.

Ajuste 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 de segurança. Se você definir muito curto, a opacidade é restaurada enquanto o iframe ainda está no meio da animação; os quadros restantes são contados.

E se o HubSpot mudar 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á de atualização. Este é o custo de 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.

Descobre o que é mesmo lento.

Mapeio o critical rendering path com dados RUM. Recebes uma lista de fixes por prioridade, não um relatório do Lighthouse.

Quero a auditoria
Corrigir o Layout Shift do Widget de Chat do HubSpotCore Web Vitals Corrigir o Layout Shift do Widget de Chat do HubSpot