
Astroの目次で読者の現在地を追い続ける — スクロールスパイ・共有レール・モバイルのdvh
Astroの目次にスクロールスパイの追従、見出しの深さを問わない1本のレール、dvhによる高さ上限を実装した記録。モバイルのmax-heightを無効化していた::details-contentの生成ボックスまで。
目次
はじめに
自分の記事を読んでいて、10以上のセクションを進んだところで、今どこにいるのかを確かめようと目次に目をやりました。目次は記事の先頭を指したままでした。ページ上で「今どこにいるか」を伝えることだけが仕事のコンポーネントが、静かにその仕事をやめていたのです。私は自分の記事を通しで読むことがないので、それに気づいていませんでした。
最初に疑ったのは、リストが長くなりすぎてはみ出しているのだろう、ということでした。はみ出してはいませんでした。 存在した当初からスクロール可能でした。一度もしなかったのは、自分自身をスクロールさせることです。そのため現在位置を示すハイライトは、私の目には死んで見えていた列の、表示されていない部分のどこかにありました。
この記事では、読者に現在地を見失わせないために目次が実際に何を必要とするのかを追います。ページを乗っ取らずに読者へ追従すること、色がなくても残る形でアクティブな項目を示すこと、そしてスマートフォン版に上限を設けて画面の外へ流れ出さないようにすること。このうち2つは、見た目とはまったく違う原因を持っていました。
現在地を保つために必要なこと
要件は4つあり、自明なのは最初の1つだけです。
| 要件 | なぜ自動では満たされないのか |
|---|---|
| アクティブな項目が見えたままである | スクロールスパイはクラスを付けるだけです。それを見せるためにリストをスクロールさせるものはありません。 |
| 色がなくても印が残る | 色の入れ替えは、色を使えない読者には見えず、使える読者にとっても控えめすぎます。 |
| リストが画面に収まる | 高さの上限がない固定カードはビューポートを超えて伸びます。 |
| 列を奪い合うものがない | サイドバーの縦方向のスペースは希少な資源であり、見出しはそれを恒久的に消費します。 |
サイトはそのどれも満たしていませんでした。そして私は、最初の3つは満たしていると思い込んでいました。
サイドバーはすでにスクロールしていた
デスクトップの目次は記事の隣の aside の中にあります。スクロールできるボックスに必要な両方の要素は、すでにそろっていました。
.page[data-has-toc] .toc { position: sticky; top: var(--nav-height); max-height: calc(100vh - var(--nav-height)); overflow-y: auto;}つまり列はスクロールしました。欠けていたのは、それを動かすもののほうです。それを担えた2つのAPIをソース内で検索しても、src/ のどこにも scrollIntoView はなく、scrollTop への代入もありませんでした。スクロールスパイはアクティブな見出しを解決し、正しいリンクに aria-current="true" を設定し、そこで止まっていました。
それがバグでした。印を付けるだけのスクロールスパイは完璧に動作し、誰の役にも立ちません。ビューポートに収まるほど短いリストでは、スクロールもするものと見分けがつかず、助けが必要なほど長いリストでは必ず印が画面の外に出ているからです。
記事を動かさずに読者へ追従する
エージェントが修正案を出し、私はそれを採用しました。アクティブな項目が、スクロールポートの上下から内側に取った帯を出たときにだけ、サイドバーを動かすというものです。
もう一つの案は、変わるたびにアクティブな項目を中央へ置き直すことでした。そちらのほうが予測しやすい一方で動きが止まることがなく、ナビゲーションの補助を落ち着きのないものに変えてしまいます。最も近い端まで戻す方式なら、長いセクションを読んでいる間は列が静止したままになり、項目が帯の中にある間は0を返すことが、リストを手でスクロールした読者とこの機能が争わないための仕組みです。
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 });このコードの形を決めたのは、設計よりもプラットフォーム側の2つの事情でした。
scrollIntoView は使えませんでした。 素直な呼び出しは link.scrollIntoView({ block: 'nearest' }) ですが、これは上へたどる途中のスクロール可能な祖先を すべて 動かします。document も含めてです。呼べば、サイドバーを整えるために記事のほうを読者の下から動かしてしまいます。ページが動かないことを保証する唯一の方法は、その要素自身の scrollBy を直接動かすことです。
behavior 引数は渡す必要がありましたが、その理由はコメントに書かれたものと逆でした。 エージェントは、html に指定したグローバルな scroll-behavior: smooth によって、こちらが頼まなくてもサイドバーがアニメーションしてしまうため、prefers-reduced-motion 下で瞬時のスクロールを強制するためにこの引数があるのだ、と書きました。レビュアーがこれを指摘し、ブラウザで確認すると1行で決着しました。
html scroll-behavior: smoothaside scroll-behavior: autoscroll-behavior は 継承されるプロパティではありません。 html に置いたルールが aside のスクロールするボックスに届くことはなく、そこでは初期値の auto が計算されます。behavior を明示的に渡すことは依然として必要ですが、それはデフォルトがアニメーションするからではなく、ジャンプするからです。コードはたまたま正しく、記録された理由は逆でした。放置したときに高くつくのは後者のほうです。
すべての見出しの深さに1本のレール
アクティブな項目は、色の変化だけで示されていました。色だけに頼ることはWCAG 1.4.1の問題であると同時に、20項目のリストでは単純に見つけるには控えめすぎます。私は左ボーダーを太くするよう頼みました。エージェントは代わりに、アクティブな区間だけを太くした連続したレールを勧めてきました。アクティブなときにしか存在しないボーダーは、その幅の分だけ他のすべての項目をずらしてしまうから、という理由です。
面白いのはレールの置き場所です。このリストのインデントは padding-inline-start で表現されており、ネストされたリスト自身は padding を持ちません。そのため すべてのアンカーのボーダーボックスの開始辺は、深さに関係なく同じxに来ます。 その辺に固定した目印は、親と子で自動的に共有されます。最初に手が伸びるのはアンカーへの border-inline-start ですが、そちらは階層ごとにレールを1段ずつ階段状にします。
目印は常に描かれ、色が変わるだけなので、セクション間を移動してもテキストはずれません。
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);}太くなる方向は外側ではなく、アンカー自身の padding の上に重なる内側です。外側へ伸ばすとスクロールポートからはみ出し、overflow-y: auto は overflow-x も auto に計算するため、そのはみ出しは1ピクセルのレールと引き換えに横スクロールバーを買うことになっていました。

1つ目の色の罠の下には、2つ目の罠があります。強制カラーモードでは目印の background は Canvas に上書きされ、すべてのリンクの color は LinkText に潰れます。つまり 色による合図を置き換えるために作った色以外の合図が、まさにそのために作った読者に対してだけ消えます。 修正は、このリポジトリのリーディングプログレスバーがすでに使っているパターンです。
@media (forced-colors: active) { a[aria-current='true']::before { background: Highlight; forced-color-adjust: none; }}固定見出しは列の10.3%を使っていた
ここは私が間違えた部分であり、上の見出しにある数字がその代償です。
「On this page」の見出しを固定して、リストがスクロールしても見えたままになるように頼みました。エージェントは反対しましたが、私はそれを覆しました。測定されていない主張に対する判断としては、妥当なものです。そして実際に作られ、測定されました。
671pxの列に対し、固定された見出しは 68.8px、列の10.3%を恒久的に 占めていました。さらに悪いことに、単に場所を取るだけでなく、前のセクションが使うのと同じ予算から取ります。アクティブな項目が居てよい帯は599pxから546pxへ縮み、その分だけ追従が早く、そして頻繁に発火しました。私が頼んだ機能と、私がついさっき承認した機能が、互いに引っ張り合っていたのです。 しかもこのコストはスケールする方向が逆です。ノートPCの縦に短いビューポートでは列が縮む一方で、68.8pxは68.8pxのままだからです。
私はこれを取り除きました。あのラベルは記事の見出しのリストに付く見出しであることが自明で、その列が何なのかを見失う読者はいません。
不透明な見出しが静かに提供していたものが1つだけあり、それは置き換える必要がありました。リーディングプログレスバーはサイトヘッダーの中に bottom: -1px、高さ3pxで置かれているため、サイドバーの最初の2ピクセルに覆いかぶさり、スクロールして通り過ぎる項目の頭を切り取っていました。マスクのグラデーションなら、高さをまったく使わずにこれを処理できます。
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 を max-block-size 付きのフレックスの縦並びにして、中の nav に summary の残りを取らせます。
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でした。 スクロールしているのではなく、はみ出していたのです。そしてそのスニペットのすべての宣言は、書いたとおりぴったり適用されていました。
原因は、マークアップには現れないボックスです。
ブラウザは details 要素のスロットされた内容を ::details-content 疑似要素で包み、本当のフレックスアイテムは nav ではなくそのボックスです。 つまり nav への min-block-size: 0 は、間違ったボックスに適用された正しいCSSでした。生成されたボックスは決して縮まないので、nav にはその中でスクロールするための、高さの決まった親がありませんでした。Chrome 151はこれを、計算高さ 0px の display: block として報告します。
上限を ::details-content へ移せば動いたはずですが、それでも間違いでした。疑似要素はスクリプトから到達できず、カードは開いたときにアクティブな項目までスクロールする必要があるからです。そこで上限は nav 自身に置きました。これならスクロールポートであると同時に、querySelector が返せるものであり続けます。
details nav { max-block-size: calc(100dvh - var(--nav-height) - var(--summary-h) - 2rem); overflow-y: auto; overscroll-behavior: contain;}この1つの宣言には3つの詳細があります。
vhではなくdvh。 モバイルブラウザのUIはスクロールにつれて畳まれ、vhは背の高い状態を測るため、カードはツールバーの高さちょうどの分だけはみ出してしまいます。--summary-hはdetailsに宣言し、summaryのmin-block-sizeとして戻します。 そのため、上限が引く数値と summary の実際の高さがずれることはありません。summary は構造上1行です。2つの span はどちらもnowrapで、長いほうは省略記号になるからです。overscroll-behavior: containは、リストの端でのフリックが下の記事へ連鎖するのを止めます。
カードに上限を設けると、デスクトップの列が抱えていた問題が再現します。そこでカードは、スクロールではなく toggle のタイミングでアクティブな項目に追いつきます。カードが開いているということは、読者は読み進めているのではなく行き先を選んでいるということであり、開いている間に追従するのはノイズにしかなりません。
数字が語ったこと
スクロールの挙動を検証すること自体にも罠が2つあり、それぞれが見つかる前に、自信満々に間違った答えを出しました。
Playwrightの要素スクリーンショットは、測る対象そのものをスクロールさせます。 locator('aside.toc').screenshot() の呼び出しは先に scrollIntoViewIfNeeded() を実行し、それがサイドバー自身のスクロールポートをスクロールさせました。出てきた画像は、scrollTop がまだ0のまま、読者に見事に追従した列を映していました。
グローバルなスムーススクロールのせいで、測定する時点ではページがまだ到着していません。 4000、9000、14000を要求したスイープが実際に読んだのは3677、8577、13568で、そこから導いた結論はすべて間違っていました。window.scrollTo({ behavior: 'instant' }) で駆動し、1.8秒待って落ち着かせることで解決しました。
これを直したうえで、落ち着いた後の位置は次のようになります。2つのギャップの列は、アクティブな項目から帯のそれぞれの端までの距離なので、どちらかが負になっていれば追従が失敗したことを意味します。
| ページのオフセット | アクティブな項目 | サイドバーの 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 |
最初の3行は、機能が動かないことを選んでいる様子です。 項目は帯の中にあるのでサイドバーは動かず、それがこの機能を落ち着きのないものに感じさせないための挙動です。セクションを変えずに手でサイドバーを少し動かすと、動かした場所にそのまま留まり、その間 window.scrollY は1ピクセルも変化しませんでした。
390x760のビューポートでは、760pxの画面に対してカードは630pxで、中のリストは473pxスクロールし、端までフリックしても記事は0しか動きません。
まとめ
今どこにいるかを伝えるコンポーネントが、私にそれを伝えるのをやめていました。そして修理すべき場所は、ほとんどどれも私が予想した場所ではありませんでした。
- リストははみ出してなどいませんでした。 最初からスクロール可能で、単にそれをスクロールさせるものがなかっただけです。つまり足りなかったのは高さではなく振る舞いでした。
- 最も近い端まで戻す方式は、中央へ置き直す方式に勝ります。 アクティブな項目が帯を出たときにだけ動かすことが、セクションを読んでいる間に列を静止させ、手でスクロールした読者を上書きしないための条件です。
scroll-behaviorは継承されません。htmlへのグローバルなsmoothは他のどのスクロールボックスについても何も言っておらず、そう主張するコメントはバグよりも長く生き残ります。- 固定見出しは列の10.3%を使い、しかもそれは読者に追従する帯が使うのと同じ予算からの支出でした。頼んだのは私で、測定結果が返ってきて、私が外しました。
detailsの中で本当のフレックスアイテムは::details-contentです。 自分が書いた要素に向けたCSSはきれいに適用され、そして何も起こしません。疑似要素はスクリプトから到達できないので、いずれにせよスクロールポートは実在する要素でなければなりません。
ここで仕事をしたのは測定です。 このうち3つは何かを秤にかけるまで見えず、そのうち1つは私自身が主張した機能でした。