デモ
デモを読み込めませんでした。
仕様
- 用途
- セクション
- 動作
- DOM
- 種類
- レシピ
- 使うもの
- ScrollTrigger
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
地図のセクションまでスクロールすると地図が画面に止まり、駅の西口から店までの道順がローズカラーの線で描かれていきます。線の先の丸が曲がり角に着くたびに、番号付きの吹き出しでその角の説明が表示され、店に着くと上からピンが落ちて弾み、「駅から徒歩6分」のバッジが開きます。上へ戻せば、線も吹き出しも逆にたどって消えていきます。
道順は直角に曲がる折れ線です。pathLength を1にしておけば、stroke-dashoffset を1から0へ動かすだけで描けるので、線の長さを測らずに済みます。線の先の丸は区間ごとに cx と cy を動かし、どの区間にもその長さに比例した時間を割り当てました。線も丸も ease: "none" で同じタイムラインに並んでいて、丸が線の先端から遅れることはありません。
吹き出しは、CSS の translate で角からずらして置いています。表示するときの小さな上下の動きは、GSAP の y で付けるのが手軽に見えます。ただ、GSAP は transform を動かすときに translate を自分の値に取り込んで消してしまうので、吹き出しが角からずれた位置へ飛びます。そこで CSS 変数の --lift を補間し、translate の式に足すことにしました。吹き出しは opacity だけで隠しているので、スクリーンリーダーでは最初から4つの道順を順に読めます。
地図を止めておくスクロール量は、いまは画面の高さの2倍。描き始めから店に着くまでを、落ち着いて目で追える長さです。短くすると道順が一気に引かれ、角の説明を読む間がなくなります。狭い画面では吹き出しを重ねると道が隠れてしまうので、説明は地図の下に一覧で並べ、地図だけを画面の中央に止めています。
コード
<section class="access" aria-labelledby="access-title">
<div class="access__intro">
<p class="access__label">ACCESS</p>
<h2 class="access__title" id="access-title">アクセス</h2>
<p class="access__text">ここに説明文が入ります。ここに説明文が入ります。</p>
<a class="access__button" href="#">地図アプリで開く</a>
</div>
<figure class="access__map">
<div class="access__canvas">
<!-- 地図はデモ用の図形。実際の地図の画像や SVG に差し替え、道順の点(data-points)をその座標に合わせる -->
<svg class="access__art" viewBox="0 0 736 576" aria-hidden="true">
<rect class="access__ground" width="736" height="576"/>
<rect class="access__park" width="150" height="576"/>
<rect class="access__block" x="296" y="88" width="104" height="160" rx="4"/>
<rect class="access__block" x="464" y="88" width="112" height="160" rx="4"/>
<rect class="access__block" x="640" y="88" width="88" height="160" rx="4"/>
<rect class="access__block" x="296" y="312" width="104" height="48" rx="4"/>
<rect class="access__block" x="464" y="312" width="112" height="48" rx="4"/>
<rect class="access__block" x="640" y="312" width="88" height="48" rx="4"/>
<rect class="access__block" x="296" y="424" width="104" height="40" rx="4"/>
<rect class="access__block" x="464" y="424" width="112" height="16" rx="4"/>
<rect class="access__block" x="640" y="424" width="88" height="16" rx="4"/>
<rect class="access__block" x="296" y="528" width="432" height="40" rx="4"/>
<path class="access__river" d="M184 0 C198 120 168 240 188 360 S194 520 182 576"/>
<path class="access__road" d="M432 0V496 M264 0V576 M608 0V472 M264 56H736 M264 280H736 M264 392H736 M0 392H264 M0 120H264"/>
<path class="access__rail" d="M0 496H736"/>
<path class="access__ties" d="M0 496H736"/>
<rect class="access__station" x="480" y="472" width="192" height="48" rx="4"/>
<text class="access__name" x="576" y="502">南町駅</text>
<text class="access__note" x="474" y="462" text-anchor="end">西口</text>
<text class="access__note" x="150" y="548" text-anchor="end">桜川</text>
<g class="access__signal" transform="translate(446 402)"><rect width="32" height="12" rx="6"/><circle cx="7" cy="6" r="2.8"/><circle cx="16" cy="6" r="2.8"/><circle cx="25" cy="6" r="2.8"/></g>
<g class="access__signal" transform="translate(446 304)"><rect width="32" height="12" rx="6"/><circle cx="7" cy="6" r="2.8"/><circle cx="16" cy="6" r="2.8"/><circle cx="25" cy="6" r="2.8"/></g>
<rect class="access__store" x="296" y="96" width="96" height="104" rx="4"/>
<rect class="access__sign" x="296" y="116" width="10" height="60" rx="2"/>
<!-- 道順の折れ線。最初が駅の西口、最後が店の前。pathLength="1" で、破線のずれを 1 → 0 と動かして描く -->
<path class="access__route" data-points="472,496 432,496 432,280 264,280 264,144" pathLength="1" stroke-dasharray="1 1" stroke-dashoffset="1"/>
<circle class="access__head" cx="472" cy="496" r="10" opacity="0"/>
<ellipse class="access__shadow" cx="344" cy="148" rx="4" ry="4" opacity="0"/>
<g class="access__pin" data-at="344,148" transform="translate(344 58)" opacity="0"><path d="M0 0C-6 -12 -18 -20 -18 -34A18 18 0 1 1 18 -34C18 -20 6 -12 0 0Z"/><circle cx="0" cy="-34" r="7"/></g>
</svg>
<p class="access__badge">駅から徒歩6分</p>
</div>
<!-- 曲がり角ごとの説明。data-at は道順の何番目の点か、data-side は吹き出しを置く向き -->
<ol class="access__steps">
<li class="access__step" data-at="1" data-side="left"><span class="access__num" aria-hidden="true">1</span>西口を出て右へ</li>
<li class="access__step" data-at="2" data-side="right"><span class="access__num" aria-hidden="true">2</span>2つ目の信号を左へ</li>
<li class="access__step" data-at="3" data-side="below"><span class="access__num" aria-hidden="true">3</span>川沿いを100mほど直進</li>
<li class="access__step" data-at="4" data-side="left"><span class="access__num" aria-hidden="true">4</span>青い看板の2階です</li>
</ol>
<figcaption class="access__caption">南町駅の西口から店までの道順</figcaption>
</figure>
</section>
.access {
--ink: #262626; /* 文字の色 */
--paper: #f2f1ed; /* 背景色 */
--surface: #ffffff; /* 吹き出しの面 */
--mute-small: #6b6b6b;
--line: rgba(38, 38, 38, 0.12);
--ground: #e6e2d9; /* 地図の色 */
--block: #d9d3c6;
--park: #b9cfb2;
--river: #9dbad2;
--rail: #8a8780;
--route: #c8675b; /* 道順の線・現在地・ピン */
--sign: #4d7ead; /* 店の青い看板 */
box-sizing: border-box;
display: grid;
grid-template-columns: minmax(0, 304px) minmax(0, 736px);
justify-content: center;
align-items: center;
gap: 48px;
min-height: 100svh;
padding: 64px 24px;
background: var(--paper);
color: var(--ink);
}
.access__label { margin: 0; font-size: 13px; font-weight: 700; line-height: 1; letter-spacing: 0.14em; color: var(--mute-small); }
.access__title { margin: 16px 0 0; font-size: 44px; font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; }
.access__text { margin: 24px 0 0; font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; }
.access__button { display: inline-flex; align-items: center; height: 48px; margin-top: 40px; padding: 0 24px; border-radius: 999px; box-shadow: inset 0 0 0 1px var(--ink); color: inherit; font-size: 14px; font-weight: 600; letter-spacing: 0.04em; text-decoration: none; }
.access__button:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
/* 地図は画面の高さに収まる幅にする(固定している間、上下が切れない) */
.access__map { position: relative; width: min(100%, calc((100svh - 128px) * 736 / 576)); margin: 0; container-type: inline-size; }
.access__canvas { position: relative; aspect-ratio: 736 / 576; border-radius: 4px; overflow: hidden; }
.access__art { display: block; width: 100%; height: 100%; }
.access__ground { fill: var(--ground); }
.access__park { fill: var(--park); }
.access__block { fill: var(--block); }
.access__river { fill: none; stroke: var(--river); stroke-width: 56; }
.access__road { fill: none; stroke: #ffffff; stroke-width: 18; }
.access__rail { stroke: var(--rail); stroke-width: 8; }
.access__ties { stroke: #ffffff; stroke-width: 4; stroke-dasharray: 2 10; }
.access__station { fill: var(--ink); }
.access__name,
.access__note { font-size: 16px; font-weight: 700; letter-spacing: 0.06em; text-anchor: middle; fill: #ffffff; }
.access__note { font-size: 12px; text-anchor: end; fill: var(--mute-small); }
.access__signal rect { fill: #3a3a3a; }
.access__signal circle:nth-of-type(1) { fill: #6ba37f; }
.access__signal circle:nth-of-type(2) { fill: #c8bf83; }
.access__signal circle:nth-of-type(3) { fill: #e07a62; }
.access__store { fill: #f4f1ea; }
.access__sign { fill: var(--sign); }
/* 破線の長さとずれは属性に書く(CSS に書くと、属性を書き換えても効かなくなる) */
.access__route { fill: none; stroke: var(--route); stroke-width: 8; stroke-linecap: round; stroke-linejoin: round; }
.access__head { fill: var(--route); stroke: #ffffff; stroke-width: 4; }
.access__shadow { fill: rgba(38, 38, 38, 0.25); }
.access__pin path { fill: var(--route); stroke: #ffffff; stroke-width: 3; }
.access__pin circle { fill: #ffffff; }
/* バッジと吹き出しの大きさは、地図の幅(cqi)に合わせる */
.access__badge { position: absolute; left: 50.5%; top: 13.5%; display: flex; align-items: center; height: 2.6em; margin: 0; padding: 0 1.15em; border-radius: 999px; background: var(--ink); color: #ffffff; font-size: clamp(11px, 1.9cqi, 14px); font-weight: 700; line-height: 1; letter-spacing: 0.04em; white-space: nowrap; clip-path: inset(0% 100% 0% 0% round 99px); }
/* 吹き出しは opacity だけで隠す(visibility で隠すと、スクリーンリーダーで読めなくなる)。
位置は translate で決めるので、表示するときの動きは --lift(px)で足す */
.access__steps { position: absolute; inset: 0; margin: 0; padding: 0; list-style: none; pointer-events: none; }
.access__step {
--lift: 0; position: absolute; left: var(--x); top: var(--y); display: flex; align-items: center; gap: 0.57em; height: 2.86em; padding: 0 1em 0 0.57em;
border-radius: 4px; background: var(--surface); box-shadow: inset 0 0 0 1px var(--line); font-size: clamp(11px, 1.9cqi, 14px); font-weight: 600; line-height: 1; letter-spacing: 0.04em; white-space: nowrap; opacity: 0;
}
.access__step[data-side="left"] { translate: calc(-100% - 1.57em) calc(-50% + var(--lift) * 1px); }
.access__step[data-side="right"] { translate: 1.57em calc(-50% + var(--lift) * 1px); }
.access__step[data-side="below"] { translate: -3.4em calc(1.57em + var(--lift) * 1px); }
/* 尾(面と同じ色の小さな四角を 45° 回す) */
.access__step::before { content: ""; position: absolute; width: 0.72em; height: 0.72em; background: var(--surface); box-shadow: inset 1px 1px 0 var(--line); }
.access__step[data-side="left"]::before { right: -0.36em; top: calc(50% - 0.36em); rotate: 135deg; }
.access__step[data-side="right"]::before { left: -0.36em; top: calc(50% - 0.36em); rotate: -45deg; }
.access__step[data-side="below"]::before { left: calc(3.4em - 0.36em); top: -0.36em; rotate: 45deg; }
.access__num { flex: none; display: grid; place-items: center; width: 1.72em; height: 1.72em; border-radius: 50%; background: var(--route); color: #ffffff; font-size: 0.86em; font-weight: 700; font-variant-numeric: tabular-nums; }
.access__caption { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
/* 狭い画面では、吹き出しを地図の下に一覧で並べる */
@media (max-width: 760px) {
.access { grid-template-columns: minmax(0, 1fr); gap: 32px; }
.access__map { width: 100%; }
.access__steps { position: static; display: grid; gap: 8px; margin-top: 16px; }
.access__step,
.access__step[data-side] { position: static; width: fit-content; font-size: 14px; translate: 0 calc(var(--lift) * 1px); }
.access__step::before { display: none; }
}
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const DRAW_START = 0.04; // 線を描く区間(スクロールの全量を 1 としたとき)
const DRAW_END = 0.82;
const DROP = 90; // ピンが落ちてくる高さ(地図の座標)
document.querySelectorAll(".access").forEach((section) => {
const map = section.querySelector(".access__map");
const route = section.querySelector(".access__route");
const head = section.querySelector(".access__head");
const pin = section.querySelector(".access__pin");
const steps = [...section.querySelectorAll(".access__step")];
const { width: W, height: H } = route.ownerSVGElement.viewBox.baseVal;
// 道順の点から折れ線を作り、吹き出しを曲がり角の位置に置く
const points = route.dataset.points.split(" ").map((p) => p.split(",").map(Number));
route.setAttribute("d", `M${points.join(" L")}`);
steps.forEach((step) => {
const [x, y] = points[step.dataset.at];
step.style.setProperty("--x", `${(x / W) * 100}%`);
step.style.setProperty("--y", `${(y / H) * 100}%`);
});
const [pinX, pinY] = pin.dataset.at.split(",").map(Number);
// 線を描く・現在地を進める・吹き出しを表示する・ピンを立てる、を 1 本のタイムラインに(長さ 1 = スクロールの全量)
const lengths = points.slice(1).map(([x, y], i) => Math.hypot(x - points[i][0], y - points[i][1]));
const total = lengths.reduce((a, b) => a + b, 0);
const span = DRAW_END - DRAW_START;
const tl = gsap
.timeline({ paused: true, defaults: { ease: "none" } })
.fromTo(route, { attr: { "stroke-dashoffset": 1 } }, { attr: { "stroke-dashoffset": 0 }, duration: span }, DRAW_START)
.fromTo(head, { attr: { opacity: 0 } }, { attr: { opacity: 1 }, duration: 0.02 }, DRAW_START - 0.02);
// 現在地の丸は区間ごとに、その区間の長さに比例した時間で動かす。線の先端といつもそろう
const arrive = [DRAW_START];
lengths.forEach((len, i) => {
const dur = (len / total) * span;
tl.fromTo(head, { attr: { cx: points[i][0], cy: points[i][1] } }, { attr: { cx: points[i + 1][0], cy: points[i + 1][1] }, duration: dur, immediateRender: i === 0 }, arrive[i]);
arrive.push(arrive[i] + dur);
});
// 吹き出しは、丸が角に着く少し前から表示する
steps.forEach((step) => {
const lift = step.dataset.side === "below" ? -8 : 8;
tl.fromTo(step, { opacity: 0, "--lift": lift }, { opacity: 1, "--lift": 0, duration: 0.05, ease: "power2.out" }, arrive[step.dataset.at] - 0.02);
});
// ピンは上から落ちて弾み、足元の影が広がる。そのあとバッジが左から開く
const end = arrive.at(-1) + 0.01;
tl.fromTo(pin, { attr: { opacity: 0 } }, { attr: { opacity: 1 }, duration: 0.01 }, end)
.fromTo(pin, { attr: { transform: `translate(${pinX} ${pinY - DROP})` } }, { attr: { transform: `translate(${pinX} ${pinY})` }, duration: 0.08, ease: "bounce.out", immediateRender: false }, end)
.fromTo(section.querySelector(".access__shadow"), { attr: { opacity: 0, rx: 4 } }, { attr: { opacity: 1, rx: 12 }, duration: 0.08, ease: "power2.in" }, end)
.fromTo(section.querySelector(".access__badge"), { clipPath: "inset(0% 100% 0% 0% round 99px)" }, { clipPath: "inset(0% 0% 0% 0% round 99px)", duration: 0.05, ease: "power2.out" }, end + 0.08)
.set({}, {}, 1);
// 動きを減らす設定では、固定せずに描き終えた地図を見せる
if (reduceMotion) {
tl.progress(1);
return;
}
// 広い画面はセクションごと、狭い画面(吹き出しが地図の下の一覧になる)は地図だけを画面の中央に固定する。
// 条件を 2 つとも並べる(片方だけだと、合わない画面で一度も呼ばれない)
gsap.matchMedia().add({ wide: "(min-width: 761px)", narrow: "(max-width: 760px)" }, (context) => {
ScrollTrigger.create({
animation: tl,
trigger: context.conditions.wide ? section : map,
start: "center center",
end: "+=200%", // 描き終えるまでのスクロール量(画面の高さの 2 倍)
pin: true,
scrub: true, // スクロール量にそのまま連動させる
});
});
});