# 讓 Astro 目錄記住你讀到哪裡 — 捲動監看、共用軌線，以及手機上的 dvh

> 為 Astro 部落格的目錄加上跟隨讀者的捲動、跨層級共用的軌線，以及在手機上悄悄吃掉 max-height 的 ::details-content 虛擬元素。

- Source: https://oharu121.com/zh-tw/blog/astro-table-of-contents-scroll-spy-shared-rail-details-content/
- Published: 2026-08-13T10:03:19+09:00
- Tags: Astro, CSS

---
## 引言

我在讀自己寫的一篇文章，往下讀了十幾個章節，抬眼看了一下目錄，想確認自己讀到哪裡。它顯示的是文章的開頭。這個頁面上唯一一個全部職責就是告訴你人在哪裡的元件，早就默默停止做這件事了，而我一直沒發現，因為我從來不會把自己的文章從頭讀到尾。

我第一個猜測是清單長到溢出了。**它並沒有溢出。** 它從存在的第一天起就是可以捲動的。它從來沒做過的事，是捲動它自己，於是標記我位置的那個高亮，就停在一個從我坐的地方看過去像是死掉的欄位裡、可見範圍之外的某處。

本文會走過一份目錄若要讓讀者不迷路，實際上需要哪些東西：跟隨讀者但不劫持頁面、標記作用中的項目並讓這個標記不靠顏色也活得下來，以及替手機版加上高度上限，讓它不會跑到螢幕外面去。其中兩項的成因，跟它們表面上看起來的樣子完全不一樣。

## 不迷路需要哪些條件

四個條件，而且只有第一個是顯而易見的。

| 條件 | 為什麼它不會自動成立 |
| --- | --- |
| 作用中的項目要保持可見 | 捲動監看只負責設一個 class。沒有任何東西會去捲動清單把它露出來。 |
| 這個標記不靠顏色也要活得下來 | 換個顏色對無法使用顏色的讀者是隱形的，對看得見顏色的人來說也很安靜。 |
| 清單要塞得進它所在的螢幕 | 一張沒有高度上限的固定卡片，會長到超出可視區域。 |
| 不要有東西來搶這一欄 | 側邊欄裡的垂直空間才是稀缺資源，而一個標題會永久花掉它。 |

**這個網站一個都沒滿足**，而我原本以為前三個是有的。

## 側邊欄本來就會捲動

桌機版的目錄住在文章旁邊的一個 `aside` 裡。它本來就同時具備了捲動容器的兩個要件：

```css title="src/layouts/BaseLayout.astro"
.page[data-has-toc] .toc {
	position: sticky;
	top: var(--nav-height);
	max-height: calc(100vh - var(--nav-height));
	overflow-y: auto;
}
```

所以這一欄是會捲動的。缺的是任何一個會去移動它的東西。在原始碼裡搜尋那兩個可能派得上用場的 API，結果是 **`src/` 底下沒有任何 `scrollIntoView`，也沒有任何一處 `scrollTop` 賦值**。捲動監看解析出作用中的標題，在對的連結上設好 `aria-current="true"`，然後就停在那裡了。

那就是這個 bug。**一個只會標記的捲動監看，運作得完美無缺，而且幫不上任何人的忙**，因為在任何短到塞得進可視區域的清單上，它和一個也會捲動的版本毫無分別；而每一份長到真的需要幫忙的清單，都是那個標記早已跑到畫面外的清單。

## 跟隨讀者，但不移動文章

這個修法是智能體提出來的，我採用了：只有當作用中的項目離開一段從捲動容器上下緣內縮出來的區間時，才去移動側邊欄。

*Figure — RevealBand: 只有當作用中的項目離開區間時，才會去追它。在區間裡面，這一欄就停在讀者放它的地方。*

另一個選項是每次變動都把作用中的項目重新置中。那更好預測，而且它永遠不會停下來，於是把一個導覽輔助變成了一個讓人手停不下來的小玩意。最近邊緣的做法，讓一個長章節被閱讀的期間欄位保持不動，而**在項目位於區間內時回傳零，正是讓這個功能不去跟一位手動捲過清單的讀者對著幹的關鍵。**

```ts title="src/components/TableOfContents.astro"
const top = port.top + 24;
const bottom = port.bottom - 48;

const above = box.top - top;
const below = box.bottom - bottom;
const delta = above < 0 ? above : below > 0 ? below : 0;

if (delta) scroller.scrollBy({ top: delta, behavior });
```

有兩個平台細節對這段程式碼的影響，比設計本身還大。

**`scrollIntoView` 不能用。** 最直覺的呼叫是 `link.scrollIntoView({ block: 'nearest' })`，而它會沿路往上走過*每一個*可捲動的祖先，包含文件本身。呼叫它，就等於為了整理側邊欄而把文章從讀者腳下捲走。只驅動那一個元素自己的 `scrollBy`，是唯一能保證頁面不會動的做法。

**`behavior` 這個引數非傳不可，而理由跟被寫進註解裡的那個剛好相反。** 智能體寫的是：`html` 上全域的 `scroll-behavior: smooth` 會讓側邊欄不管我們有沒有要求都做動畫，所以這個引數的存在，是為了在 `prefers-reduced-motion` 之下強制瞬間捲動。一位校閱者提出了疑問，而到瀏覽器裡確認，一行就結案了：

```text
html  scroll-behavior: smooth
aside scroll-behavior: auto
```

`scroll-behavior` **不是一個會繼承的屬性。** `html` 上的那條規則從來沒有抵達 aside 的捲動容器，那個容器算出來的是初始值 `auto`。明確傳入 `behavior` 依然是必要的，但原因是預設會用跳的，而不是它會做動畫。這段程式碼是誤打誤撞寫對的，而被記下來的理由是反的；兩者之中，後者留著不管的代價高得多。

## 一條軌線服務所有標題層級

作用中的項目原本只用一個顏色變化來標記，除此之外什麼都沒有。單靠顏色既是 WCAG 1.4.1 的問題，在一份二十個項目的清單裡，也單純安靜到找不到。我要的是一條更粗的左邊框。智能體回過頭來建議改用一條連續的軌線加上一段較粗的作用中區段，理由是一個只有在作用中才存在的邊框，會把其他每一個項目都推移它自己的寬度。

有意思的是軌線該放在哪裡。這份清單的縮排是用 `padding-inline-start` 編碼的，而巢狀清單本身不帶任何 padding，所以**不論深度為何，每個 anchor 的 border-box 起始邊都落在同一個 x 上。** 一個釘在那條邊上的標記，會自動被父項與子項共用。而把 `border-inline-start` 加在 anchor 上，也就是第一個會想到的做法，反而讓軌線每一層階梯式錯開一次。

*Figure — SharedRail: 縮排住在 padding 裡，所以一條軌線就能同時服務兩個標題層級。把邊框放在 anchor 上，則會變成一個層級一條軌線。*

這個標記永遠都會被畫出來，變的只有顏色，所以在章節之間移動不會推移任何文字：

```css title="src/components/TocList.astro"
a::before {
	content: '';
	position: absolute;
	inset-block: 0;
	inset-inline-start: -2px;
	inline-size: 3px;
	background: transparent;
}

a[aria-current='true']::before {
	background: var(--toc-marker, transparent);
}
```

它是往內加粗，蓋在 anchor 自己的 padding 上，而不是往外。往外長會突出捲動容器，而 `overflow-y: auto` 會把 `overflow-x` 也算成 `auto`，於是這一點突出，就會為了一個像素的軌線換來一條水平捲軸。

*Figure: 成果：一條軌線、目前章節上一段加粗的強調區段，以及清單在兩端延續下去的淡出效果。*

第一個顏色陷阱底下還藏著第二個。在強制色彩模式下，標記的 `background` 會被覆寫成 `Canvas`，每個連結的 `color` 都塌成 `LinkText`，於是**那個為了取代顏色訊號而做出來的非顏色訊號，剛好對它所服務的那群讀者消失了。** 修法就是這個儲存庫裡閱讀進度條已經在用的模式：

```css title="src/components/TocList.astro"
@media (forced-colors: active) {
	a[aria-current='true']::before {
		background: Highlight;
		forced-color-adjust: none;
	}
}
```

## 固定標題花掉了整欄的 10.3%

這是我判斷錯的地方，而上面那個標題裡的數字就是它的代價。

我要求把「On this page」這個標題改成固定的，讓它在清單捲動時仍然可見。智能體不建議這麼做，而我推翻了它；在一個未經量測的主張面前，這是個合理的決定。然後它被做出來，也被量了。

*Figure — StickyCost: 在實際頁面上量到的。被釘住的標題吃掉整欄的十分之一，而它的每一個像素，都是從揭示機制可用的區間裡扣出來的。*

在一欄 671px 的高度上，被釘住的標題是 **68.8px，也就是整欄的 10.3%，而且是永久的。** 更糟的是，它不只是佔掉空間，它佔的還是上一節所花費的同一份額度：作用中的項目被允許停留的區間，從 599px 縮到 546px，於是揭示觸發得更早、也更頻繁。**我要求的那個功能，和我剛剛才批准的那個功能，正在互相拉扯。** 這個代價的縮放方向也是錯的，因為一個比較矮的筆電可視區域會讓欄位變短，而 68.8px 還是 68.8px。

我把它拿掉了。那個標籤自明地就是一份文章標題清單的說明文字，不會有任何讀者搞不清楚那一欄是什麼。

那個不透明的標題原本默默提供的一件事，確實得換個做法補上。閱讀進度條位於網站頁首內 `bottom: -1px` 的位置，高度是 3px，所以它突出到側邊欄最上面的兩個像素上，一直在把捲過去的項目頂端切掉。用一段遮罩漸層來處理這件事，完全不用花掉任何高度：

```css title="src/layouts/BaseLayout.astro"
mask-image: linear-gradient(
	to bottom,
	transparent,
	#000 1rem,
	#000 calc(100% - 2rem),
	transparent
);
```

那段漸層接著製造出它自己的缺陷，是在校閱時抓到的。循序焦點導覽只會把取得焦點的元素捲到剛好進入視野，這會讓它停在漸層的斜坡裡，把它的焦點外框算繪成半透明。在同一個元素上加 `scroll-padding-block: 1rem 2rem`，可以讓瀏覽器自己的焦點捲動落在兩段淡出之外。

## ::details-content 吃掉了手機版的 max-height

在 72rem 以下，目錄是一張黏在頁首底下的 `details` 卡片。它完全沒有高度上限，所以在手機上打開它，會長出一份跑到螢幕底部外面、而且沒辦法回頭的清單。我要求給它加上上限。

第一次嘗試是最整齊的那個。把 `details` 做成一個 flex 直向容器並給它 `max-block-size`，讓裡面的 `nav` 拿走 `summary` 剩下的空間：

```css
details {
	display: flex;
	flex-direction: column;
	max-block-size: calc(100dvh - var(--nav-height) - 2rem);
}

details nav {
	min-block-size: 0;
	overflow-y: auto;
}
```

在瀏覽器裡量測，卡片正確地被限制在 628px。裡面的 nav 回報的高度卻是 **1057px，而 `scrollHeight - clientHeight` 等於 0。** 它是在溢出，不是在捲動，而那段程式碼裡的每一條宣告，都完全照著字面在生效。

成因是一個不會出現在標記裡的框。

*Figure — DetailsBoxTree: 被 slot 進來的內容，會被包進一個產生出來的框裡。nav 是孫節點，所以瞄準它的 flex 屬性會生效，但什麼都不會發生。*

瀏覽器會把 `details` 元素被 slot 進來的內容，包進一個 `::details-content` 虛擬元素裡，而**真正的 flex 項目是那個框，不是 `nav`。** 所以 nav 上的 `min-block-size: 0` 是套用在錯誤的框上的正確 CSS：那個產生出來的框從來沒有縮小過，於是 nav 沒有一個有界的父容器可以在裡面捲動。Chrome 151 回報它是 `display: block`，計算後的高度是 `0px`。

把上限移到 `::details-content` 上會有效，但依然是錯的，因為**虛擬元素無法從指令碼觸及**，而這張卡片打開時需要被捲動到作用中的項目。因此上限落在 `nav` 自己身上，這讓它同時是捲動容器，也是 `querySelector` 拿得回來的東西：

```css title="src/components/TableOfContents.astro"
details nav {
	max-block-size: calc(100dvh - var(--nav-height) - var(--summary-h) - 2rem);
	overflow-y: auto;
	overscroll-behavior: contain;
}
```

這一條宣告裡有三個細節：

- **用 `dvh` 而不是 `vh`。** 手機瀏覽器的介面會在你捲動時收起來，而 `vh` 量的是它展開時的狀態，所以卡片會剛好突出工具列的高度。
- **`--summary-h` 宣告在 `details` 上，再餵回給 `summary` 當作它的 `min-block-size`**，所以上限所扣掉的那個數字，和那條 summary 的實際高度不會各走各的。這條 summary 在構造上就是單行的，因為它的兩個 span 都是 `nowrap`，長的那個會以刪節號截斷。
- **`overscroll-behavior: contain`** 阻止清單捲到底之後的一次滑動連鎖到下方的文章上。

給卡片加上限，等於重現了桌機欄位原本的問題，所以這張卡片是在 `toggle` 時追上作用中的項目，而不是在捲動時。卡片開著，代表讀者正在挑一個目的地，而不是順著讀下去；在它開著的時候跟隨他們，只會是噪音。

## 數字說了什麼

驗證捲動行為本身也有兩個陷阱，而且每一個在被抓到之前，都給出過一個很有自信的錯誤答案。

**Playwright 的元素截圖會捲動它所量測的那個東西。** 呼叫 `locator('aside.toc').screenshot()` 會先跑一次 `scrollIntoViewIfNeeded()`，而那捲動了側邊欄自己的捲動容器。拍出來的圖，顯示一欄看似漂亮地跟隨了讀者，而它的 `scrollTop` 其實還是 0。

**全域的平滑捲動意味著你量測的時候頁面還沒到位。** 一輪要求 4000、9000 與 14000 的掃描，實際讀到的是 3677、8577 與 13568，而從中得出的每一個結論都是錯的。改用 `window.scrollTo({ behavior: 'instant' })` 驅動，並等 1.8 秒讓它安定下來，就修好了。

修正之後，安定下來的位置長這樣。兩個間距欄位是作用中的項目到區間各一邊的距離，所以其中任何一個出現負數，都代表揭示失敗了：

| 頁面位移 | 作用中的項目 | 側邊欄 scrollTop | 上緣間距 | 下緣間距 |
| --- | --- | --- | --- | --- |
| 0 | Introduction | 0 | 45 | 692 |
| 4000 | Cacheable | 0 | 243 | 329 |
| 9000 | Routing the QUERY method | 0 | 450 | 122 |
| 14000 | Python client (httpx) | 246 | 572 | 0 |
| 19000 | The real axis of competition | 605 | 572 | 0 |

**前三列是這個功能拒絕動作。** 項目位於區間內，所以側邊欄不會移動，而這正是讓它不顯得神經質的行為。在不換章節的情況下用手推一下側邊欄，它會回到原本被放的位置，而且**在這整段過程中，`window.scrollY` 一個像素都沒有變過。**

在 390x760 的可視區域上，卡片量到 630px、螢幕是 760px，裡面的清單可捲動 473px，而把它滑到底之後，文章移動了 0。

## 總結

那個負責告訴你人在哪裡的元件，已經不再告訴我我在哪裡了，而關於這次修復，幾乎沒有一件事出現在我以為會找到它的地方。

- **這份清單從來沒有溢出過。** 它一直都是可捲動的，只是沒有任何東西去捲動它，所以要補的是一個缺席的行為，而不是一個缺席的高度。
- **最近邊緣勝過重新置中。** 只在作用中的項目離開區間時才移動，才能讓欄位在一個章節被閱讀的期間保持不動，也才不會去覆蓋一位手動捲過它的讀者。
- **`scroll-behavior` 不會繼承。** `html` 上一個全域的 `smooth`，對其他任何捲動容器什麼都沒說，而一句主張相反的註解，活得比那個 bug 還久。
- **一個固定標題花掉了整欄的 10.3%**，而且扣的是跟隨讀者的區間所用的同一份額度。是我要求的，量測結果回來了，然後我把它拿掉了。
- **`::details-content` 才是 `details` 裡真正的 flex 項目。** 瞄準你所寫下的那個元素的 CSS，會乾淨地生效，而且什麼都不做；又因為虛擬元素從指令碼上無法觸及，捲動容器終究得是一個真的元素。

**在這裡真正做了事的是量測。** 這裡面有三件事，在有東西被放上秤之前都是看不見的，而其中一件，還是我自己爭取來的功能。

## 參考連結

- [CSSOM View Module：`scroll-behavior`，包含寫明它不會繼承的那一行](https://drafts.csswg.org/cssom-view/#propdef-scroll-behavior)
- [HTML Standard：`details` 元素與它被 slot 進來的內容框](https://html.spec.whatwg.org/multipage/interactive-elements.html#the-details-element)
- [MDN：`::details-content`](https://developer.mozilla.org/en-US/docs/Web/CSS/::details-content)
- [WCAG 2.2：顏色的使用（1.4.1）](https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html)
- [WCAG 2.2：焦點未被遮蔽（2.4.11）](https://www.w3.org/WAI/WCAG22/Understanding/focus-not-obscured-minimum.html)
- [MDN：可視區域百分比長度，以及 `dvh` 為什麼和 `vh` 不同](https://developer.mozilla.org/en-US/docs/Web/CSS/length#viewport-percentage_lengths)
