デモ
デモを読み込めませんでした。
仕様
- 用途
- セクション
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
拠点の一覧を、暗い地図の板の上で見せます。画面に入ると、西から順にピンが空から落ちてきて、弾んで立ちます。立った足元には光の輪が広がります。5 本立ちそろうと、隣どうしの拠点を結ぶ弧が 1 本ずつ描かれ、ピンに重ねると拠点名と人数の札が上に開きます。
落ちて弾む動きは bounce.out そのままで、1 本 0.9 秒、0.25 秒ずつずらしています。落ち始めの高さは拠点ごとに変えて、板の上端より必ず上から入るようにしています。同じ高さから落とすと、下のほうの拠点では落ち始めが板の中に見えてしまいます。
弧は、2 点の中点を進む向きの左へ、2 点の距離の 18% だけ膨らませた曲線です。線は pathLength を 1 にして破線の送りを 1 から 0 へ動かすだけで描けるので、曲線の長さを測る必要がありません。
ピンの場所は SVG の transform 属性で決め、上下の動きは内側のもう 1 つの入れ物にかけています。1 つの要素で場所と動きを取り合うと、落ちるたびに位置がずれます。頭の丸を大きくするのも、拡大ではなく半径そのものを動かしています。拡大は軸の置き方で見た目が変わるためです。
つまみは、落ちる高さ、弾みの長さ、弧の膨らみ、札の位置です。1 本のピンだけを落として見せる作りと違い、こちらは拠点の順番と結びまで見せる形なので、会社の拠点案内、配送できる範囲、催しの開催地の紹介に向いています。陸の形は抽象的な塊なので、案件では実際の地図の輪郭に差し替えてください。
コード
<section class="map" aria-labelledby="map-title">
<p class="map__eyebrow">Locations</p>
<h2 class="map__title" id="map-title">5つの拠点、<wbr>ひとつのチーム。</h2>
<p class="map__lead">ここに説明文が入ります。</p>
<div class="map__board">
<!-- 抽象的な陸の塊。案件では、実際の地図の輪郭に差し替える -->
<svg class="map__svg" viewBox="0 0 1088 424" preserveAspectRatio="none" aria-hidden="true" focusable="false">
<path class="map__land" d="M62 108C124 66 214 70 254 110C302 158 288 226 238 266C192 302 98 296 68 248C38 200 22 138 62 108Z" />
<path class="map__land" d="M372 66C452 40 562 54 614 98C668 144 642 204 582 218C522 232 432 220 392 182C352 144 332 92 372 66Z" />
<path class="map__land" d="M424 272C504 248 594 262 632 302C668 342 630 386 564 392C498 398 440 382 418 348C396 314 390 286 424 272Z" />
<path class="map__land" d="M742 116C822 80 942 92 1002 138C1060 184 1048 268 988 304C926 340 822 332 776 290C730 248 702 152 742 116Z" />
</svg>
<!-- --x / --y は地図の座標(viewBox と同じ 1088 × 424)。位置も弧も、この値から決まる -->
<ul class="map__list">
<li class="map__site" style="--x: 152; --y: 250">
<a class="map__pin" href="/company/fukuoka/"><span class="map__sr">福岡の拠点を見る</span></a>
<span class="map__card"><span class="map__name">福岡</span><span class="map__count">スタッフ12名</span></span>
</li>
<li class="map__site" style="--x: 436; --y: 180">
<a class="map__pin" href="/company/osaka/"><span class="map__sr">大阪の拠点を見る</span></a>
<span class="map__card"><span class="map__name">大阪</span><span class="map__count">スタッフ8名</span></span>
</li>
<li class="map__site" style="--x: 566; --y: 292">
<a class="map__pin" href="/company/nagoya/"><span class="map__sr">名古屋の拠点を見る</span></a>
<span class="map__card"><span class="map__name">名古屋</span><span class="map__count">スタッフ6名</span></span>
</li>
<li class="map__site" style="--x: 824; --y: 198">
<a class="map__pin" href="/company/tokyo/"><span class="map__sr">東京の拠点を見る</span></a>
<span class="map__card"><span class="map__name">東京</span><span class="map__count">スタッフ24名</span></span>
</li>
<li class="map__site" style="--x: 946; --y: 128">
<a class="map__pin" href="/company/sendai/"><span class="map__sr">仙台の拠点を見る</span></a>
<span class="map__card"><span class="map__name">仙台</span><span class="map__count">スタッフ4名</span></span>
</li>
</ul>
</div>
<p class="map__note">ピンに重ねるか、キーボードで選ぶと、拠点名と人数が出ます。</p>
</section>
.map {
--bg: #f2f1ed;
--surface: #ffffff;
--ink: #262626;
--mute: #6b6b6b;
--face: #262626; /* 地図の板 */
--pin: #eeece6;
--land: rgba(238, 236, 230, 0.1);
--grid: rgba(238, 236, 230, 0.07);
--link: rgba(238, 236, 230, 0.72);
padding: 96px 24px;
background: var(--bg);
color: var(--ink);
}
.map__eyebrow,
.map__title,
.map__lead,
.map__board,
.map__note { max-width: 1088px; margin-inline: auto; }
.map__eyebrow { margin: 0 auto 12px; font-size: 13px; font-weight: 700; line-height: 1; letter-spacing: 0.14em; text-transform: uppercase; color: var(--mute); }
.map__title { margin: 0 auto; font-size: clamp(28px, 4vw, 44px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; word-break: keep-all; }
.map__lead { margin: 16px auto 0; font-size: 14px; line-height: 1.7; letter-spacing: 0.04em; color: var(--mute); }
.map__note { margin: 20px auto 0; font-size: 13px; line-height: 1.6; letter-spacing: 0.04em; color: var(--mute); }
/* 板の比は地図の座標と同じ。経緯線は背景の格子で引く */
.map__board {
position: relative;
margin-top: 32px;
aspect-ratio: 1088 / 424;
border-radius: 4px;
background-color: var(--face);
background-image: linear-gradient(90deg, var(--grid) 1px, transparent 1px), linear-gradient(var(--grid) 1px, transparent 1px);
background-size: 12.5% 25%;
}
.map__svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: hidden; border-radius: 4px; }
.map__land { fill: var(--land); }
.map__link { fill: none; stroke: var(--link); stroke-width: 3; stroke-linecap: round; }
.map__list { margin: 0; padding: 0; list-style: none; }
/* 位置は地図の座標の割合。板の比をそろえてあるので、縮んでも図とずれない */
.map__site { position: absolute; left: calc(var(--x) / 1088 * 100%); top: calc(var(--y) / 424 * 100%); }
.map__site::before { content: ""; position: absolute; left: -9px; top: -9px; width: 14px; height: 14px; border: 2px solid var(--pin); border-radius: 50%; opacity: calc(var(--ring, 1) * 0.55); scale: calc(0.45 + var(--ring, 1) * 0.55); }
/* ピン。当たりは頭より広くとる。頭は丸、足は細い棒 */
.map__pin { position: absolute; left: -28px; top: -54px; width: 56px; height: 60px; }
.map__pin::before { content: ""; position: absolute; left: 17px; top: 5px; width: 9px; height: 9px; border: 6.5px solid var(--pin); border-radius: 50%; background: var(--face); transition: scale 0.2s; }
.map__pin::after { content: ""; position: absolute; left: 26.75px; top: 28px; width: 2.5px; height: 26px; border-radius: 2px; background: var(--pin); }
.map__site:hover .map__pin::before,
.map__pin:focus-visible::before { scale: 1.18; }
.map__pin:focus-visible { outline: 2px solid var(--pin); outline-offset: -8px; border-radius: 4px; }
/* 札は明暗を反転した白い面。頭の丸の 9px 上に置く */
.map__card {
position: absolute;
left: 50%;
bottom: 58px;
display: grid;
gap: 2px;
padding: 8px 14px;
border-radius: 4px;
background: var(--surface);
color: var(--ink);
white-space: nowrap;
opacity: 0;
translate: -50% 8px;
transition: opacity 0.25s, translate 0.25s;
pointer-events: none;
}
.map__site:hover .map__card,
.map__site:focus-within .map__card { opacity: 1; translate: -50% 0; }
.map__name { font-size: 15px; font-weight: 700; line-height: 1.3; letter-spacing: -0.01em; }
.map__count { font-size: 13px; line-height: 1.4; letter-spacing: 0.04em; color: var(--mute); }
.map__sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (max-width: 720px) {
.map { padding: 64px 16px; overflow-x: clip; }
/* 板を縦に伸ばして拠点をばらけさせる。陸は抽象的な塊なので、伸びても図として読める */
.map__board { aspect-ratio: 1088 / 560; }
.map__link { vector-effect: non-scaling-stroke; }
.map__site::before { left: -7px; top: -7px; width: 10px; height: 10px; }
.map__pin { left: -22px; top: -36px; width: 44px; height: 44px; }
.map__pin::before { left: 15px; top: 5px; width: 6px; height: 6px; border-width: 4px; }
.map__pin::after { left: 21px; top: 20px; width: 2px; height: 16px; }
/* 札は上でなく下に出す(上だと板の外へはみ出す) */
.map__card { top: 14px; bottom: auto; padding: 6px 10px; translate: -50% -8px; }
.map__site:hover .map__card,
.map__site:focus-within .map__card { translate: -50% 0; }
.map__name { font-size: 13px; }
.map__count { font-size: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.map__card,
.map__pin::before { transition: none; }
}
import { gsap } from "gsap";
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const NS = "http://www.w3.org/2000/svg";
const BEND = 0.18; // 弧の膨らみ。2 点の距離に対する割合
// 隣どうしを結ぶ弧。中点を進む向きの左へ膨らませ、ピンの足元より少し上を通す
function arc(a, b) {
const [dx, dy] = [b.x - a.x, b.y - a.y];
const len = Math.hypot(dx, dy) || 1;
const cx = (a.x + b.x) / 2 + (dy / len) * len * BEND;
const cy = (a.y + b.y) / 2 - (dx / len) * len * BEND - 4;
return `M${a.x} ${a.y - 4}Q${Math.round(cx)} ${Math.round(cy)} ${b.x} ${b.y - 4}`;
}
document.querySelectorAll(".map").forEach((section) => {
const svg = section.querySelector(".map__svg");
const sites = [...section.querySelectorAll(".map__site")];
// 位置は CSS 変数(地図の座標)で持たせてあるので、弧もそこから引く
const points = sites.map((site) => {
const style = getComputedStyle(site);
return { x: Number(style.getPropertyValue("--x")), y: Number(style.getPropertyValue("--y")) };
});
const links = points.slice(1).map((point, i) => {
const path = document.createElementNS(NS, "path");
path.setAttribute("class", "map__link");
path.setAttribute("pathLength", "1"); // 長さを 1 と決めると、破線も送りも割合で書ける
path.setAttribute("d", arc(points[i], point));
svg.append(path);
return path;
});
// 動きを減らす設定では、立ちそろった地図をそのまま見せる
if (reduceMotion) return;
for (const path of links) path.style.strokeDasharray = "1 1";
const tl = gsap.timeline({ paused: true });
sites.forEach((site, i) => {
// 板の上端より上から落とす。同じ高さから落とすと、下のほうの拠点は落ち始めが板の中に見える
tl.fromTo(site, { y: -(site.offsetTop + 60) }, { y: 0, duration: 0.9, ease: "bounce.out" }, i * 0.25)
.fromTo(site, { "--ring": 0 }, { "--ring": 1, duration: 0.4, ease: "power2.out" }, i * 0.25 + 0.38);
});
tl.fromTo(links, { strokeDashoffset: 1 }, { strokeDashoffset: 0, duration: 0.5, ease: "power2.inOut", stagger: 0.22 }, 2); // 5 本目が立ってから結び始める
// 板の上端が画面の下から 20% の線を越えたら、1 回だけ落とす
const board = section.querySelector(".map__board");
const io = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting) return;
io.disconnect();
tl.play();
},
{ rootMargin: "0px 0px -20% 0px" }
);
io.observe(board);
// Tab でピンに来たら、線を越える前でも立てる(落ちる前のピンに焦点が残らないように)
section.addEventListener("focusin", () => tl.play());
});