スクロール連動UIを作るとき、scrollイベントのたびに「今どこまで来たか」を判定して、要素を動かしたくなることがあります。
たとえば、プログレスバーを伸ばしたり、見出しの色を変えたり、カードを少しずつ移動させたりする実装です。
ただ、このときに毎回 if を増やしていく設計にすると、実装はすぐに複雑になります。
少し表現を足しただけなのに、条件分岐が増えて、あとから調整しにくくなることも珍しくありません。
そこで便利なのが、スクロール量を「状態(state)」として扱う考え方です。
この記事では、スクロール位置を 0〜1 の値に正規化し、その値をもとに複数のUIを同時に制御する方法を、初心者向けにわかりやすく整理します。
「Scroll → State → UI」 という流れで考えると、スクロール演出はかなり見通しよく作れるようになります。
スクロールをきっかけに動く表現そのものを先に整理したい場合は、以前書いた Scroll Trigger Animation の記事 もあわせて読むと流れがつかみやすいです。
1.スクロールはイベントではなく「連続した状態」として見る
スクロール連動UIを作り始めたばかりのときは、次のように考えがちです。
- ある位置を超えたらクラスを付ける
- ここまで来たら表示を切り替える
- ここでアニメーションを始める
もちろん、こうした実装が悪いわけではありません。
ただ、変化がなめらかにつながるUIを作りたいときは、「発火」だけで考えると少し窮屈です。
たとえば、スクロール量に応じて以下のような変化を同時に起こしたい場面があります。
- プログレスバーが伸びる
- 見出しが少しずつ移動する
- カードの透明度が変わる
- 数値表示が進行率に合わせて変わる
このとき、全部を個別の条件分岐で書くよりも、まずスクロール位置そのものを1つの状態値に変換する方が整理しやすくなります。
つまり、考え方はこうです。
- Scroll = 入力
- State = 正規化された進行度
- UI = Stateを見て描画
この形にすると、スクロールは「ただのイベント」ではなく、連続的に変化する入力値として扱えるようになります。
「ある位置で切り替える」発想が中心の実装は、Scroll Spy UI や Scroll Trigger Animation のような構成と相性がよく、今回のような state ベース設計とは役割が少し違います。
2.Scroll → State に変換する
最初にやることはシンプルです。
現在のスクロール位置を、0〜1の範囲に収まる値へ変換します。
const scrollTop = window.scrollY;
const maxScroll = document.documentElement.scrollHeight - window.innerHeight;
const progress = maxScroll > 0 ? scrollTop / maxScroll : 0;この progress が、今回の中心になる state です。
- ページの一番上なら
0 - 一番下まで行けば
1 - 真ん中あたりなら
0.5前後
こうしておくと、スクロール量そのものを直接扱わずに済みます。
たとえば scrollY をそのまま使うと、ページの高さによって数値の意味が変わってしまいます。
でも 0〜1 に正規化しておけば、ページ全体の長さが変わっても考え方は同じです。
この「意味のそろった値」にしてからUIへ渡すのが大事なポイントです。
3.StateでUIを制御する
progress が作れたら、あとはその値を使ってUIを動かしていきます。
たとえば、横に移動する要素ならこんな形です。
box.style.transform = `translateX(${progress * 120}px)`;透明度ならこうです。
box.style.opacity = 0.3 + progress * 0.7;拡大率も同じ考え方で書けます。
box.style.transform = `scale(${1 + progress * 0.2})`;この方法の良いところは、すべてが同じ state を見て動くことです。
「この要素は別計算、この要素は別条件」と分かれていくより、
「全員が progress を参照して変化する」方が、調整の見通しがかなりよくなります。
4.1つのstateで複数のUIを同期させる
Scroll-Driven State UI の気持ちよさは、ここにあります。
たとえば同じ progress を使えば、次のような複数の変化を一斉に同期できます。
- プログレスバーの幅
- 数値表示
- 見出しの移動
- カードの透明度
- 背景色の変化
コードのイメージはこんな感じです。
bar.style.width = `${progress * 100}%`;
label.textContent = `${Math.round(progress * 100)}%`;
title.style.transform = `translateY(${progress * 40}px)`;
card.style.opacity = 0.4 + progress * 0.6;全部が1つの state によって動くので、
UI全体に「同じテンポで変化している感じ」が出ます。
この一体感は、個別にイベント処理を書き足していくやり方だと作りにくい部分です。
進行度を見せるUIそのものをもっとシンプルに確認したい場合は、Scroll Progress の記事から読むのもおすすめです。
5.clampして範囲を整える
スクロール量を計算していると、環境や計算タイミングによって、ほんの少しだけ 0 未満や 1 超えになることがあります。
そういうブレを防ぐために、clamp を入れておくと安心です。
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
const progress = clamp(scrollTop / maxScroll, 0, 1);これで state の範囲が必ず 0〜1 に収まります。
UI設計では、「想定する値の範囲を固定する」ことがとても大切です。
値の意味がブレないだけで、後から追加する表現もかなり楽になります。
6.セクション単位のstateに分ける考え方
ページ全体の progress だけでも多くの演出は作れますが、
特定のセクションの中だけで変化させたい場合もあります。
そのときは、ページ全体ではなく、その要素が画面内でどれくらい進んだかを state にします。
考え方の例です。
const rect = section.getBoundingClientRect();
const start = window.innerHeight;
const end = -rect.height;
const raw = (start - rect.top) / (start - end);
const localProgress = clamp(raw, 0, 1);こうすると、そのセクション専用の localProgress が作れます。
- セクションが画面に入り始めるころは
0 - 途中で
0.5 - 通過し終わるころに
1
この考え方は、以前の Scroll Trigger や Scroll Story UI を一段設計寄りに整理するのにも役立ちます。
7.よくある失敗
scrollY をそのまま使ってしまう
最初はわかりやすいのですが、ページの高さが変わると数値の意味がズレやすくなります。
まず 0〜1 に変換してから使う方が安全です。
条件分岐を増やしすぎる
if (scrollY > 100) { ... }
if (scrollY > 300) { ... }
if (scrollY > 600) { ... }この形は小さなデモでは動いても、あとから表現を追加すると崩れやすいです。
連続的な変化は、なるべく state ベースで考えた方が整理しやすくなります。
値の範囲を決めない
state の意味が曖昧なままだと、UIの側で調整がしにくくなります。
まずは 0〜1 で揃えるのがおすすめです。
更新処理を重くしすぎる
スクロールに合わせて毎回重い処理をすると、当然カクつきやすくなります。
まずは 「stateを計算して、必要な描画だけ更新する」 というシンプルな流れを意識すると安定しやすいです。
8.Scroll-Driven State UI の基本実装
ここでは、ページ全体のスクロール進行度を state にして、以下を同期させます。
- 上部のプログレスバー
- パーセント表示
- タイトルの移動
- カードの透明度と拡大
コードはシンプルですが、設計の考え方が見えやすい構成です。
HTML
<div class="progress-bar">
<div class="progress-fill" id="progressFill"></div>
</div>
<section class="hero">
<p class="eyebrow">Scroll-Driven State UI</p>
<h1 id="heroTitle">Scroll → State → UI</h1>
<p class="lead">
スクロール量を0〜1の状態として扱うと、複数のUIを同期して制御しやすくなります。
</p>
<div class="status">
<span>Progress</span>
<strong id="progressLabel">0%</strong>
</div>
</section>
<section class="demo-area">
<div class="card" id="syncCard">
<p class="card-label">Synced Card</p>
<p class="card-value" id="cardValue">0.00</p>
</div>
</section>
<section class="spacer"></section>CSS
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: sans-serif;
color: #e8ecf1;
background: #0f1115;
line-height: 1.7;
}
.progress-bar {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 4px;
background: rgba(255,255,255,0.08);
z-index: 1000;
}
.progress-fill {
width: 0%;
height: 100%;
background: linear-gradient(90deg, #7dd3fc, #a78bfa);
}
.hero,
.demo-area,
.spacer {
width: min(900px, calc(100% - 40px));
margin: 0 auto;
}
.hero {
min-height: 100vh;
display: grid;
align-content: center;
gap: 16px;
}
.eyebrow {
margin: 0;
font-size: 0.9rem;
color: #8ea3b8;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.hero h1 {
margin: 0;
font-size: clamp(2rem, 6vw, 4.5rem);
line-height: 1.05;
}
.lead {
margin: 0;
max-width: 700px;
color: #b8c3cf;
}
.status {
display: inline-flex;
align-items: center;
gap: 10px;
width: fit-content;
padding: 10px 14px;
border: 1px solid rgba(255,255,255,0.12);
border-radius: 999px;
background: rgba(255,255,255,0.04);
}
.demo-area {
min-height: 100vh;
display: grid;
place-items: center;
}
.card {
width: min(420px, 100%);
padding: 28px;
border-radius: 24px;
background: linear-gradient(180deg, rgba(255,255,255,0.08), rgba(255,255,255,0.03));
border: 1px solid rgba(255,255,255,0.12);
box-shadow: 0 20px 60px rgba(0,0,0,0.25);
transform-origin: center;
}
.card-label {
margin: 0 0 8px;
color: #9db0c3;
font-size: 0.95rem;
}
.card-value {
margin: 0;
font-size: clamp(2rem, 5vw, 3.5rem);
font-weight: 700;
}
.spacer {
height: 120vh;
}JavaScript
const progressFill = document.getElementById('progressFill');
const progressLabel = document.getElementById('progressLabel');
const heroTitle = document.getElementById('heroTitle');
const syncCard = document.getElementById('syncCard');
const cardValue = document.getElementById('cardValue');
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
function updateScrollState() {
const scrollTop = window.scrollY;
const maxScroll = document.documentElement.scrollHeight - window.innerHeight;
const rawProgress = maxScroll > 0 ? scrollTop / maxScroll : 0;
const progress = clamp(rawProgress, 0, 1);
progressFill.style.width = `${progress * 100}%`;
progressLabel.textContent = `${Math.round(progress * 100)}%`;
const titleY = progress * 40;
heroTitle.style.transform = `translateY(${titleY}px)`;
const scale = 1 + progress * 0.18;
const opacity = 0.35 + progress * 0.65;
syncCard.style.transform = `scale(${scale})`;
syncCard.style.opacity = opacity;
cardValue.textContent = progress.toFixed(2);
}
updateScrollState();
window.addEventListener('scroll', updateScrollState, { passive: true });
window.addEventListener('resize', updateScrollState);9.実装のポイント
この実装で見てほしいのは、「スクロール量を直接UIにぶつけていない」ことです。
やっている流れは次の3段階だけです。
- スクロール位置を取得する
- 0〜1 の state に変換する
- その state をUIに配る
この順番にするだけで、コードの役割がかなり明確になります。
とくに初心者のうちは、
「イベント処理の中にそのまま見た目の制御を書き続ける」
という形になりやすいのですが、state を1つ挟むだけで整理しやすくなります。
10.実験:stateによるUI制御を体験する
今回の実験では、ページをスクロールすると、1つの progress 値に合わせて複数のUIが同時に変化します。
- 上部のバーが伸びる
- パーセント表示が変わる
- 見出しが下へ少し移動する
- カードが少しずつ拡大し、透明度も変わる
つまり、見た目は複数変わっていても、元になっている state は1つだけです。
観察ポイント
- スクロール量がそのままではなく、0〜1 の state になっていること
- UIごとに別イベントを書いていないこと
- 同じ進行度を見ているから、変化に一体感が出ること
この考え方をそのまま発展させると、スクロールに応じて内容の見せ方を切り替えていく Scroll Story UI のような表現にもつながります。
11.まとめ
Scroll-Driven State UI では、スクロールを単なる発火イベントとしてではなく、連続的に変化する状態値として扱います。
今回のポイントは次の3つです。
- スクロール量をそのまま使わず、まず
0〜1に正規化する - UIはその state を見て変化させる
- 1つの state から複数UIを同期させる
この考え方を覚えると、プログレス表示だけでなく、
スクロールに合わせた色変化、位置変化、フェード、ストーリー表現なども整理しやすくなります。
「Scroll Trigger の次に、もう少し設計っぽく考えたい」
そんなタイミングでちょうど使いやすい考え方です。
Scroll Animationシリーズ
- #24 Scroll Reveal Animation
- #25 Scroll Progress
- #26 Scroll Snap Animation
- #27 Parallax Scroll Animation
- #28 Scroll Trigger Animation
- #29 Sticky Scroll Animation
- #30 Scroll Story UI
- #31 Scroll State Machine UI
- #32 Scroll Velocity Animation
- #33 Scroll Mouse Interaction UI
- #34 Scroll Spy UI
- #35 Scroll Sync UI
- #36 Scroll-Driven State UI

コメントを残す