Chakra Tooltip 邊界顯示時 scrollbar 閃爍的修法

問題

Chakra Tooltip 出現的瞬間,網頁會多出水平 + 垂直 scrollbar 各一條,下一個 frame 又消失。Tooltip 收起來之後一切正常。

肉眼看就是個閃爍,但影響很惱人:

  • 內容區域因為 scrollbar 突然出現而被擠掉幾 px,layout shift
  • 在 sticky / fixed 元素旁邊看起來特別明顯
  • 視覺上像是 page 在抽搐

核心原因

Chakra Tooltip 底層用 @popperjs/core 定位,預設的 strategy"absolute",而且 Chakra 沒有把這個選項透過 props 暴露出來。

absolute strategy 的行為:

  • popper element 用 position: absolute 定位
  • layout box 的參考座標是離它最近的 positionstatic 的祖先——通常會冒到 <body>
  • popper element 雖然視覺上會被定位到正確位置,但它的盒子是計入 <html> 的 scrollable area 的

當 tooltip 出現在接近 viewport 邊界時:

  1. 一個 frame:popper element 先以 absolute 被插入並定位,盒子撐出 <html> 的 scroll area
  2. 瀏覽器看到 overflow → 跑出 scrollbar
  3. 下一個 frame:popper 的 placement 計算修正完成,盒子收回 viewport 內
  4. overflow 消失 → scrollbar 也消失

整個過程在一個 render frame 內完成,但 scrollbar 還是會閃一下。

解法

唯一暴露出來的缺口是 Tooltip 接受 popper 的 modifiers prop。寫一個 modifier 把 strategy'absolute' 改成 'fixed'

import type { Modifier } from '@popperjs/core';
 
const fixedStrategyModifier: Partial<Modifier<'fixedStrategy', object>> = {
  name: 'fixedStrategy',
  enabled: true,
  phase: 'beforeRead',
  fn: ({ state }) => {
    state.options.strategy = 'fixed';
    state.strategy = 'fixed';
  },
  effect: ({ state }) => {
    state.options.strategy = 'fixed';
    state.strategy = 'fixed';
    state.elements.popper.style.position = 'fixed';
  },
};
 
const tooltipModifiers = [fixedStrategyModifier];

用法:

<Tooltip
  placement="bottom"
  hasArrow
  label={t('tooltipBulkSetAllocation')}
  shouldWrapChildren
  isDisabled={isLocked}
  modifiers={tooltipModifiers}
>
  ...
</Tooltip>

為什麼三個地方都要改

改動位置何時跑為什麼必要
effectstate.options.strategypopper instance 初始化時跑一次設定 popper 的「設定」,影響後續所有 update
effectstate.elements.popper.style.position初始化時直接寫 DOM第一次 render 就用 position: fixed,避免「先 absolute 一個 frame 再修正」
fnstate.options.strategy / state.strategy每次 update(scroll、resize、placement 變化)都跑popper 內部某些路徑會把 strategy 重設回預設,所以 update 時要再壓一次

少寫任一段都會在特定時序下漏網——特別是 effect 裡如果只改 options 沒改 inline style,第一次 mount 還是會閃。

fixed 為什麼能解決

position: fixed 的 layout box 是對 viewport 而不是 <body>根本不計入 <html> 的 scrollable area。所以不管 popper 一開始算錯位置在哪,都不會撐大 <html> overflow,scrollbar 不會被觸發。

Trade-offs / 注意事項

  • Fixed 對 scroll 容器內的 tooltip 行為不同:如果 Tooltip 是在一個有 overflow: auto / scroll 的容器內,trigger 元素跟著容器 scroll,但 fixed popper 是對 viewport,所以 popper 不會跟著容器內部 scroll 飄走——這通常是你要的(tooltip 跟 trigger 對齊),但要意識到行為跟 absolute 是有差的。
  • 這個 modifier 是 Chakra 給的唯一缺口:理論上比較乾淨的做法是 Chakra 把 strategy 暴露成 prop。如果未來 Chakra 加了,這段 hack 就可以拆掉。
  • 每個 Tooltip 都要傳:modifier 不會自動套到全站。如果想全站套用,可以包一個自己的 <Tooltip> wrapper 預設帶 modifiers={tooltipModifiers}