デモ
デモを読み込めませんでした。
仕様
- 用途
- ナビゲーション
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
画面の下に固定した Dock のナビゲーションで、ポインタが近づくと、近いアイコンほど大きくなります。大きさはポインタと各アイコンの中心との距離から決め、半径 180px の中で余弦の山を描くように、真上で最大、離れるほどなめらかに元の大きさへ戻します。拡大は幅と高さで行うので、隣のアイコンが押し広げられ、帯も左右に伸びます。いちばん大きいアイコンの上には、名前の札が出ます。
ふだん 56px のアイコンを、真上で 104px まで広げます。拡大の中心は gsap.quickTo で 0.12 秒遅れてポインタを追うので、素早く動かしても大きさが段々にならず、波がなめらかに移ります。Dock に入る・出るときは、拡大の強さを 0 から 1 へ 0.25 秒で上げ下げし、いきなり膨らんだりしぼんだりしないようにします。
距離は、拡大していないときに測ったアイコンの中心から測ります。拡大して動いた今の位置から測ると、広がったアイコンがポインタから逃げて次の拡大を変え、ポインタを止めてもアイコンが揺れます。拡大に scale は使いません。scale では隣が押し広げられず、アイコンどうしが重なります。項目の高さはふだんの大きさで止め、大きくなったアイコンは下端をそろえたまま帯の上へはみ出させます。
名前の札はリンクの中の文字で、そのままリンクの名前になります。ふだんは透明にしておくだけにして、読み上げには残します。キーボードで移ったときは、焦点のあるアイコンを中心に同じように広げ、札を出します。動きを減らす設定と、指で触る端末では拡大せず、名前の札とフォーカスの枠だけにします。
つまみは、ふだんと最大の大きさ、拡大が届く半径、追う時間です。半径を広げるほどなだらかな波になり、多くのアイコンが一緒に動きます。最大をふだんの 2 倍より大きくすると、帯からのはみ出しが目立ちます。丸い地が滑ってついてくるナビとは違い、アイコンの大きさそのものが動くので、ポートフォリオやアプリ紹介のページの、項目の少ないナビゲーションに向いています。
コード
<!-- 画面の下に固定する Dock のナビゲーション。名前の札(dock__label)が、そのままリンクの名前になる -->
<nav class="dock" aria-label="メイン">
<ul class="dock__list">
<li class="dock__item">
<a class="dock__link" href="/" aria-current="page">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 10.5 12 4l8 6.5V19a1 1 0 0 1-1 1h-4.5v-5.5h-5V20H5a1 1 0 0 1-1-1z" /></svg>
<span class="dock__label">ホーム</span>
</a>
</li>
<li class="dock__item">
<a class="dock__link" href="/work/">
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3.5" y="7" width="17" height="12.5" rx="2" /><path d="M9 7V5.5A1.5 1.5 0 0 1 10.5 4h3A1.5 1.5 0 0 1 15 5.5V7M3.5 12.5h17" /></svg>
<span class="dock__label">制作実績</span>
</a>
</li>
<li class="dock__item">
<a class="dock__link" href="/services/">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m12 4 8.5 4.5L12 13 3.5 8.5z" /><path d="m3.5 12.5 8.5 4.5 8.5-4.5M3.5 16.5 12 21l8.5-4.5" /></svg>
<span class="dock__label">サービス</span>
</a>
</li>
<li class="dock__item">
<a class="dock__link" href="/journal/">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5.5h5.5A2.5 2.5 0 0 1 12 8v11.5a2 2 0 0 0-2-2H4zM20 5.5h-5.5A2.5 2.5 0 0 0 12 8v11.5a2 2 0 0 1 2-2h6z" /></svg>
<span class="dock__label">ブログ</span>
</a>
</li>
<li class="dock__item">
<a class="dock__link" href="/about/">
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8.5" r="3.75" /><path d="M4.5 20a7.5 7.5 0 0 1 15 0" /></svg>
<span class="dock__label">プロフィール</span>
</a>
</li>
<!-- 区切りの線の後ろに置く項目 -->
<li class="dock__item dock__item--end">
<a class="dock__link" href="/contact/">
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3.5" y="5.5" width="17" height="13" rx="2" /><path d="m4 7 8 6 8-6" /></svg>
<span class="dock__label">お問い合わせ</span>
</a>
</li>
</ul>
</nav>
.dock {
--base: 56px; /* アイコンのふだんの大きさ */
--max: 104px; /* いちばん大きくなったときの大きさ */
--gap: 12px;
--tag: #ffffff; /* 名前の札と、今いるページの点 */
--ink: #262626; /* アイコンの線と、名前の札の文字 */
--bar: rgb(17 17 17 / 0.42); /* Dock の帯 */
--line: rgb(255 255 255 / 0.2);
--tile-line: rgb(38 38 38 / 0.12); /* 明るいページの上でも、アイコンと札の縁が見えるように */
position: fixed;
left: 50%;
bottom: 24px;
z-index: 100;
transform: translateX(-50%);
}
.dock__list {
display: flex;
gap: var(--gap);
margin: 0;
padding: 12px;
list-style: none;
border-radius: 24px;
background: var(--bar);
box-shadow: inset 0 0 0 1px var(--line);
backdrop-filter: blur(16px);
}
/* 項目の高さは、ふだんの大きさで止める。大きくなったアイコンは下端をそろえたまま、帯の上へはみ出す */
.dock__item { display: flex; align-items: flex-end; height: var(--base); }
/* 区切りの線 */
.dock__item--end { position: relative; margin-left: calc(var(--gap) + 1px); }
.dock__item--end::before { content: ""; position: absolute; top: 8px; bottom: 8px; left: calc(var(--gap) * -1 - 1px); width: 1px; background: var(--line); }
/* 幅と高さは --size(JS が距離から決める)。scale ではないので、隣のアイコンが押し広げられる */
.dock__link {
position: relative;
display: grid;
place-items: center;
width: var(--size, var(--base));
height: var(--size, var(--base));
border-radius: 24%;
background: var(--tile, var(--tag));
box-shadow: inset 0 0 0 1px var(--tile-line);
color: var(--ink);
}
/* アイコンの地。暗い帯の上なので、明るめのくすんだ色にする(線は墨色) */
.dock__item:nth-child(1) .dock__link { --tile: #8cb3d0; }
.dock__item:nth-child(2) .dock__link { --tile: #9bcc97; }
.dock__item:nth-child(3) .dock__link { --tile: #ddba8e; }
.dock__item:nth-child(4) .dock__link { --tile: #ac9ad5; }
.dock__item:nth-child(5) .dock__link { --tile: #dd918c; }
.dock__item:nth-child(6) .dock__link { --tile: #d5c976; }
/* viewBox 24 をふだん 28px で描くので、線 2px は stroke-width 1.71。大きくなると線も一緒に太る */
.dock__link svg {
width: 50%;
height: 50%;
fill: none;
stroke: currentColor;
stroke-width: 1.71;
stroke-linecap: round;
stroke-linejoin: round;
}
/* 今いるページの点 */
.dock__link[aria-current="page"]::after { content: ""; position: absolute; left: 50%; bottom: -8px; width: 4px; height: 4px; margin-left: -2px; border-radius: 50%; background: var(--tag); }
/* 名前の札。ふだんは透明にしておくだけ(display: none にすると、リンクの名前が消える) */
.dock__label {
position: absolute;
left: 50%;
bottom: calc(100% + 12px);
padding: 0 12px;
border-radius: 4px;
background: var(--tag);
box-shadow: 0 0 0 1px var(--tile-line);
font-size: 13px;
font-weight: 600;
line-height: 32px;
letter-spacing: 0.04em;
white-space: nowrap;
translate: -50% 0;
opacity: 0;
pointer-events: none;
}
.dock__link:hover .dock__label,
.dock__link:focus-visible .dock__label { opacity: 1; }
/* 焦点の枠は白と墨の 2 重にして、明るいページでも暗い帯の上でも見えるようにする */
.dock__link:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; box-shadow: 0 0 0 4px var(--tag); }
/* 狭い画面では、並びが画面に収まる大きさにする */
@media (max-width: 480px) {
.dock { --base: 44px; --max: 72px; --gap: 8px; }
.dock__list { padding: 8px; border-radius: 20px; }
}
import { gsap } from "gsap";
const RANGE = 180; // 拡大が届く半径(px)。アイコン 3 つ分ほど
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const canHover = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
// 動きを減らす設定と、指で触る端末では拡大しない(名前の札とフォーカスの枠だけにする)
if (!reduceMotion && canHover) {
document.querySelectorAll(".dock").forEach((dock) => {
const list = dock.querySelector(".dock__list");
const links = [...dock.querySelectorAll(".dock__link")];
const css = getComputedStyle(dock);
const base = parseFloat(css.getPropertyValue("--base")); // ふだんの大きさ
const max = parseFloat(css.getPropertyValue("--max")); // いちばん大きくなったときの大きさ
// 距離は、拡大していないときの各アイコンの中心から測る。拡大して動いた位置から測ると、拡大が次の拡大を呼んで揺れる
let centers = [];
const measure = () => {
centers = links.map((link) => {
const box = link.getBoundingClientRect();
return box.left + box.width / 2;
});
};
// x は拡大の中心、power は拡大の強さ(0〜1)。変わるたびに、各アイコンの大きさを CSS 変数で渡す
const state = { x: 0, power: 0 };
const render = () => {
links.forEach((link, i) => {
const d = Math.abs(state.x - centers[i]);
const k = d < RANGE ? (1 + Math.cos((Math.PI * d) / RANGE)) / 2 : 0; // 近いほど 1、半径の外は 0
link.style.setProperty("--size", `${base + (max - base) * k * state.power}px`);
});
};
const followX = gsap.quickTo(state, "x", { duration: 0.12, ease: "power3", onUpdate: render });
const power = (to) => gsap.to(state, { power: to, duration: 0.25, ease: "power2.out", overwrite: "auto", onUpdate: render });
// 拡大していないときだけ測り直す(リサイズで位置が変わっても合う)
const start = (x) => {
if (state.power < 0.05) {
measure();
gsap.set(state, { x });
}
power(1);
};
// 大きくなって帯の上へはみ出したアイコンも list の中なので、その上にいるあいだは出た扱いにならない
list.addEventListener("pointerenter", (event) => start(event.clientX));
list.addEventListener("pointermove", (event) => followX(event.clientX));
list.addEventListener("pointerleave", () => power(0));
// キーボードで移ったときは、焦点のあるアイコンを中心に広げる(マウスで押したときの焦点は除く)
dock.addEventListener("focusin", (event) => {
if (!event.target.matches(":focus-visible")) return;
if (state.power < 0.05) measure();
const x = centers[links.indexOf(event.target)];
start(x);
followX(x);
});
dock.addEventListener("focusout", (event) => {
if (!dock.contains(event.relatedTarget)) power(0);
});
});
}