核心原則

元件位置 = 定位基準(anchor)+ 相對基準的偏移。元件「跟著狀態飄」不是元件本身的問題、是它的 anchor 隨狀態在動。 Debug 時把元件位置拆成「找錨點 → 算偏移」兩層、確認哪一層在隨狀態變化。


為什麼狀態化錯位的根因不在元件本身

商業邏輯

CSS 計算元件位置時,元件總是「相對某個 reference」 — block flow 是「上一個 sibling 的下緣」、absolute 是 offset parent、grid item 是 grid container。這個 reference 才是元件位置的決定因素

當 reference 在不同狀態下尺寸或位置變動,元件被動跟著動 — 看起來是元件「自己飄」,根因卻在 reference。

三層拆解 debug 法

問題修法
1. 元件本身元件 CSS 規則錯了?看元件的 computed style
2. 元件的 referencereference 在動嗎?尺寸隨狀態變動?量 reference 在每個狀態下的 bounding rect
3. Reference 的 reference上一層也在動嗎?一層一層往上追

多數狀態化錯位的根因在第 2 或第 3 層、不在第 1 層。


這次任務的實際情境

觀察

新加 scope UI(搜尋範圍 radio group)後出現三個狀態的位置不一致:

狀態scope UI 位置
初始載入(pagefind 還沒 mount)緊接 H1 下方
點擊 input(focus、空查詢)在 input 與 results 區之間(如預期)
輸入查詢(results 載入後)跑到所有結果的最下方

判讀

第一輪猜測:scope UI 自己的 CSS 在不同狀態下不同 — 用 playwright 看 computed style,發現三狀態下 scope 的 grid-row 都是 3、CSS 屬性沒變。

第二輪:用 playwright getBoundingClientRect() 量 scope 的位置,發現 y 座標確實在三狀態下不同。

第三輪:往上一層看「scope 的 grid container 是誰、container 的 grid template 在不同狀態下變了嗎」。發現 search-shell 的 grid template-rows 是 auto、自動依子元素內容撐開。

關鍵發現:.pagefind-ui__drawer 不是 .pagefind-ui 的直接子節點 — 它在 <form>

1.pagefind-ui (display: contents)
2└── form.pagefind-ui__form (grid-row: 2)
3    └── div.pagefind-ui__drawer (grid-row: 4 設了沒生效)

於是:

  • 初始:form 只含 input、row 2 矮、scope 在 row 3 緊接 row 2 下。
  • 輸入後:form 含 input + drawer(187 個結果)、row 2 撐到全頁高。grid-row 4 比 row 2 後 — 但 drawer 被 form 包住、整個 form 在 row 2 — scope(row 3)在 form 之後 = 結果之後。

scope 的 anchor(grid container 的 row 排列)在 form 撐開時改變 — anchor 在動、scope 跟著動。

執行

確認 anchor 問題後改用 absolute 定位:scope 浮在 form 之上、drawer 用 margin-top 讓位。scope 的 anchor 改為 .search-shellposition: relative、不再依賴 form 的尺寸。三狀態下位置一致。


拆解 anchor 的四個工具

1. 找元件的 reference

元件的 positionReference
static(預設)上一個 sibling 的下緣 / 父 container
relative元件原本在 flow 中的位置
absolute最近的 positioned ancestor
fixedviewport
sticky滾動容器
Grid itemGrid container 的 cell
Flex itemFlex container 的軸線

2. 用 getBoundingClientRect

1const el = document.querySelector('.search-scope');
2console.log(el.getBoundingClientRect());

在三個狀態下分別量、比對 y 座標。差異對應到「reference 在動」。

3. 往上追 ancestor chain

1let parents = []; let el = target;
2while (el) {
3  parents.push(el.tagName + '.' + el.className);
4  el = el.parentElement;
5}
6console.log(parents);

找出 reference 是誰、reference 的 reference 是誰、一層一層追到「不會動」的元素。

4. Computed style vs DOM tree 一起看

CSS 規則在 computed style 顯示為「我設了什麼」、DOM tree 顯示「實際巢狀關係」。兩者一起看才知道規則為什麼沒生效。


內在屬性比較:三種定位策略對狀態化錯位的抵抗

策略Anchor 穩定性狀態化飄移風險
Static / block flow低 — 任何前置元素變動都影響高 — sibling 撐高就被推下去
Grid / Flex item中 — 跟 container 設計綁定中 — container row 撐開時跟著動
Absolute(自定義 offset parent)高 — anchor 是固定 ancestor低 — anchor 不變則元件不動
Fixed最高 — anchor 是 viewport不會因內容變動飄移、但會因捲動變化

當一個元件需要在多種狀態下保持固定位置 — 優先 absolute(搭配明確的 offset parent)。


設計取捨:對抗狀態化飄移的定位策略

四種做法、各自機會成本不同。這個專案選 A(absolute + 自定義 offset parent)當預設、其他做法在特定情境合理。

A:Absolute + 穩定 offset parent(這個專案的預設)

  • 機制:元件 position: absolute、選定一個尺寸不隨狀態變動的 ancestor 作為 offset parent
  • 選 A 的理由:anchor 不變則元件不動、跨所有互動狀態位置一致
  • 適合:需要在多狀態下保持固定位置的元件
  • 代價:跳出 layout flow、附近元件需要手動讓位(margin spacer)

B:Grid / Flex item

  • 機制:把元件當 grid / flex container 的子項、用 grid-row / flex-order 排
  • 跟 A 的取捨:B 自然 reflow、A 完全 anchor-driven;B 在 container 內容隨狀態撐開時、grid 排序跟著重算
  • B 比 A 好的情境:container 尺寸不隨狀態變動的場景(純 layout、內容靜態)

C:Static / block flow(預設 layout)

  • 機制:不設 position、跟 sibling 自然排
  • 跟 A/B 的取捨:C 最簡單、A/B 主動處理 anchor;C 完全受前置 sibling 影響、狀態化飄移風險最高
  • C 才合理的情境:頁面內容極穩定、無狀態切換 — 否則第 N 個元素位置受前 N-1 個元素影響

D:Fixed(相對 viewport)

  • 機制position: fixed、anchor 是 viewport
  • 跟 A 的取捨:D 永遠在 viewport 同位置、A 跟著內容;D 對「導航類元件」合理、對「內容相關元件」不合理
  • D 比 A 好的情境:永遠可見的功能元件(toolbar、scroll-to-top button)

判讀徵兆

訊號可能的根因第一個該嘗試的動作
元件位置在不同互動狀態下不同Anchor 隨狀態變動用 playwright 量三個狀態下的 bounding rect
Computed style 三狀態下都一樣、但位置不同Reference 元素的尺寸在動量 reference 元素的尺寸、確認哪個狀態下變大
改元件 CSS 一個狀態好了、另一個壞用了 reference-dependent layout改用 absolute、選擇穩定的 offset parent
元件初始正確、互動後跑掉Reference 因 reactivity 撐開找出該 reference、用 absolute 跳出其影響

核心原則:元件「會飄」不是元件的個性、是它依賴的東西在飄。先找飄的源頭,不要追著元件改。