本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
セクション

スクロールでロードマップのセクションに道が描かれ、印が線に沿って進む

デモ

操作:スクロール。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。 触っている間は、ホイールでデモの中がスクロールします。

仕様

動作
DOM
種類
レシピ
使うもの
ScrollTrigger・DrawSVGPlugin・MotionPathPlugin
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

ロードマップの節を画面に固定し、スクロールに合わせて、波打つ道を出発点から描いていきます。道の先では、矢印の付いた丸い印が進む向きを向いて進み、節目の点を通るたびに点が塗られ、その時期の説明のカードが点から離れる向きに現れます。まだ通っていない道は、灰色の点線で先に見せておきます。

線は DrawSVGPlugin で 0% から 100% まで描き、印は MotionPathPlugin で同じ線に沿わせて、進む向きへ回します。どちらも線の長さに対する割合で進むので、同じタイムラインにイーズ無しで並べると、印はいつも描かれた線の先にいます。節を固定する長さは画面の高さの 1.7 倍で、スクロール量にそのまま結びます(scrub)。

印は中心が (0, 0) になる形で描き、alignOrigin で中心を線に乗せます。指定しないと、印を囲む箱の左上が線に乗り、印が線から外れて進みます。説明のカードは、節目までの道の長さを全長で割った割合の位置で出します。節目までの部分の曲線を作って getTotalLength で測り、印が着く少し前から出し始めると、着いたときに出そろって見えます。

道は、出発点と節目の点(地図の幅と高さに対する比)を順に通るなめらかな曲線で作るので、点を足し引きするだけで形を変えられます。地図は 16:9 の箱で、狭い画面では 960px より縮めず、印を追って地図を横へ送ります。カードは opacity だけで隠し、読み上げからは外しません。動きを減らす設定では、固定せずに描き終えた地図を見せます。

つまみは、道の形、固定する長さ、道と印の色です。流れるページの中で直線を伸ばして年表の項目を出す形と違い、節を止めたまま道全体を見せて印を進めるので、先の予定まで見通せます。ロードマップ・導入の手順・配送の流れの説明に向いています。

コード

HTML
<section class="roadmap" aria-labelledby="roadmap-title">
  <div class="roadmap__stage">
    <div class="roadmap__head">
      <p class="roadmap__eyebrow">Roadmap — 2026</p>
      <h2 class="roadmap__title" id="roadmap-title">ヒアリングから全国展開までの流れ</h2>
    </div>

    <!--
      地図は 16:9 の箱。道はスクリプトが、出発点(data-start)と各節目の --x・--y(地図の幅と高さに対する比)を
      順に通る曲線で描く。点を足し引きするだけで、道の形が変わる
    -->
    <div class="roadmap__map" data-start="0.07 0.62">
      <svg class="roadmap__art" viewBox="0 0 1280 720" aria-hidden="true">
        <path class="roadmap__route" />
        <path class="roadmap__line" />
        <!-- 印。中心が (0, 0) の丸と、右を向いた矢印。進む向きへ回る -->
        <g class="roadmap__marker">
          <circle class="roadmap__halo" r="23" />
          <circle class="roadmap__core" r="14" />
          <path class="roadmap__arrow" d="M-3.5 -6.5 L4.5 0 L-3.5 6.5" />
        </g>
      </svg>

      <ol class="roadmap__stops">
        <li class="roadmap__stop" style="--x: 0.24; --y: 0.42">
          <p class="roadmap__when">1〜3月</p>
          <h3 class="roadmap__name">ヒアリング</h3>
          <p class="roadmap__text">ここに説明文が入ります。</p>
        </li>
        <li class="roadmap__stop" style="--x: 0.42; --y: 0.74">
          <p class="roadmap__when">4〜6月</p>
          <h3 class="roadmap__name">試作</h3>
          <p class="roadmap__text">ここに説明文が入ります。</p>
        </li>
        <li class="roadmap__stop" style="--x: 0.6; --y: 0.42">
          <p class="roadmap__when">7〜9月</p>
          <h3 class="roadmap__name">限定公開</h3>
          <p class="roadmap__text">ここに説明文が入ります。</p>
        </li>
        <li class="roadmap__stop" style="--x: 0.78; --y: 0.74">
          <p class="roadmap__when">10〜12月</p>
          <h3 class="roadmap__name">正式公開</h3>
          <p class="roadmap__text">ここに説明文が入ります。</p>
        </li>
        <li class="roadmap__stop" style="--x: 0.93; --y: 0.48">
          <p class="roadmap__when">2027年</p>
          <h3 class="roadmap__name">全国展開</h3>
          <p class="roadmap__text">ここに説明文が入ります。</p>
        </li>
      </ol>
    </div>
  </div>
</section>

CSS
.roadmap {
  --bg: #f2f1ed;
  --ink: #262626;
  --small: #6b6b6b;
  --surface: #ffffff; /* まだ着いていない節目の点の塗り */
  --route: #c8c7c3; /* まだ通っていない道(点線)と、節目の点の縁 */
  --accent: #b27771; /* 描かれる道と印(中身のパレットのくすみローズ) */
  --on-accent: #ffffff;
  --card-bg: #262626; /* 節目の説明は明暗を反転したカード */
  --card-ink: #ffffff;
  --card-small: #a8a6a0;
  --card: 224px; /* カードの幅 */
  background-color: var(--bg);
  color: var(--ink);
}

/* 画面の高さの舞台。スクリプトが無いときや動きを減らす設定では、狭い画面で地図を横にスクロールして見る */
.roadmap__stage {
  position: relative;
  display: flex;
  height: 100vh;
  height: 100svh;
  overflow-x: auto;
}

/* 固定して描くときは、地図をスクリプトが横に送るので、はみ出しは切る */
.roadmap.is-pinned .roadmap__stage { overflow: clip; }

.roadmap__head { position: absolute; z-index: 1; top: 48px; left: clamp(24px, 7vw, 96px); }

.roadmap__eyebrow {
  margin: 0;
  font-size: 13px;
  font-weight: 700;
  line-height: 1;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--small);
}

.roadmap__title { margin: 16px 0 0; font-size: 24px; line-height: 1.3; letter-spacing: -0.01em; word-break: auto-phrase; }

/* 16:9 の地図を舞台の中央に置く。画面に収まる大きさにし、狭い画面でも 960px より小さくしない */
.roadmap__map {
  position: relative;
  flex: none;
  width: max(960px, min(100%, 100vh * 16 / 9));
  width: max(960px, min(100%, 100svh * 16 / 9));
  aspect-ratio: 16 / 9;
  margin: auto;
}

.roadmap__art { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }

.roadmap__route,
.roadmap__line { fill: none; stroke-width: 6; stroke-linecap: round; stroke-linejoin: round; }
/* 長さ 0 の線分と丸い線端で、点線にする */
.roadmap__route { stroke: var(--route); stroke-dasharray: 0 18; }
.roadmap__line { stroke: var(--accent); }

.roadmap__start { fill: var(--accent); }
.roadmap__dot { fill: var(--surface); stroke: var(--route); stroke-width: 3; }

.roadmap__halo { fill: var(--accent); fill-opacity: 0.18; }
.roadmap__core { fill: var(--accent); stroke: var(--on-accent); stroke-width: 3; }
.roadmap__arrow { fill: none; stroke: var(--on-accent); stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }

.roadmap__stops { position: absolute; inset: 0; margin: 0; padding: 0; list-style: none; }

/* 節目の点の 32px 下に置く。山の点(is-above)は上に置く。左右は地図の端からはみ出さないよう寄せる */
.roadmap__stop {
  position: absolute;
  top: calc(var(--y) * 100% + 32px);
  left: clamp(3%, var(--x) * 100% - var(--card) / 2, 97% - var(--card));
  width: var(--card);
  box-sizing: border-box;
  padding: 16px;
  border-radius: 4px;
  background-color: var(--card-bg);
  color: var(--card-ink);
}

.roadmap__stop.is-above { top: auto; bottom: calc((1 - var(--y)) * 100% + 32px); }

.roadmap__when { margin: 0; font-size: 12px; font-weight: 700; line-height: 1; letter-spacing: 0.06em; color: var(--card-small); }
.roadmap__name { margin: 8px 0 0; font-size: 20px; line-height: 1.3; letter-spacing: -0.01em; }
.roadmap__text { margin: 4px 0 0; font-size: 14px; line-height: 1.7; letter-spacing: 0.04em; color: var(--card-small); }

JavaScript
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { DrawSVGPlugin } from "gsap/DrawSVGPlugin";
import { MotionPathPlugin } from "gsap/MotionPathPlugin";

gsap.registerPlugin(ScrollTrigger, DrawSVGPlugin, MotionPathPlugin);

const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

// 点を順に通るなめらかな曲線(Catmull-Rom を 3 次ベジェに直したもの)。upTo 番目の点までの部分だけも作れる
function curve(points, upTo = points.length - 1) {
  let d = `M${points[0]}`;
  for (let i = 0; i < upTo; i++) {
    const [p0, p1, p2, p3] = [points[Math.max(0, i - 1)], points[i], points[i + 1], points[Math.min(points.length - 1, i + 2)]];
    const c1 = [p1[0] + (p2[0] - p0[0]) / 6, p1[1] + (p2[1] - p0[1]) / 6];
    const c2 = [p2[0] - (p3[0] - p1[0]) / 6, p2[1] - (p3[1] - p1[1]) / 6];
    d += ` C${c1} ${c2} ${p2}`;
  }
  return d;
}

document.querySelectorAll(".roadmap").forEach((section) => {
  const stage = section.querySelector(".roadmap__stage");
  const map = section.querySelector(".roadmap__map");
  const svg = map.querySelector(".roadmap__art");
  const line = map.querySelector(".roadmap__line");
  const marker = map.querySelector(".roadmap__marker");
  const stops = [...map.querySelectorAll(".roadmap__stop")];
  // GSAP は var(--name) のままでは補間できないので、色は値にして渡す
  const styles = getComputedStyle(section);
  const [surface, route, accent] = ["--surface", "--route", "--accent"].map((name) => styles.getPropertyValue(name).trim());

  // SVG の要素を作り、印より奥に置く
  const make = (tag, attrs) => {
    const node = document.createElementNS(svg.namespaceURI, tag);
    for (const [key, value] of Object.entries(attrs)) node.setAttribute(key, value);
    marker.before(node);
    return node;
  };

  // 道の通る点(地図の比を viewBox の座標に直す)。最初が出発点、残りが節目
  const { width: W, height: H } = svg.viewBox.baseVal;
  const at = (x, y) => [parseFloat(x) * W, parseFloat(y) * H];
  const points = [at(...map.dataset.start.split(" ")), ...stops.map((stop) => at(stop.style.getPropertyValue("--x"), stop.style.getPropertyValue("--y")))];
  map.querySelectorAll(".roadmap__route, .roadmap__line").forEach((path) => path.setAttribute("d", curve(points)));
  make("circle", { class: "roadmap__start", cx: points[0][0], cy: points[0][1], r: 10 });

  // 線を描く・印を進める・節目を出す、を 1 本のタイムラインに(長さ 1 = 線の全長)。
  // 線も印も線の長さに対する割合で進むので、印はいつも描かれた線の先にいる。
  // alignOrigin で印の中心を線に乗せる(無いと、印を囲む箱の左上が線に乗って、印が線からずれる)
  const tl = gsap
    .timeline({ paused: true, defaults: { ease: "none" } })
    .fromTo(line, { drawSVG: "0%" }, { drawSVG: "100%", duration: 1 }, 0)
    .to(marker, { motionPath: { path: line, align: line, alignOrigin: [0.5, 0.5], autoRotate: true }, duration: 1 }, 0);

  // 節目は「そこまでの長さ ÷ 全長」の位置で出す。そこまでの部分の曲線を作って測る
  const total = line.getTotalLength();
  const measure = make("path", {});
  stops.forEach((stop, i) => {
    const [x, y] = points[i + 1];
    const above = y < points[i][1]; // 上りで着く山の点は上、下りで着く谷の点は下に説明を置く
    stop.classList.toggle("is-above", above);
    const dot = make("circle", { class: "roadmap__dot", cx: x, cy: y, r: 9 });
    measure.setAttribute("d", curve(points, i + 1));
    // 印が着く少し前から出す。最後の節目も、描き終えるまでに出しきる
    const start = Math.min(measure.getTotalLength() / total - 0.015, 0.93);
    tl.fromTo(dot, { attr: { r: 7 }, fill: surface, stroke: route }, { attr: { r: 9 }, fill: accent, stroke: accent, duration: 0.03 }, start)
      // 本文は opacity だけで隠す(visibility で隠すと、読み上げから外れる)
      .fromTo(stop, { opacity: 0, y: above ? 14 : -14 }, { opacity: 1, y: 0, duration: 0.07, ease: "power2.out" }, start);
  });
  measure.remove();

  // 動きを減らす設定では、固定せずに描き終えた地図を見せる
  if (reduceMotion) {
    tl.progress(1);
    return;
  }

  // 狭い画面で地図が舞台より広いときは、印を追って地図を左へ送る
  tl.fromTo(map, { x: 0 }, { x: () => Math.min(0, stage.clientWidth - map.offsetWidth), duration: 1 }, 0);
  section.classList.add("is-pinned");
  ScrollTrigger.create({
    animation: tl,
    trigger: section,
    start: "top top",
    end: "+=170%", // 描き終えるまでのスクロール量(画面の高さの 1.7 倍)。その間、節を画面に固定する
    pin: true,
    scrub: true, // スクロール量にそのまま連動させる(数値の scrub は実時間で追うので使わない)
    invalidateOnRefresh: true, // 画面の幅が変わったら、地図を送る量を測り直す
  });
});

インタラクションデザインについて、
お気軽にご相談ください。

ここに並べているのは、Web サイトの中で動く UI のサンプルです。新しく作るサイトに組み込むことも、いま公開中のサイトに、ボタンやヒーロー、スクロールの演出だけを足すこともできます。設計から実装までの進め方はWeb サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。