デモ
デモを読み込めませんでした。
仕様
- 用途
- ローディング
- 動作
- DOM
- 種類
- レシピ
- 使うもの
- ScrollTrigger
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
画面の上に止めたヘッダーの下端に、細いバーを置きます。バーを左端から横に伸ばす変形(scaleX)を 0 から 1 へ、イーズ無しで動かし、ScrollTrigger でページのスクロール量にそのまま結びます。範囲をページの先頭から最後までにすると、ページ全体を読んだ割合になります。
幅(width)ではなく変形で伸ばすのは、スクロールのたびにレイアウトの計算を起こさないためです。変形の原点は左端に置きます。右端の % の数字は、ScrollTrigger が知らせる進み具合を 100 倍して丸めたもので、数字の幅を tabular-nums でそろえて、桁が変わっても横に揺れないようにしています。
scrub には数値ではなく true を渡します。数値にすると、バーが実時間で遅れて追いかけ、スクロールを止めたあとも少し動き続けます。読了バーは今の位置を示す道具なので、遅れずに付いてくるほうが読みやすくなります。ページの末尾にコメント欄や長いフッターがあるときは、記事の要素を基準にして、記事の上端が画面の上端に来たところから、下端が画面の下端に来たところまでを測ります。
バーには進捗バーの役割(progressbar)と今の値を付けて読み上げに伝え、見た目の % の数字は読み上げから外しています。動きを減らす設定でも、バーはスクロールした量を写すだけで自分からは動かないので、そのままにしています。
太さ・色・溝の色は CSS の変数で変えられます。長い記事・ブログ・ドキュメント・レシピのように、あとどれくらいで読み終わるかを知りたいページに向いています。狭い画面では記事の題を外し、ロゴと割合だけを並べています。
コード
<header class="read-header">
<a class="read-header__logo" href="/">Hi! I'm HIGASHIYUKI®</a>
<p class="read-header__title">動きで視線を導く</p>
<!-- 読んだ割合は下のバーが読み上げに伝えるので、見た目の数字は読ませない -->
<span class="read-header__percent" aria-hidden="true">0%</span>
<div class="read-progress" role="progressbar" aria-label="記事を読んだ割合"
aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<span class="read-progress__fill"></span>
</div>
</header>
<article class="read-article">
<p class="read-article__kicker">Essay</p>
<h1>動きで視線を導く</h1>
<p class="read-article__dek">ここに説明文が入ります。</p>
<p class="read-article__meta"><span>編集部</span><span>2026年9月</span><span>6分で読めます</span></p>
<img class="read-article__figure" src="/img/motion-essay.jpg" alt="" width="1280" height="640">
<p>ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。</p>
<p>ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。</p>
<h2>目的から動かし方を考える</h2>
<p>ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。</p>
<p>ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。</p>
<blockquote>動きは、読む人の理解を助けるためにある。</blockquote>
<h2>動きに意味を持たせる</h2>
<p>ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。</p>
<p>ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。</p>
<h2>動きの止め方にこだわる</h2>
<p>ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。</p>
<p>ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。</p>
</article>
:root {
--ground: #f2f1ed;
--ink: #262626;
--mute: #6b6b6b;
--track: rgba(38, 38, 38, 0.12); /* バーの溝 */
--bar: 6px; /* バーの太さ */
--header: 112px; /* ナビ(高さ 48px)の上下に 32px */
}
/* 画面の上に止めたヘッダー。ロゴ・記事の題・読んだ割合を 3 列に並べ、題を中央にそろえる */
.read-header {
position: fixed;
inset: 0 0 auto;
z-index: 10;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 32px;
box-sizing: border-box;
height: var(--header);
padding: 0 clamp(24px, 7.5vw, 96px);
background-color: var(--ground);
color: var(--ink);
font-size: 14px;
font-weight: 600;
line-height: 1;
letter-spacing: 0.04em;
white-space: nowrap;
}
.read-header__logo {
justify-self: start;
color: inherit;
font-size: 18px;
font-weight: 700;
letter-spacing: 0.12em;
text-decoration: none;
}
.read-header__logo:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
.read-header__title { margin: 0; color: var(--mute); }
/* 桁が変わっても幅が揺れないよう、数字の幅をそろえる */
.read-header__percent {
justify-self: end;
min-width: 4ch;
text-align: right;
font-variant-numeric: tabular-nums;
}
.read-progress {
position: absolute;
inset: auto 0 0;
height: var(--bar);
background-color: var(--track);
}
/* width ではなく scaleX で伸ばす(レイアウトを起こさない)。JS が動く前は 0 */
.read-progress__fill {
position: absolute;
inset: 0;
background-color: var(--ink);
transform: scaleX(0);
transform-origin: 0 50%;
}
/* 記事の先頭は、止めたヘッダーの分だけ空ける。本文の列は 640px */
.read-article {
padding: calc(var(--header) + 64px) max(24px, calc(50% - 320px)) 96px;
background-color: var(--ground);
color: var(--ink);
font-size: 18px;
line-height: 1.8;
letter-spacing: 0.04em;
}
.read-article p { margin: 0 0 24px; }
.read-article .read-article__dek { word-break: auto-phrase; } /* リードは文節で折り返す */
.read-article h1 { margin: 0 0 24px; font-size: clamp(32px, 5vw, 44px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; }
.read-article h2 { margin: 48px 0 16px; font-size: 24px; line-height: 1.3; letter-spacing: -0.01em; }
.read-article blockquote { margin: 48px 0; padding-left: 24px; border-left: 4px solid var(--ink); font-size: 20px; font-weight: 700; line-height: 1.6; }
.read-article .read-article__kicker { margin-bottom: 16px; color: var(--mute); font-size: 13px; font-weight: 700; line-height: 1; letter-spacing: 0.14em; text-transform: uppercase; }
.read-article .read-article__meta { display: flex; flex-wrap: wrap; gap: 16px; margin-bottom: 48px; color: var(--mute); font-size: 14px; line-height: 1; }
.read-article__figure { display: block; width: 100%; height: auto; aspect-ratio: 2 / 1; margin-bottom: 48px; border-radius: 4px; background-color: #e6ded2; object-fit: cover; }
/* 狭い画面では、記事の題を外してロゴと割合だけにする */
@media (max-width: 720px) {
:root { --header: 72px; }
.read-header { grid-template-columns: 1fr auto; }
.read-header__title { display: none; }
}
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
// 動きを減らす設定(prefers-reduced-motion)でも、そのままにする。
// バーはスクロールした量をそのまま写すだけで、自分からは動かない(スクロールバーと同じ)
document.querySelectorAll(".read-progress").forEach((bar) => {
const fill = bar.querySelector(".read-progress__fill");
const percent = bar.closest(".read-header")?.querySelector(".read-header__percent");
gsap.fromTo(
fill,
{ scaleX: 0 },
{
scaleX: 1,
ease: "none", // 読んだ量にそのまま比例させる
scrollTrigger: {
// ページ全体の読了率。記事の範囲だけなら trigger: "article", start: "top top", end: "bottom bottom"
start: 0,
end: "max",
scrub: true, // スクロール量にそのまま連動させる(数値の scrub は実時間で追うので遅れる)
onUpdate: (self) => {
const value = Math.round(self.progress * 100);
bar.setAttribute("aria-valuenow", value);
if (percent) percent.textContent = `${value}%`;
},
},
}
);
});