デモ
デモを読み込めませんでした。
仕様
- 用途
- アイコン
- 動作
- 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 倍の大きさで描いています。店舗案内やアクセスのページの地図、イベント会場の案内、物件の所在地に向いています。
コード
<!-- 地図は地図アプリへのリンク(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>
.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;
}
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());
});