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

アイコン

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

デモ

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

仕様

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

しくみと調整

地図が画面に入ってきたところで、ピンが上端の外から落ちてきて、地面でつぶれ、小さく2回弾んで止まります。足元の楕円の影は、ピンが高いうちは大きく薄く、地面に近づくにつれて小さく濃くなります。地図にカーソルを重ねれば、ピンはいったん縮んでから上端までの半分の高さへ跳ね上がり、また落ちてきます。

落ち方は重力に合わせました。0.52秒の power1.in は時間の2乗で進む曲線なので、物が落ちるときと同じ加速になります。弾む高さは落ちた高さの14%、次が3%で、上がって下りる時間は高さの平方根に比例させています。こうしておくと、どの弾みも同じ重力の中で起きているように見えます。跳ね上がるときは裏返しの power1.out で、頂点でいったん速さがなくなり、ふわりと止まってから落ちてきます。

影は自分のトゥイーンを持たず、ピンの高さだけから大きさと濃さを計算しています。別のトゥイーンで動かすと、弾んでいる途中でピンと影の様子がずれるので、ピンの値が変わるたびに onUpdate で同じ関数から描き直しています。つぶれるときは伸び縮みの中心をピンの先に置いているので、先は地面から浮かず、影も横の倍率に合わせて広がります。

高さの基準は固定の値にせず、ピンの先から地図の上端までの距離を、動かすたびに測り直しています。画面の幅で地図の大きさが変わっても、最初はかならず地図の外から落ちてきます。はみ出した分は、リンクの overflow で切っています。落ちている最中に重ね直されても受け付けず、弾み終わるまで待たせることにしました。途中でやり直すと、ピンが空中で急に向きを変えたように見えてしまいます。

地図は地図アプリへのリンクなので、キーボードでフォーカスが当たったとき(:focus-visible のときだけ)も、同じようにピンを落とします。

コード

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 サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。