GODRICH

スクロール インジケーター 9選 — 現在地がすぐわかる

スクロール インジケーターは、長いページをどこまで読んだかを棒・数字・印で伝えるUI部品です。この記事では、ライブラリーに頼らないHTML・SCSS・少量のJavaScriptだけで組んだ九つの現在地表示を、そのまま貼れる断片コードと一緒に載せます。どれも上のグリッドの中で実際に動くので、まず九つの動きを見てください。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

並び順は目立たせ方の強さではなく、読み手が一枚のページで辿る時間の順です。まだ一行も読んでいない人に「下に続く」と知らせる(01)ところから始まって、読み進むあいだの連続した進み(02)、四つの節目(03)、今いる段の明示(04)、番号による位置の言語化(05)へ進み、横方向の位置を伝える影(06)でページの内側を締めくくります。そこから先はページの外へ出て、ブラウザーのタブという顔に進みを書く(07)、離れて戻った人を最深点まで迎えに行く(08)、記事が終わった後に次の一編へ手を伸ばす(09)までを、この順で並べました。

01最初の画面だけで揺れる下向きヒント

ページがまだ一行も動いていないあいだだけ、下向きの矢印がtranslateY(5px)の往復で揺れて、続きが下にあることを伝えます。scrollTopが8pxを超えると.is-goneが付いて矢印は下へ120%退いて薄れ、先頭に戻るとまた立ち上がります。最初の画面が埋まっていて続きに気づけない紹介ページに向いていて、24コマのうち20コマに動きがあり、変化が及んだ面積は19.874%です。

scrollToptranslateYanimation-fill-mode
.sh__hint {
  position: absolute;
  left: 0;
  right: 0;
  bottom: $sp-1;
  transition: transform $dur-quick $easing, opacity $dur-instant linear;
}
.sh.is-gone .sh__hint {
  transform: translateY(120%);
  opacity: 0;
}
@keyframes sh-bob {
  0%, 100% { transform: translateY(0); }
  50% { transform: translateY(5px); }
}

02枠を一周する読書の進み具合

本文のスクロールポートにscroll-timeline-name: --pv-scrollで名前を付けると、四辺の細い棒がanimation-rangeで四分の一ずつを受け持ち、上辺から時計回りに枠線が引かれます。読んだ割合の数字も、同じタイムラインで@propertyに登録した--pv-pctcounter()に流して出すので、枠と数字が食い違う瞬間が生まれません。上部バーを置く余地がない全画面の読み物や写真の上に文章を載せる記事に向いていて、動いたのは24コマ中6コマと静かですが、変化の平均強度は200.3と九つで最も高いです。

scroll-timeline-nameanimation-rangearia-valuenow
.pv__scroller {
  height: var(--h);
  overflow-y: auto;
  scroll-timeline-name: --pv-scroll;
  scroll-timeline-axis: block;
}
.pv__bar {
  position: absolute;
  background: $color;
  animation-duration: 1s;
  animation-timing-function: $easing;
  animation-fill-mode: both;
  animation-timeline: --pv-scroll;
}
.pv__bar--top {
  top: 0; left: 0;
  width: 100%; height: 4px;
  transform-origin: left center;
  transform: scaleX(0);
  animation-name: pv-draw-x;
  animation-range: 0% 25%;
}

数字の側は、登録した整数をカウンターで刻むこの二つだけです。

@property --pv-pct {
  syntax: "<integer>";
  inherits: false;
  initial-value: 0;
}
@keyframes pv-count { from { --pv-pct: 0; } to { --pv-pct: 100; } }
.pv__pct::after {
  counter-reset: pv-n var(--pv-pct);
  content: counter(pv-n) "%";
}

034分の1ごとに灯る読了バッジ

25・50・75・100の四つの丸は、その深さに届いた瞬間steps(1, end)の一コマで色を替え、一度だけ1.22倍に跳ねます。下の棒は読んだ割合と同じだけscaleXで満ち、新しく灯った深さはaria-liveで読み上げられます。最後まで読むと特典を渡す案内や読了の深さを測る社内教育の文書に向いていて、24コマのうち22コマが動き、変化が及んだ面積は4.189%です。

aria-livesteps(1, end)scaleX
.db__chip {
  transition: background $dur-instant steps(1, end), color $dur-instant steps(1, end);
}
.db__chip.is-on {
  background: $color;
  color: #fff;
  animation-name: db-pop;
  animation-duration: $dur-base;
  animation-timing-function: $easing;
  animation-iteration-count: 1;
}
@keyframes db-pop {
  0% { transform: scale(1); }
  45% { transform: scale(1.22); }
  100% { transform: scale(1); }
}

04今読んでいる段落だけがはっきりする焦点

IntersectionObserverrootMargin: '-45% 0px -45% 0px'の中央の帯を通る段落を一つだけ選び、aria-currentをそこへ移して、残りの段落はopacity .35に退きます。左のレールの点は段落の番号×20pxずつ下りて今いる段を指し、プレビューではanimation-delayを0.4秒間隔でずらして焦点が下へ流れます。行を見失うと困る長い契約書や規約、声に出して読む台本に向いていて、24コマのうち23コマまでが動く、九つで一番長い動きです。

IntersectionObserveraria-currentanimation-delay
var io = new IntersectionObserver(function (entries) {
  entries.forEach(function (e) {
    if (!e.isIntersecting) return;
    var i = ps.indexOf(e.target);
    ps.forEach(function (p, j) {
      p.classList.toggle('is-cur', i === j);
      if (i === j) p.setAttribute('aria-current', 'true');
      else p.removeAttribute('aria-current');
    });
    dot.style.transform = 'translateY(' + (i * 20) + 'px)';
  });
}, { root: view, rootMargin: '-45% 0px -45% 0px', threshold: 0 });
ps.forEach(function (p) { io.observe(p); });

05何番目の区間かを数えるカウンター

場面を越えるたびに「3 / 9」の数字が替わり、今いる場面へaria-currentが移ります。上下の矢印キーを受け取ったkeydownscrollToで前後の場面の先頭へ正確に移すので、指を使わずに位置を動かせます。1画面に1場面ずつ送る発表資料や段階の決まった説明文書に向いていて、変化が及んだ面積は25.274%と九つで最大です。

scrollTokeydownaria-current
function goTo(i) {
  sc.classList.remove('is-demo');
  i = Math.max(0, Math.min(secs.length - 1, i));
  view.scrollTo({ top: secs[i].offsetTop, behavior: 'smooth' });
}
view.addEventListener('keydown', function (e) {
  if (e.key === 'ArrowDown') { e.preventDefault(); goTo(currentIndex() + 1); }
  if (e.key === 'ArrowUp') { e.preventDefault(); goTo(currentIndex() - 1); }
});
document.getElementById('sc-up').addEventListener('click', function () { goTo(currentIndex() - 1); });
document.getElementById('sc-down').addEventListener('click', function () { goTo(currentIndex() + 1); });

06横にまだ続くと知らせる端の影

scrollWidthが見える幅より広いあいだだけ、両端にlinear-gradientの影が灯ります。scrollLeftが4px以内で端に届くとその側の影だけがis-offで消え、右の影に乗った小さな矢印が一度4px押し出されて戻って、まだ続いていることを伝えます。スマートフォンで横に送る料金比較表や列の多い統計表に向いていて、24コマのうち15コマが動き、変化が及んだ面積は11.218%です。

scrollLeftscrollWidthlinear-gradient
.ot__shade {
  position: absolute;
  top: 32px;
  bottom: 30px;
  width: 32px;
  pointer-events: none;
  opacity: 1;
  transition: opacity $dur-quick $easing;
}
.ot__shade--l {
  left: $sp-2;
  background: linear-gradient(to right, $subject-cream, rgba(255, 247, 230, 0));
}
.ot__shade--r {
  right: $sp-2;
  background: linear-gradient(to left, $subject-cream, rgba(255, 247, 230, 0));
}
.ot__shade.is-off { opacity: 0; }

07ブラウザーのタブ名に書かれる進み具合

読んだ割合がdocument.titleの先頭に「34% · 」の形で付き、タブをいくつも開いている人も題名だけでどこまで読んだかが分かります。タブのアイコン位置の輪は、@propertyに登録した--pconic-gradientの停止点に直結させて満ちていきます。タブを行き来しながら読む長い文書や社内のウィキに向いていて、変化が及んだ面積は0.849%と九つで最も小さい代わり、24コマのうち22コマが動いています。

@propertyconic-gradientdocument.title
@property --p {
  syntax: "<number>";
  inherits: false;
  initial-value: 0;
}
.tt__ring {
  --p: 0;
  width: 20px;
  height: 20px;
  border-radius: 50%;
  background: conic-gradient(from -90deg, $subject-blue calc(var(--p) * 360deg), rgba(23, 20, 26, .18) 0);
}

08読んでいた場所を残す続きから表示

いちばん深く下りたscrollTopの位置に点線が一本残り、浅い位置へ戻っても上書きはされません。その線が窓の外へ押し出されると丸いボタンがtranslateYで上がってその場所へ戻ろうと誘い、文言が変わるたびaria-liveが読み上げます。途中で離れて戻ってくるヘルプ文書や回数の多い連載に向いていて、24コマのうち13コマが動き、変化が及んだ面積は24.428%です。

scrollToparia-livetranslateY
.rs__pad {
  position: relative;
  padding: $sp-1 $sp-1 160px;
}
.rs__mark {
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
  border-top: 2px dashed $color;
  pointer-events: none;
}
.rs__pill {
  transform: translateY(150%);
  opacity: 0;
  transition: transform $dur-base $easing, opacity $dur-quick $ease-out;
}
.rs__pill.is-up {
  transform: translateY(0);
  opacity: 1;
}

09末尾で次の記事を引き寄せる進行リング

記事が終わった後もさらに引き下ろすと、輪のstroke-dashoffsetが263.9から0へ減って一周を満たし、満ちた割合はaria-valuenowに反映されます。充填は本文を抜けてからの45%区間(r - 0.55) / 0.45で計算され、95%に達すると内側の文字が一コマで「次の記事へ」に切り替わります。次の記事へ読み継がせる媒体記事や回が続く講座の一覧に向いていて、24コマのうち15コマが動き、変化が及んだ面積は2.317%です。

stroke-dashoffsetaria-valuenowsteps(1, end)
.nx__fill {
  stroke: $subject-blue;
  stroke-linecap: round;
  stroke-dasharray: 263.9;
  stroke-dashoffset: 263.9;
}
@keyframes nx-draw {
  0%, 30% { stroke-dashoffset: 263.9; }
  78%, 88% { stroke-dashoffset: 0; }
  90%, 100% { stroke-dashoffset: 263.9; }
}
.nx--next .nx__l--0 { opacity: 0; }
.nx--next .nx__l--1 { opacity: 1; color: $subject-blue; }

どこで壊れるか — 落とし穴

九つを作る間に実際にぶつかった壁を、数値ごとに残しておきます。まず狭い画面です。見本の舞台は480×300ですが、site/static/demo-tokens.scss@mixin stageが定める携帯電話の舞台は320×200で、余白を引くと主体が使える高さは174pxまでです。03と05は読み窓を84pxまで、09は128pxまで下げ、ほかの六つも104〜150pxの間に下げて収めました。@media (max-width: 340px)の中で値を上げることは一度もしていません。174pxの天井を突き破ると、狭い画面ではみ出して表示が切れます。

次に07の静的な経路です。見本のループでは四枚のタブ名をdisplayの切り替えで差し替えていたため、display: noneに隠れた三枚のせいで、実際に人が操作するとタブ名の欄が空欄のままでした。そこで一枚だけ静的な経路に残し(07/index.htmltabLabel)、JSがその文字を進捗率つきの題名に書き換える形に直しました。

08の丸いボタンは、点線が窓の外へ出たときにだけ現れます。この条件は.rs__viewの高さ148pxに対して.rs__padの下余白が160pxあることで初めて成り立ちます。下余白が窓の高さを下回ると最深点は決して窓の外へ出ないので、ボタンは何回読み直しても現れません。

見本の動画は2秒を24枚で抜くので、標本は4.1667%間隔の目盛りに並びます。07と09では、巻き戻す区間を目盛りと目盛りの間(88〜90%)に置きました。目盛りの上で戻すと代表画像が戻りの途中を捉えて、「輪は空なのに文字は次の記事へ」のような食い違った一枚になります。

02は九つの中で唯一、見本用の時間ループを持ちません。四辺も数字もすべてscroll-timeline-name: --pv-scrollから出しています。名前なしのanimation-timeline: scroll()を使うと、overflow: hiddenの箱が別のスクロールポートとして選ばれてしまうことがあるため、接続を名前で固定しました。

対応するブラウザーも条件です。animation-timelineは2026年9月時点でChrome・Edge 115以降、Safari 26以降、Firefox 158以降で動き、caniuseの集計では全世界の87.22%に届きます。この機能に依存しているのは02と07の二つで、数字の表示に使う@propertyはChrome・Edge 85以降・Safari 16.4以降・Firefox 128以降の対応です(MDNの@propertyMDNのanimation-timeline)。残る七つは普通のJavaScriptとCSSだけで組んであるので、どこでも動きます。ここまで直した九つの入った圧縮ファイルを開く合言葉は exsgeatr です。

アクセシビリティ — reduced-motion と読み上げ

九つともprefers-reduced-motion: reduceでは見本の動きを止め、伝えるべき状態を静止画として残します。項目ごとの扱いは次のとおりです。

項目 止めるもの 残すもの
01下向きヒント 矢印の揺れと本文の流れ ヒントは不透明のまま立ち、見出しは最初の一文のみ
02枠の進行線 四辺の描画アニメーション 上辺だけ点灯し、百分率は操作に応じて変わる
03読了バッジ 跳ねと棒の充填 25と50の二つが点灯し、棒は半分
04段落の焦点 点の移動と濃淡の遷移 三番目の段落だけが太字
05区間カウンター 場面の流し 最初の場面と「1 / 9」の表示
06端の影 カードの横流しと矢印の押し 左の影は消え、右の影だけ点灯
07タブの進行 輪の充填と文言の差し替え 輪は半分、文言は最初の題名
08続きから表示 本文の流れと浮上の遷移 点線は途中、ボタンは上がった姿
09進行リング 輪の描画と文言の切り替え 輪は半分(dashoffset 132)、「下に引いてみて」の文言

reduceブロックの宣言に!importantを付けているのは、見本用の.is-demo側の規則が詳細度で勝ってしまうためです。操作の面では、九つのスクロール窓すべてがtabindex="0"role="region"aria-labelを持つフォーカス可能な領域で、:focus-visibleで輪郭が示されます。02・03・07・09はrole="progressbar"aria-valuenowで進みを数値でも伝え、03と08はaria-liveで節目と文言を読み上げ、04の段落と05の場面はaria-currentで今いる位置を示します。この設定の意味はMDNのprefers-reduced-motionにまとまっています。スクロールをきっかけにした動きをさらに見るなら進行バーに絞った隣の記事スクロール効果の分類へ進み、この配布の仕組みはサイト紹介に書いてあります。

FAQ — よくある質問

スクロール インジケーターは読了率や検索順位に効きますか

画面上の表示が直接ランキングに働くことはありません。ただし03の節目や09の引き込みは最後まで読む導線として機能しやすく、結果として滞在時間が伸びる余地はあります。どこで読まれているかを細かく知りたいなら、計測側でイベントを取る方が確実です。

WordPressやReactのページにも貼れますか

貼れます。九つとも標準のDOMとCSSだけで動くので、WordPressなら固定ページのカスタムHTMLブロックに展開したCSSとJavaScriptを置くだけで動作します。Reactに貼り替える手間も省けるよう、zipには貼り替え済みの九つのReact版を同梱してあります。

既存のページに一つだけ足すならどれですか

迷ったら03か08です。この二つは新しいCSS機能に依存しないので古めのブラウザーでも同じ挙動になり、貼り足す範囲も小さいです。Chrome系が確実に使える社内ツールなら、見た目の進行がCSSだけで済む02の枠がいちばん軽く済みます。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。