デモ
デモを読み込めませんでした。
仕様
- 用途
- セクション
- 動作
- 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 だけで隠し、読み上げからは外しません。動きを減らす設定では、固定せずに描き終えた地図を見せます。
つまみは、道の形、固定する長さ、道と印の色です。流れるページの中で直線を伸ばして年表の項目を出す形と違い、節を止めたまま道全体を見せて印を進めるので、先の予定まで見通せます。ロードマップ・導入の手順・配送の流れの説明に向いています。
コード
<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>
.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); }
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, // 画面の幅が変わったら、地図を送る量を測り直す
});
});