本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
アイコン

地図のピンのアイコンが落ちて弾み、足元の影が縮んで濃くなる

デモ

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

仕様

用途
アイコン
動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

ピンは、しずく形の塗りに丸い穴を抜いた形で、足元に楕円の影を置きます。地図が画面に入ると、ピンが地図の上端の外から落ちてきて、着地でつぶれ、2 回小さく弾んで止まります。地図に重ねると、ピンがいったん縮んでから、地図の上端までの半分の高さへ跳ね上がり、もう一度落ちてきます。

落ちるのは 0.52 秒の power1.in です。power1 は 2 次の曲線なので、重力で落ちるのと同じ加速になります。弾む高さは落ちた高さの 14%、その次は 3% と小さくし、上って下りる時間を高さの平方根に比例させると、同じ重力のまま弾んでいるように見えます。跳ね上がるときは落ちるのを裏返した power1.out で、同じ高さを落ちるのと同じ時間をかけて上ります。頂点で速さが 0 になるので、ふわりと止まってから落ちて見えます。着地の瞬間は横 1.26 倍・縦 0.7 倍に 0.06 秒つぶします。伸び縮みの中心はピンの先に置くので、先は地面から離れません。

影は、ピンの高さだけから計算します。高さを 0(地面)〜1(地図の上端)とすると、大きさは「1 + 1.4 × 高さ」倍、濃さは「0.3 × (1 − 高さ) の 1.5 乗」です。高い所では大きく薄く、落ちてくるにつれて小さく濃くなり、つぶれたときは横の倍率に合わせて広がります。影をピンと別のトゥイーンで動かすと、弾みの途中で高さとずれるので、ピンの値が変わるたびに(onUpdate)影も同じ関数で描き直します。

高さの 1 は、ピンの先から地図の上端までの距離で、動かすたびに測って決めます。画面の幅で地図の大きさが変わっても、最初はいつも地図の外から落ち、重ねたときはその半分まで跳ねます。はみ出したピンは、リンクの overflow で切ります。落ちている間に重ね直しても受けず、弾み終わるまで待ちます。途中でやり直すと、空中で急に向きが変わって見えます。

地図は地図アプリへのリンクなので、キーボードで焦点を当てたときも(:focus-visible のときだけ)同じように落とします。動きを減らす設定では、最初から地図の上に置いたまま動かしません。落ちる高さ・弾む回数・つぶす量で印象が変わります。このデモは、ピンを 6 倍の大きさで描いています。店舗案内やアクセスのページの地図、イベント会場の案内、物件の所在地に向いています。

コード

HTML
<!-- 地図は地図アプリへのリンク(href は実際の地図の URL に置き換える) -->
<a class="map-link" href="https://maps.example.com/?q=喫茶ひだまり">
  <!-- ピンと足元の影。画面に入ると上から落ち、重ねる(焦点を当てる)ともう一度落ちる -->
  <svg class="map-link__pin" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
    <ellipse class="map-link__shadow" cx="12" cy="21.5" rx="4" ry="1.2" />
    <g class="map-link__body">
      <path d="M12 21.5C11.6 21.5 4.5 15.2 4.5 9.5A7.5 7.5 0 0 1 19.5 9.5C19.5 15.2 12.4 21.5 12 21.5Z" />
      <circle class="map-link__hole" cx="12" cy="9.5" r="2.8" />
    </g>
  </svg>
  <span class="map-link__label">
    地図アプリで開く
    <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M8 16 16 8M9 8h7v7" /></svg>
  </span>
</a>

CSS
.map-link {
  --ink: #262626;      /* 文字と影の色 */
  --pin: #a05a54;      /* ピンの色(くすみローズの濃い段) */
  --surface: #ffffff;  /* ピンの穴と札の地 */
  --size: 48px;        /* ピンの大きさ(24 グリッドの図を描く大きさ) */
  position: relative;
  display: block;
  max-width: 480px;
  aspect-ratio: 4 / 3;
  border-radius: 4px;
  overflow: hidden;
  color: var(--ink);
  text-decoration: none;
  /* 仮の地図(道 2 本)。実際は地図の画像を background-image に置く */
  background:
    linear-gradient(#fff 0 0) 0 64% / 100% 12px no-repeat,
    linear-gradient(#fff 0 0) 40% 0 / 12px 100% no-repeat,
    #e6e2d8;
}

.map-link:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 3px;
}

/* ピンの先(24 グリッドの 12, 21.5)を、地図の left・top の点に合わせる */
.map-link__pin {
  position: absolute;
  left: 56%;
  top: 60%;
  width: var(--size);
  height: var(--size);
  margin: calc(var(--size) * -21.5 / 24) 0 0 calc(var(--size) / -2);
  overflow: visible;
}

.map-link__body {
  fill: var(--pin);
}

.map-link__hole {
  fill: var(--surface);
}

.map-link__shadow {
  fill: var(--ink);
  fill-opacity: 0.3;
}

.map-link__label {
  position: absolute;
  left: 16px;
  bottom: 16px;
  display: flex;
  align-items: center;
  gap: 6px;
  height: 36px;
  padding: 0 14px 0 16px;
  border-radius: 999px;
  background: var(--surface);
  font-size: 14px;
  font-weight: 600;
  line-height: 1;
  letter-spacing: 0.04em;
  white-space: nowrap;
}

.map-link__label svg {
  width: 14px;
  height: 14px;
  fill: none;
  stroke: currentColor;
  stroke-width: 3;
  stroke-linecap: round;
  stroke-linejoin: round;
}

JavaScript
import { gsap } from "gsap";

const FALL = 0.52; // 地図の上端から落ちきるまでの秒数
const HOP = 0.5; // 重ねたときに跳ね上がる高さ(1 = 地図の上端)
const RISE = FALL * Math.sqrt(HOP); // 跳ね上がる秒数(同じ高さを落ちるのと同じ。同じ重力)
const SPREAD = 1.4; // 高さ 1(地図の上端)で、影が何倍ぶん広がるか
// 弾み: 着地でつぶす横・縦の倍率、つぶれている秒数、次に弾む高さ(落ちた高さに対する比。0 なら止まる)
const BOUNCES = [
  [1.26, 0.7, 0.06, 0.14],
  [1.12, 0.86, 0.05, 0.03],
  [1.05, 0.95, 0.04, 0],
];
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const f = (v) => Math.round(v * 1000) / 1000;

// 跳ね上がる: いったん縮んでから、縦に伸びながら HOP の高さまで上がる。上がりきる時刻を返す
function addLift(tl, s) {
  tl.to(s, { sx: 1.1, sy: 0.84, duration: 0.12, ease: "power2.out" }, 0)
    .to(s, { h: HOP, duration: RISE, ease: "power1.out" }, 0.12)
    .to(s, { sx: 0.9, sy: 1.14, duration: 0.2, ease: "power2.out" }, 0.12);
  return 0.12 + RISE;
}

// 落ちて弾む: 今の高さから power1.in(重力と同じ 2 次の加速)で落ち、着地のたびにつぶれて、前より低く弾む
function addDrop(tl, s, at, from) {
  const k = Math.sqrt(Math.max(0.01, from)); // 低い所から落ちるときは、そのぶん短く
  tl.to(s, { h: 0, sx: 0.92, sy: 1.12, duration: FALL * k, ease: "power1.in" }, at);
  let t = at + FALL * k;
  for (const [sx, sy, hold, up] of BOUNCES) {
    tl.to(s, { sx, sy, duration: hold, ease: "power1.out" }, t);
    t += hold;
    if (!up) {
      tl.to(s, { sx: 1, sy: 1, duration: 0.25, ease: "power2.out" }, t);
      return;
    }
    const half = FALL * k * Math.sqrt(up); // 上って下りる時間は √高さ に比例する
    const flex = Math.min(0.14, half);
    tl.to(s, { h: up * from, duration: half, ease: "power1.out" }, t)
      .to(s, { h: 0, duration: half, ease: "power1.in" }, t + half)
      .to(s, { sx: 0.96, sy: 1.06, duration: flex, ease: "power2.out" }, t)
      .to(s, { sx: 1, sy: 1, duration: half * 2 - flex, ease: "power1.inOut" }, t + flex);
    t += half * 2;
  }
}

document.querySelectorAll(".map-link").forEach((link) => {
  const svg = link.querySelector(".map-link__pin");
  const body = link.querySelector(".map-link__body");
  const shadow = link.querySelector(".map-link__shadow");
  // 高さ 1 = ピンの先が地図の上端より上に出るまでの距離(24 グリッドの単位)
  const lift = () => {
    const unit = svg.getBoundingClientRect().width / 24;
    return (svg.getBoundingClientRect().top - link.getBoundingClientRect().top) / unit + 21.5 + 1;
  };

  // 見た目はこの 3 つの値から描く(高さ・横と縦の伸び縮み)。伸び縮みの中心はピンの先
  const state = { h: reduceMotion ? 0 : 1, sx: 1, sy: 1 };
  let drop = lift();
  const draw = () => {
    body.setAttribute("transform", `translate(12 ${f(21.5 - state.h * drop)}) scale(${f(state.sx)} ${f(state.sy)}) translate(-12 -21.5)`);
    // 影: 高いほど大きく薄く、地面に近いほど小さく濃く。つぶれて横に広がると、影も広がる
    const grow = 1 + SPREAD * state.h;
    shadow.setAttribute("rx", f(4 * grow * state.sx));
    shadow.setAttribute("ry", f(1.2 * grow));
    shadow.style.fillOpacity = f(0.3 * Math.max(0, 1 - state.h) ** 1.5);
  };
  draw();
  if (reduceMotion) return; // 動きを減らす設定では、最初から地図の上に置いたまま動かさない

  let anim = null;
  const run = (build) => {
    if (anim?.isActive()) return; // 動いている間は受けない
    drop = lift(); // 画面の幅が変わっていても、地図の上端から落とす
    anim = gsap.timeline({ onUpdate: draw });
    build(anim);
  };
  // 画面に半分入ったら、上から落とす
  new IntersectionObserver(([entry], observer) => {
    if (!entry.isIntersecting) return;
    observer.disconnect();
    run((tl) => addDrop(tl, state, 0, state.h));
  }, { threshold: 0.5 }).observe(link);
  // 重ねたら(キーボードの焦点でも)跳ね上がって、もう一度落ちる(落ちてくる前なら、そのまま落とす)
  const hop = () => run((tl) => (state.h > HOP ? addDrop(tl, state, 0, state.h) : addDrop(tl, state, addLift(tl, state), HOP)));
  link.addEventListener("pointerenter", hop);
  link.addEventListener("focus", () => link.matches(":focus-visible") && hop());
});

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

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