HubSpotチャットウィジェットのレイアウトシフトを修正する

ブラウザのレイアウトシフト除外ルールを2つ活用し、0.91のCLSを0.02に下げる回避策

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

HubSpotチャットによって引き起こされるレイアウトシフトの修正方法

HubSpotのチャットウィジェットは、ページ上で0.91という大規模なCLSを引き起こす可能性があります。これはよく知られた問題です。そこで、皆さんのためにこれを修正しました。以下のドロップインスクリプトを使用すれば、CLSのp75を0.91から0.00に抑えることができます。理想を言えばHubSpot自身が修正すべきですが、彼らは修正しませんし、私がHubSpotのコードを変更することもできません。ですので、これはハックだと承知していますが、機能します。

HubSpotチャットのCLSは一般的な不満です

人々は何年も前から不満の声を上げています。HubSpot Communityのスレッドがこちらこちらこちらにあり、Google Search Consoleのヘルプスレッド、そしてCronyx Digitalの記事もあります。それでもHubSpotは修正していません。

なぜCLSが発生するのか

HubSpotのチャットウィジェットは、idがhubspot-messages-iframe-containerのiframe内にレンダリングされます。訪問者がランチャーをタップすると、コンテナはランチャーサイズ(約60×60ピクセル)からフルパネルサイズ(約380×600ピクセル)へとアニメーションしながら拡大します。訪問者が閉じた時は、コンテナが元に戻るアニメーションが行われます。

ここに落とし穴があります。Layout Instability APIは、hadRecentInputを入力イベントが発生したドキュメントに結び付けます。HubSpotランチャーのクリックはiframeドキュメント内で発生します。しかし、iframeコンテナがリサイズされる場所であるため、レイアウトシフトは親ドキュメントに影響します。2つの異なるドキュメントが存在するわけです。親ドキュメントのhadRecentInputはfalseのままであるため、このシフトはカウントされてしまいます。

実際には対処すべきシフトのタイミングが3つあります。ロード時、HubSpotはランチャーを小さく(約100x96)マウントし、1、2秒後に約242x245に拡大してウェルカムバブルを表示します。ここではユーザー入力は一切ありません。そのため完全にカウントされます。開く時、クリックはiframe内で発生するため、親ドキュメントはそれを検知しません。これも完全にカウントされます。閉じる時、クロスフレームの同じ問題が発生することに加え、閉じるアニメーションが500ミリ秒以上続くため、仮に正しい起因付けが行われていたとしても、除外ウィンドウを過ぎた後のフレームがカウントされてしまいます。

私は複数のクライアントサイトでこれを測定しました。1回の開閉サイクルだけで、0.91のCLS寄与が生じる可能性があります。これだけで、ページが合格スコアから「不良(poor)」のバケツへと押し下げられるのに十分な数値です。

hubspot chat cls trace

コード

これがそのスクリプトです。あなたのサイトにドロップしてください。1回だけ実行され、ビルドステップ、フレームワーク、依存関係は一切不要です。Vanilla JSです。HubSpotランチャーの上に透明なボタンをマウントし、開閉を処理し、ロード時の拡大を隠蔽(クローク)します。

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

  // 戦略:
  //   LOAD: iframe-containerが表示された瞬間にopacity:0で隠蔽し、
  //   HubSpotがランチャーからウェルカムバブルへの展開を終えた後に表示する。
  //   Chromeのレイアウトシフトアルゴリズムは、現在と前のフレームの両方で
  //   計算されたopacityが0である要素の矩形変更をスキップするため、
  //   この拡大によるシフトエントリはゼロになる。
  //
  //   OPEN: 不可視の同一オリジンのオーバーレイがタップを捕捉し、
  //   widget.open()を呼び出す。シフトは500ミリ秒の入力猶予期間内に発生するため、
  //   hadRecentInput=trueとなり、シフトはCLSから除外される。
  //
  //   CLOSE: (クリックハンドラー内で) 同期的にコンテナにopacity:0を設定し、
  //   その後にwidget.close()を呼び出す。同じopacity:0の除外ルールにより、
  //   HubSpotの遅い閉じるアニメーションがマスクされる。HubSpotが
  //   (ランチャーサイズに戻って)完了したら、次のクリックが機能するようにopacityを復元する。
  //
  //   AUTO-OPEN: HubSpotが読み取る前にhs-messages-is-open Cookieを削除し、
  //   前回の「開いた」セッションによってページ読み込み時に入力なしのシフトが
  //   発生しないようにする。

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

  // ロード時の隠蔽: HubSpotはランチャーを約100x96でマウントし、
  // 約1〜2秒待機した後、ウェルカムバブルを表示するために約242x245に拡大する。
  // この隙間、ランチャーは「安定」しているように見えるため、短い安定性しきい値では
  // 早く表示されすぎてしまう。その隙間よりも長いつきい値が必要となる。
  // 余裕を持たせて2000ミリ秒でカバーする。遅いネットワークに備えて7秒をハードキャップとする。
  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() {
    // 隠蔽がまだアクティブな場合は、今すぐ表示する。入力主導のシフトは
    // すでにhadRecentInputによって除外されており、ユーザーはチャットを見る必要があるため。
    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;
    // 重要: レイアウトシフトAPIが閉じるアニメーションの矩形変更をスキップするように、
    // widget.close()の前にopacity:0を設定する。
    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);
})();

回避策

私のスクリプトは、それぞれのシフトのタイミングに対する3つの処理と、自動オープンのパスに対する1つの防御策を実行します。

トリック1: ロード時のランチャーの拡大を隠蔽する。cloakOnLoadは、iframeコンテナがマウントされた瞬間にopacity: 0を設定し、ResizeObserverでそれを監視し、寸法が2秒間安定した段階で表示します。遅いネットワークのためのセーフティネットとして、7秒のハードキャップを設けています。Chrome 89は、現在と前のフレームの両方で計算されたopacityが0である要素の矩形変更を無視するため、ランチャーからウェルカムバブルへの拡大は目に見えない形で行われます。シフトエントリはゼロです。隠蔽が解除される前に訪問者がタップした場合、入力自体がシフトを除外するため、showChatが即座に表示されます。

トリック2: 親でのオープンクリックをキャッチする。HubSpotランチャーの上に不可視のボタンをマウントし、ResizeObserverMutationObserver、および1.5秒のrequestAnimationFrameポーリングを使用して、iframeの境界矩形に固定したままにします。HubSpotは時折、特にシングルページアプリにおいてiframeを再マウントするため、その際にオブザーバーが再アタッチされます。訪問者がタップすると、ボタンは親ドキュメントでイベントを受け取り、HubSpotConversations.widget.open()を呼び出します。これで親には最近の入力(recent input)が存在することになります。ChromeはこのシフトにhadRecentInput=trueのフラグを立てます。これで除外されます。チャットが開いている時、ボタンは閉じる用の「X」のみを覆うため、チャット内のクリックを妨げることはありません。

トリック3: 閉じるアニメーションをopacityの背後に隠す。トリック1と同じopacity 0のトリックです。スクリプトはクリックハンドラー内で同期的にopacity: 0を設定し、その後widget.close()を呼び出します。順序が重要です。最初にopacityを0にし、次に閉じます。これを逆にすると、opacityがゼロになる前に閉じるアニメーションが開始され、シフトの最初の数フレームが記録されてしまいます。

上部にあるCookieの削除は防御策です。hs-messages-is-open Cookieは、前のセッションでウィジェットが開いていたことを記憶しており、次のページ読み込み時に自動的に再オープンします(ユーザー入力は不要です)。このCookieを削除することで、そのパスを完全に遮断します。

結果

私がこれを最初に導入したクライアントのサイトでは、フィールドデータが蓄積される2日間のうちにCLSのp75が0.91から0.00に低下しました。ウィジェットは視覚的には今までと同じように開閉します。訪問者が何かに気づくことはありません。CrUXは月次サイクルで新しいフィールドデータをピックアップします。

hubspot cls fixed coredash

HubSpotチャットのCLSに関するFAQ

修正自体について

これによりHubSpotのトラッキングやチャット機能は壊れませんか?

壊れません。ウィジェット自体は変更されていません。開くクリックと閉じるクリックを傍受しているだけです。会話履歴、エージェントのルーティング、連絡先の特定など、どれも影響を受けません。

これはモバイルでも機能しますか?

はい。透明なボタンはposition: fixedで配置され、iframeの境界矩形に追従します。デスクトップと同じようにモバイルでも機能します。

これはINPに影響しますか?

クリックハンドラーは同期的な処理をほとんど行いません(opacityの書き込み、Cookieの書き込み、getBoundingClientRectの同期的なDOM読み取り程度です)。INPへの影響はごくわずかです。ほとんどのサイトにおいて、HubSpotウィジェット自体がより大きなINPリスクであり、それは別の問題となります。

ブラウザとCLSの測定

SafariとFirefoxについてはどうですか?

SafariとFirefoxはLayout Instability APIを実装していないため、CLSはChromiumブラウザでのみ測定されます。ウィジェットの開閉に関して言えば、スクリプトはSafariやFirefoxでも引き続き機能します。いずれにせよ、それらの訪問者はあなたのCrUXのCLSデータには寄与していません。

チューニングと安定性

opacityを復元するためのタイムアウトが1200msなのはなぜですか?

HubSpotの閉じるアニメーションは数百ミリ秒にわたって実行されます。1200msあれば十分な余裕が生まれます。短すぎると、iframeがまだアニメーションの途中である間にopacityが復元され、残りのフレームがカウントされてしまいます。

HubSpotがiframeのIDを変更した場合はどうなりますか?

その場合、このスクリプトは壊れます。hubspot-messages-iframe-containerというIDは何年にもわたり安定していますが、それはサードパーティの実装の詳細です。HubSpotが再設計版をリリースした場合は、セレクタを更新する必要があります。これが他社のウィジェットの上に構築する際のコストです。

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.

Search Consoleに指摘されましたか?

フィールドデータに基づいた優先順位付きの修正リストをお渡しします。50ページのPDFではありません。

監査を依頼
HubSpotチャットウィジェットのレイアウトシフトを修正するCore Web Vitals HubSpotチャットウィジェットのレイアウトシフトを修正する