Scroll State Machine UIの作り方|スクロール演出を状態管理で整理する方法

Scroll State Machine UIのアイキャッチ画像

Scroll Story UIやStickyセクションを作っていると、最初はシンプルでも、あとから「文章を増やしたい」「表示パターンを追加したい」「切り替え条件を調整したい」となって、コードが一気に複雑になることがあります。

そんなときに役立つのが、スクロール位置をそのまま見て処理するのではなく、いったん state に変換して管理する考え方です。

この記事では、Scroll State Machine UI という形で、スクロール演出を状態管理ベースで整理する方法を解説します。
Scroll Story UI をもう少し設計寄りに理解したい人にもおすすめです。

Scroll Story UI をベースにした演出を作りたい場合は、先に #30 Scroll Story UI(実践編) も見ると流れがつかみやすいです。

1.Scroll State Machine UIとは

Scroll State Machine UI とは、スクロール量に応じてUIの状態を切り替える設計です。

たとえば、1つのStickyセクションの中で次のような状態があるとします。

  • STEP 1: 導入を表示
  • STEP 2: 中央の説明を表示
  • STEP 3: 最後のまとめを表示

このとき、scrollY を直接使って細かく表示を切り替えるのではなく、まず「今どの state なのか」を決めて、その state に応じてUIを更新します。

つまり考え方としては、こうなります。

  1. スクロール量を取得する
  2. スクロール量から state を決める
  3. state に応じて表示を切り替える

このように分けると、表示ロジックと判定ロジックが整理されます。

2.なぜそのまま scrollY を使うと壊れやすいのか

スクロール演出を作り始めたばかりのときは、次のようなコードになりがちです。

if (scrollY > 200) {
  title.textContent = "STEP 2";
}
if (scrollY > 500) {
  title.textContent = "STEP 3";
}
if (scrollY > 800) {
  card.classList.add("active");
}

この書き方でも簡単なデモなら動きます。
ただし、条件が増えるほど次の問題が出やすくなります。

  • どの条件が今の表示を決めているのかわかりにくい
  • 表示の切り替えが重複する
  • テキスト、画像、背景など複数要素の整合性が崩れやすい
  • あとから STEP を増やすと調整が大変

つまり、スクロール値を直接UIに結びつけすぎると、構造が見えにくくなるわけです。

3.先に state を決めると何が良いのか

先に state を決める形にすると、コードの責務を分けられます。

  • 判定: 今どの状態か
  • 描画: その状態なら何を表示するか

この分離ができると、次のメリットがあります。

①UIの切り替えが読みやすい

currentState === 2 のように状態が明確になるので、今どの段階かが把握しやすくなります。

②デザイン変更に強い

STEP 2 の表示内容だけ差し替えたい場合でも、判定部分を大きく触らずに済みます。

③演出の追加がしやすい

テキストだけでなく、背景色、画像、インジケーター、進行バーなどを state 単位でまとめて変更できます。

④Scroll Story UIを構造化できる

前回までのスクロール系UIを、「なんとなく切り替える実装」から「設計された切り替え」へ進化させやすくなります。

状態を整理する考え方そのものは、#10 状態管理統合 でも土台を扱っています。

4.実装の考え方

今回は、Stickyエリアの中に3つの状態を用意します。

  • state 0: Intro
  • state 1: Focus
  • state 2: Finish

そしてスクロール進行率に応じて state を切り替えます。

考え方は次の通りです。

const progress = 0.0〜1.0 の値に正規化する
const state = progress に応じて 0, 1, 2 を返す
render(state)

この「正規化してから state に変換する」流れにすると、セクションの高さが多少変わっても調整しやすくなります。

5.今回の完成イメージ

このデモでは、左側に固定表示のパネル、右側にスクロール用の説明ブロックを置きます。
スクロールが進むと、固定パネルの表示内容が Intro → Focus → Finish と切り替わります。

ポイントは、固定パネルの見た目を直接スクロールでいじるのではなく、state を経由して更新していることです。

HTML

<section class="scroll-state-demo" id="demo">
  <div class="demo-sticky">
    <div class="panel">
      <p class="eyebrow">Scroll State Machine</p>
      <h2 id="stateTitle">Intro</h2>
      <p id="stateText">
        スクロール位置を直接使うのではなく、まず state に変換してから UI を更新します。
      </p>

      <div class="state-dots" aria-label="State indicator">
        <span class="dot active"></span>
        <span class="dot"></span>
        <span class="dot"></span>
      </div>
    </div>
  </div>

  <div class="demo-content">
    <div class="step-block">
      <span class="step-label">STEP 1</span>
      <h3>Intro</h3>
      <p>まずは導入。スクロール演出全体の入り口です。</p>
    </div>

    <div class="step-block">
      <span class="step-label">STEP 2</span>
      <h3>Focus</h3>
      <p>次に注目ポイントへ。主役のメッセージや要素を見せます。</p>
    </div>

    <div class="step-block">
      <span class="step-label">STEP 3</span>
      <h3>Finish</h3>
      <p>最後はまとめ。流れの着地地点をはっきり見せます。</p>
    </div>
  </div>
</section>

CSS

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  font-family: sans-serif;
  background: #0f1115;
  color: #f5f7fb;
}

.scroll-state-demo {
  display: grid;
  grid-template-columns: minmax(260px, 420px) 1fr;
  gap: 40px;
  max-width: 1100px;
  margin: 0 auto;
  padding: 80px 24px;
}

.demo-sticky {
  position: relative;
}

.panel {
  position: sticky;
  top: 80px;
  padding: 28px;
  border-radius: 20px;
  background: linear-gradient(180deg, #171a21 0%, #101319 100%);
  border: 1px solid rgba(255,255,255,0.08);
}

.eyebrow {
  margin: 0 0 12px;
  font-size: 12px;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: #9aa4b2;
}

.panel h2 {
  margin: 0 0 16px;
  font-size: clamp(28px, 4vw, 44px);
}

.panel p {
  margin: 0;
  line-height: 1.8;
  color: #d6dbe4;
}

.state-dots {
  display: flex;
  gap: 10px;
  margin-top: 24px;
}

.dot {
  width: 10px;
  height: 10px;
  border-radius: 999px;
  background: rgba(255,255,255,0.18);
  transition: transform 0.25s ease, background 0.25s ease;
}

.dot.active {
  background: #ffffff;
  transform: scale(1.4);
}

.demo-content {
  display: grid;
  gap: 28px;
}

.step-block {
  min-height: 72vh;
  padding: 32px;
  border-radius: 20px;
  background: rgba(255,255,255,0.04);
  border: 1px solid rgba(255,255,255,0.06);
  display: flex;
  flex-direction: column;
  justify-content: center;
}

.step-label {
  display: inline-block;
  margin-bottom: 14px;
  font-size: 12px;
  letter-spacing: 0.12em;
  color: #9aa4b2;
}

.step-block h3 {
  margin: 0 0 12px;
  font-size: clamp(24px, 3vw, 36px);
}

.step-block p {
  margin: 0;
  line-height: 1.8;
  color: #d6dbe4;
}

@media (max-width: 820px) {
  .scroll-state-demo {
    grid-template-columns: 1fr;
    gap: 24px;
  }

  .panel {
    top: 16px;
  }

  .step-block {
    min-height: 52vh;
  }
}

JavaScript

const section = document.querySelector(".scroll-state-demo");
const title = document.getElementById("stateTitle");
const text = document.getElementById("stateText");
const dots = document.querySelectorAll(".dot");

const stateContent = [
  {
    title: "Intro",
    text: "スクロール位置を直接使うのではなく、まず state に変換してから UI を更新します。"
  },
  {
    title: "Focus",
    text: "state を1つ決めておけば、テキスト・背景・インジケーターなどをまとめて切り替えられます。"
  },
  {
    title: "Finish",
    text: "Scroll Story UI を場当たり的な実装から、整理された設計へ変えやすくなります。"
  }
];

let currentState = -1;

function clamp(value, min, max) {
  return Math.min(Math.max(value, min), max);
}

function getProgress() {
  const rect = section.getBoundingClientRect();
  const windowHeight = window.innerHeight;

  const total = rect.height - windowHeight;
  const current = -rect.top;

  if (total <= 0) return 0;

  return clamp(current / total, 0, 1);
}

function getStateFromProgress(progress) {
  if (progress < 0.33) return 0;
  if (progress < 0.66) return 1;
  return 2;
}

function renderState(state) {
  if (state === currentState) return;
  currentState = state;

  title.textContent = stateContent[state].title;
  text.textContent = stateContent[state].text;

  dots.forEach((dot, index) => {
    dot.classList.toggle("active", index === state);
  });
}

function update() {
  const progress = getProgress();
  const state = getStateFromProgress(progress);
  renderState(state);
}

window.addEventListener("scroll", update, { passive: true });
window.addEventListener("resize", update);
window.addEventListener("load", update);

6.解説

①progress を先に作る

まずはセクション全体の進行率を 0〜1 に正規化しています。

const progress = clamp(current / total, 0, 1);

この形にしておくと、画面サイズやセクションの長さが変わっても考え方が崩れにくくなります。

②progress から state を決める

次に、進行率を見て state を返します。

function getStateFromProgress(progress) {
  if (progress < 0.33) return 0;
  if (progress < 0.66) return 1;
  return 2;
}

ここでは3段階にしていますが、4段階や5段階にもそのまま拡張できます。

③描画は renderState にまとめる

state が決まったら、UIの更新は renderState() にまとめます。

title.textContent = stateContent[state].title;
text.textContent = stateContent[state].text;

このように「今の state に対して何を表示するか」が1か所に集まるので、修正しやすくなります。

④state が変わったときだけ更新する

毎回DOMを書き換えるのではなく、前回と state が同じなら何もしないようにしています。

if (state === currentState) return;

この一行だけでも、処理の見通しがかなり良くなります。

7.この設計が向いている場面

Scroll State Machine UI は、次のような場面と相性が良いです。

ステップ型の説明UI

サービス紹介、特徴説明、チュートリアルなど。

Stickyセクションを使うUI

固定表示のカードや画像を、右側のスクロールに合わせて切り替える構成。

演出が増えやすいページ

テキスト、背景、画像、メーター、番号表示など、複数の要素を同期したいとき。

8.逆に向いていない場面

細かい連続アニメーションをずっと滑らかに動かしたい場合は、state だけでは足りないことがあります。

たとえば次のような表現です。

  • 少しずつ拡大する
  • 透明度が連続的に変わる
  • スクロール量に応じて座標がなめらかに動く

こうした場合は、state による段階切り替えと、progress による連続変化を組み合わせるのが実用的です。

連続的な動きまで含めてなめらかさを作りたい場合は、#11 Interpolation#18 Progress Bar Animation と組み合わせる考え方も有効です。

9.Scroll Story UIとの違い

前回までの Scroll Story UI は、「スクロールに応じて表示が切り替わる体験」を作ることが中心でした。
一方、今回の Scroll State Machine UI は、その体験をどう整理して実装するかに寄っています。

つまり、

  • Scroll Story UI = 見せ方
  • Scroll State Machine UI = 設計の整理方法

という関係で考えるとわかりやすいです。

10.実験:スクロール進行を state に変換してみる

この実験では、スクロール量そのものではなく、いったん state に変換してから表示を更新しています。
どのタイミングで Intro / Focus / Finish が切り替わるかを観察してみてください。

※この実験は記事本文の幅に合わせたコンパクト版です。スクロール進行に応じて、上部パネルの状態が Intro → Focus → Finish と切り替わります。

観察ポイント

①切り替えの責務が分かれている

scroll → progress → state → render という流れに分けることで、処理の役割が見やすくなります。

②UI更新がまとまっている

テキストやインジケーターの更新が renderState() に集まっているので、あとから表示内容を増やしやすいです。

③Scroll Storyを設計として整理できる

見た目はシンプルでも、実装の考え方を変えるだけで壊れにくさが上がります。

Sticky表現そのものを先に確認したい場合は、#29 Sticky Scroll Animation#30 Scroll Story UI もあわせてどうぞ。

11.まとめ

スクロール演出は、最初は感覚的に作れても、要素が増えるほど管理が難しくなります。
そんなときは、スクロール値を直接使い続けるのではなく、state に変換してからUIを更新すると整理しやすくなります。

今回のポイントは次の3つです。

  • スクロール量を progress に正規化する
  • progress から state を決める
  • state に応じて UI を描画する

この形にしておくと、Scroll Story UI や Sticky UI をあとから拡張しやすくなります。
スクロール演出を「なんとなく作る」から一歩進めて、「壊れにくく設計する」ための考え方として使ってみてください。


Scroll Animationシリーズ

コメント

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

CAPTCHA