タグ
黒いカードにピンクの炎を伴う白のAstroロケットロゴとロゴタイプ

Astroの目次で読者の現在地を追い続ける — スクロールスパイ・共有レール・モバイルのdvh

Astroの目次にスクロールスパイの追従、見出しの深さを問わない1本のレール、dvhによる高さ上限を実装した記録。モバイルのmax-heightを無効化していた::details-contentの生成ボックスまで。

目次

はじめに

自分の記事を読んでいて、10以上のセクションを進んだところで、今どこにいるのかを確かめようと目次に目をやりました。目次は記事の先頭を指したままでした。ページ上で「今どこにいるか」を伝えることだけが仕事のコンポーネントが、静かにその仕事をやめていたのです。私は自分の記事を通しで読むことがないので、それに気づいていませんでした。

最初に疑ったのは、リストが長くなりすぎてはみ出しているのだろう、ということでした。はみ出してはいませんでした。 存在した当初からスクロール可能でした。一度もしなかったのは、自分自身をスクロールさせることです。そのため現在位置を示すハイライトは、私の目には死んで見えていた列の、表示されていない部分のどこかにありました。

この記事では、読者に現在地を見失わせないために目次が実際に何を必要とするのかを追います。ページを乗っ取らずに読者へ追従すること、色がなくても残る形でアクティブな項目を示すこと、そしてスマートフォン版に上限を設けて画面の外へ流れ出さないようにすること。このうち2つは、見た目とはまったく違う原因を持っていました。

現在地を保つために必要なこと

要件は4つあり、自明なのは最初の1つだけです。

要件 なぜ自動では満たされないのか
アクティブな項目が見えたままである スクロールスパイはクラスを付けるだけです。それを見せるためにリストをスクロールさせるものはありません。
色がなくても印が残る 色の入れ替えは、色を使えない読者には見えず、使える読者にとっても控えめすぎます。
リストが画面に収まる 高さの上限がない固定カードはビューポートを超えて伸びます。
列を奪い合うものがない サイドバーの縦方向のスペースは希少な資源であり、見出しはそれを恒久的に消費します。

サイトはそのどれも満たしていませんでした。そして私は、最初の3つは満たしていると思い込んでいました。

サイドバーはすでにスクロールしていた

デスクトップの目次は記事の隣の aside の中にあります。スクロールできるボックスに必要な両方の要素は、すでにそろっていました。

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

つまり列はスクロールしました。欠けていたのは、それを動かすもののほうです。それを担えた2つのAPIをソース内で検索しても、src/ のどこにも scrollIntoView はなく、scrollTop への代入もありませんでした。スクロールスパイはアクティブな見出しを解決し、正しいリンクに aria-current="true" を設定し、そこで止まっていました。

それがバグでした。印を付けるだけのスクロールスパイは完璧に動作し、誰の役にも立ちません。ビューポートに収まるほど短いリストでは、スクロールもするものと見分けがつかず、助けが必要なほど長いリストでは必ず印が画面の外に出ているからです。

記事を動かさずに読者へ追従する

エージェントが修正案を出し、私はそれを採用しました。アクティブな項目が、スクロールポートの上下から内側に取った帯を出たときにだけ、サイドバーを動かすというものです。

アクティブな項目が帯から出たときだけ、サイドバーがスクロールする同じ縦長の目次の列が 3 つ並ぶ。それぞれの内側に、上端から 24 ピクセル、下端から 48 ピクセル内側に入った帯が示されている。1 つ目ではアクティブな項目が帯の中にあり、列は動かない。2 つ目では項目が帯より上にあり、矢印が帯の上端まで戻る動きを示す。3 つ目では項目が帯より下にあり、矢印が下端まで戻る動きを示す。動く場合でも、列は最も近い端に届く分しかスクロールしない。目次の列が動かすのは、自身のスクロール領域だけ帯の中にある項目アクティブな項目移動しない帯より上に出た項目アクティブな項目上端まで戻る帯より下に出た項目アクティブな項目下端まで戻る24px48px項目が帯から出たときだけ、列がスクロールする
アクティブな項目を追いかけるのは、それが帯から出たときだけです。帯の中にある間、列は読者が置いた場所に留まります。

もう一つの案は、変わるたびにアクティブな項目を中央へ置き直すことでした。そちらのほうが予測しやすい一方で動きが止まることがなく、ナビゲーションの補助を落ち着きのないものに変えてしまいます。最も近い端まで戻す方式なら、長いセクションを読んでいる間は列が静止したままになり、項目が帯の中にある間は0を返すことが、リストを手でスクロールした読者とこの機能が争わないための仕組みです。

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

このコードの形を決めたのは、設計よりもプラットフォーム側の2つの事情でした。

scrollIntoView は使えませんでした。 素直な呼び出しは link.scrollIntoView({ block: 'nearest' }) ですが、これは上へたどる途中のスクロール可能な祖先を すべて 動かします。document も含めてです。呼べば、サイドバーを整えるために記事のほうを読者の下から動かしてしまいます。ページが動かないことを保証する唯一の方法は、その要素自身の scrollBy を直接動かすことです。

behavior 引数は渡す必要がありましたが、その理由はコメントに書かれたものと逆でした。 エージェントは、html に指定したグローバルな scroll-behavior: smooth によって、こちらが頼まなくてもサイドバーがアニメーションしてしまうため、prefers-reduced-motion 下で瞬時のスクロールを強制するためにこの引数があるのだ、と書きました。レビュアーがこれを指摘し、ブラウザで確認すると1行で決着しました。

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

scroll-behavior継承されるプロパティではありません。 html に置いたルールが aside のスクロールするボックスに届くことはなく、そこでは初期値の auto が計算されます。behavior を明示的に渡すことは依然として必要ですが、それはデフォルトがアニメーションするからではなく、ジャンプするからです。コードはたまたま正しく、記録された理由は逆でした。放置したときに高くつくのは後者のほうです。

すべての見出しの深さに1本のレール

アクティブな項目は、色の変化だけで示されていました。色だけに頼ることはWCAG 1.4.1の問題であると同時に、20項目のリストでは単純に見つけるには控えめすぎます。私は左ボーダーを太くするよう頼みました。エージェントは代わりに、アクティブな区間だけを太くした連続したレールを勧めてきました。アクティブなときにしか存在しないボーダーは、その幅の分だけ他のすべての項目をずらしてしまうから、という理由です。

面白いのはレールの置き場所です。このリストのインデントは padding-inline-start で表現されており、ネストされたリスト自身は padding を持ちません。そのため すべてのアンカーのボーダーボックスの開始辺は、深さに関係なく同じxに来ます。 その辺に固定した目印は、親と子で自動的に共有されます。最初に手が伸びるのはアンカーへの border-inline-start ですが、そちらは階層ごとにレールを1段ずつ階段状にします。

padding によるインデントなら、見出しの深さが変わってもレールは 1 本同じ目次のリストが 2 列に並ぶ。どちらも h2 の項目 2 つと、その下にインデントされた h2 あたり 2 つの h3 の項目からなる。左ではインデントが padding から生まれるため、6 つの項目すべての左側に 1 本の連続したレールが同じ x 座標で走り、インデントされた h3 の 1 つがアクセント色の太い区間で示される。右ではアンカーごとのボーダーにより、h2 の項目のレールと、インデントされた h3 の項目のレールが別々の x 座標に置かれ、1 本の線ではなく階段状に途切れた区間になる。リストの横に 1 本のレールpadding が動かすのはテキストだけ現在深さが変わってもレールは 1 本アンカーごとのボーダーボーダーは枠ごとインデントされる現在階層ごとにレールが 1 本ずつ
インデントは padding にあるので、1本のレールが両方の見出しレベルを兼ねます。ボーダーをアンカーに置くと、レールは階層ごとに1本ずつになります。

目印は常に描かれ、色が変わるだけなので、セクション間を移動してもテキストはずれません。

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

太くなる方向は外側ではなく、アンカー自身の padding の上に重なる内側です。外側へ伸ばすとスクロールポートからはみ出し、overflow-y: autooverflow-xauto に計算するため、そのはみ出しは1ピクセルのレールと引き換えに横スクロールバーを買うことになっていました。

サイドバーの目次。リストの横に連続した縦のレールが走り、アクティブな項目が青い太い区間で示され、下端でリストがフェードアウトしている

結果として、レールは1本、現在のセクションにはアクセント色の太い区間、そしてリストが続く両端にはフェードが入りました。

1つ目の色の罠の下には、2つ目の罠があります。強制カラーモードでは目印の backgroundCanvas に上書きされ、すべてのリンクの colorLinkText に潰れます。つまり 色による合図を置き換えるために作った色以外の合図が、まさにそのために作った読者に対してだけ消えます。 修正は、このリポジトリのリーディングプログレスバーがすでに使っているパターンです。

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

固定見出しは列の10.3%を使っていた

ここは私が間違えた部分であり、上の見出しにある数字がその代償です。

「On this page」の見出しを固定して、リストがスクロールしても見えたままになるように頼みました。エージェントは反対しましたが、私はそれを覆しました。測定されていない主張に対する判断としては、妥当なものです。そして実際に作られ、測定されました。

固定した見出しはサイドバー列の 1 割を使い続ける同じ縮尺で描かれた 2 本の縦棒があり、どちらも同じ 671px のサイドバー列を表します。見出しを固定した左の棒は、上端の 68.8px、つまり列全体の 10.3% を「On this page」の固定帯に使うため、アクティブな項目が収まる帯は 546px になります。右の棒には見出し帯がないため、同じ帯が 599px あります。どちらの帯も下端の位置は同じなので、53px の差はすべて右の棒の上部に強調された区画として現れます。見出しを固定した場合固定しない場合固定された「On this page」見出し68.8px列全体の 10.3%アクティブな項目が収まる帯546px671px の列+53pxその分だけ帯が広がりますアクティブな項目が収まる帯599px671px の列固定した見出しは列の 10.3% を占め続けます。しかもその分は、読者を追う帯が使うのと同じ予算から引かれます。
実際のページ上で測定した値です。固定した見出しは列の10分の1を消費し、その1ピクセルずつが、追従に使える帯から引かれています。

671pxの列に対し、固定された見出しは 68.8px、列の10.3%を恒久的に 占めていました。さらに悪いことに、単に場所を取るだけでなく、前のセクションが使うのと同じ予算から取ります。アクティブな項目が居てよい帯は599pxから546pxへ縮み、その分だけ追従が早く、そして頻繁に発火しました。私が頼んだ機能と、私がついさっき承認した機能が、互いに引っ張り合っていたのです。 しかもこのコストはスケールする方向が逆です。ノートPCの縦に短いビューポートでは列が縮む一方で、68.8pxは68.8pxのままだからです。

私はこれを取り除きました。あのラベルは記事の見出しのリストに付く見出しであることが自明で、その列が何なのかを見失う読者はいません。

不透明な見出しが静かに提供していたものが1つだけあり、それは置き換える必要がありました。リーディングプログレスバーはサイトヘッダーの中に bottom: -1px、高さ3pxで置かれているため、サイドバーの最初の2ピクセルに覆いかぶさり、スクロールして通り過ぎる項目の頭を切り取っていました。マスクのグラデーションなら、高さをまったく使わずにこれを処理できます。

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 カードになります。高さの上限がまったくなかったため、スマートフォンで開くとリストが画面の下端を越えて伸び、そこへ戻る手段がありませんでした。私は上限を設けるよう頼みました。

最初の試みはきれいなものでした。detailsmax-block-size 付きのフレックスの縦並びにして、中の navsummary の残りを取らせます。

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ボックスツリーの図です。外側のボックスは display: flex と flex-direction: column を指定した details 要素で、その直下に 2 つのフレックスアイテム、すなわち summary のボックスと、破線で描かれた ::details-content のボックスが並びます。後者はマークアップに存在しない生成ボックスで、計算値は display: block です。nav 要素はその生成ボックスの内側にあり、1 階層深いためフレックスアイテムにはなりません。注釈は 2 つあり、nav に指定した min-block-size: 0 は正しい CSS でありながら対象のボックスが違うので生成ボックスは縮まず nav にはスクロールできる親が存在しないこと、そして疑似要素はスクリプトから取得できないためスクロールポートは実在する nav 要素でなければならないことを示します。detailsdisplay: flex; flex-direction: column2 つのフレックスアイテムsummaryフレックスアイテム::details-contentマークアップには存在しない生成ボックスdisplay: blockフレックスアイテムnavフレックスアイテムではありません(1 階層深い)正しい CSS、間違ったボックスmin-block-size: 0 は nav に指定され、CSS としては正しく適用されます。しかし nav はフレックスアイテムではないため、生成ボックスは縮まず、コンテンツ分の高さを保ち続けます。なぜ上限を nav に置いたか疑似要素はスクリプトから取得できないため、スクロールポートは実在する要素である必要があります。nav に向けた CSS は正しく適用され、そして何も起こりません。nav には高さの決まった親がないため、スクロールせずあふれます。
スロットされた内容は生成ボックスに包まれます。nav は孫要素なので、そこへ向けたフレックスのプロパティは適用され、そして何も起こしません。

ブラウザは details 要素のスロットされた内容を ::details-content 疑似要素で包み、本当のフレックスアイテムは nav ではなくそのボックスです。 つまり nav への min-block-size: 0 は、間違ったボックスに適用された正しいCSSでした。生成されたボックスは決して縮まないので、nav にはその中でスクロールするための、高さの決まった親がありませんでした。Chrome 151はこれを、計算高さ 0pxdisplay: block として報告します。

上限を ::details-content へ移せば動いたはずですが、それでも間違いでした。疑似要素はスクリプトから到達できず、カードは開いたときにアクティブな項目までスクロールする必要があるからです。そこで上限は nav 自身に置きました。これならスクロールポートであると同時に、querySelector が返せるものであり続けます。

src/components/TableOfContents.astro
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-hdetails に宣言し、summarymin-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つは私自身が主張した機能でした。

参考リンク

この記事をシェア