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

地図のセクションで拠点のピンが順に落ちて立ち、弧で結ばれる

デモ

操作:カーソルを重ねる。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。

仕様

動作
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 本のピンだけを落として見せる作りと違い、こちらは拠点の順番と結びまで見せる形なので、会社の拠点案内、配送できる範囲、催しの開催地の紹介に向いています。陸の形は抽象的な塊なので、案件では実際の地図の輪郭に差し替えてください。

コード

HTML
<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>

CSS
.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; }
}

JavaScript
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());
});

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

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