デモ
デモを読み込めませんでした。
仕様
- 用途
- ナビゲーション
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
項目ごとに中身の大きさが違うドロップダウンを、1 枚の白い板で見せます。隣の項目へ移るとき、板は閉じずに、次の中身の幅・高さ・位置へ補間しながら滑り、上の三角も項目の真下へ動きます。中身は移る向きに少しずれながら入れ替わり、右の項目へ移ると、前の中身は左へ抜け、次の中身は右から入ります。
板の補間は 0.45 秒の power3.out です。前の中身は 0.12 秒で消し、消えきってから次の中身を 40px 横から入れます。2 つの中身を重ねたままフェードすると、途中で文が混ざって読めない瞬間が出ます。板は中身より先に動き出すので、大きな中身も、広がっていく板の中から現れて見えます。
中身はすべて板の中に重ねて置き、板の overflow: hidden ではみ出しを切ります。中身の大きさは隠したままでも測れるよう、display ではなく visibility で隠します。項目から板へ下りるあいだに閉じないよう、ナビと板のすきまは透明な余白でつなぎ、ナビから出ても 0.12 秒待ってから閉じます。
中身は DOM ではメニューの後ろにあるので、キーボードでの移動を補います。開いた項目で Tab を押すと中身の最初のリンクへ入り、最後のリンクから Tab で次の項目へ戻ります。Esc で閉じて項目に焦点を戻し、タッチでは押して開け閉めします。項目のボタンには aria-expanded を付けて、開いているかを伝えます。
板の ease を expo.out にすると、吸い付くように止まります。中身のずれを小さくすると落ち着いた印象に、大きくすると移る向きがはっきりします。動きを減らす設定では、補間せずにその場で切り替えます。製品・導入事例・開発者向け・会社案内のように、中身の量が違うメニューを並べるヘッダーに向いています。
コード
<!-- 板(plate)は 1 枚だけ置き、項目ごとの中身(pane)をその中に重ねる。中身は aria-controls で項目のボタンと結ぶ。
隠れている中身は visibility: hidden なので、タブ移動と読み上げから外れる -->
<nav class="morph-nav" aria-label="メイン">
<ul class="morph-nav__menu">
<li><button class="morph-nav__trigger" type="button" aria-expanded="false" aria-controls="menu-products">製品</button></li>
<li><button class="morph-nav__trigger" type="button" aria-expanded="false" aria-controls="menu-solutions">ソリューション</button></li>
<li><button class="morph-nav__trigger" type="button" aria-expanded="false" aria-controls="menu-developers">開発者向け</button></li>
<li><button class="morph-nav__trigger" type="button" aria-expanded="false" aria-controls="menu-company">会社情報</button></li>
</ul>
<div class="morph-nav__dropdown">
<span class="morph-nav__caret" aria-hidden="true"></span>
<div class="morph-nav__plate">
<div class="morph-nav__pane morph-nav__pane--grid" id="menu-products">
<a href="/payments/"><strong>オンライン決済</strong><span>ここに説明文が入ります。</span></a>
<a href="/billing/"><strong>定期課金</strong><span>ここに説明文が入ります。</span></a>
<a href="/connect/"><strong>プラットフォーム決済</strong><span>ここに説明文が入ります。</span></a>
<a href="/terminal/"><strong>店頭決済</strong><span>ここに説明文が入ります。</span></a>
</div>
<div class="morph-nav__pane" id="menu-solutions">
<p class="morph-nav__label">事業の規模から探す</p>
<a href="/startups/"><strong>スタートアップ</strong><span>ここに説明文が入ります。</span></a>
<a href="/enterprise/"><strong>大企業</strong><span>ここに説明文が入ります。</span></a>
<p class="morph-nav__label">業種から探す</p>
<a href="/ecommerce/"><strong>ネットショップ</strong></a>
<a href="/saas/"><strong>SaaS</strong></a>
<a href="/marketplaces/"><strong>マーケットプレイス</strong></a>
</div>
<div class="morph-nav__pane" id="menu-developers">
<a href="/docs/"><strong>ドキュメント</strong><span>ここに説明文が入ります。</span></a>
<a href="/api/"><strong>APIリファレンス</strong><span>ここに説明文が入ります。</span></a>
<a href="/changelog/"><strong>更新履歴</strong><span>ここに説明文が入ります。</span></a>
</div>
<div class="morph-nav__pane" id="menu-company">
<a href="/company/"><strong>会社概要</strong></a>
<a href="/customers/"><strong>導入事例</strong></a>
<a href="/careers/"><strong>採用情報</strong></a>
<a href="/blog/"><strong>ブログ</strong></a>
</div>
</div>
</div>
</nav>
.morph-nav {
--surface: #ffffff; /* 板 */
--ink: #262626; /* 文字 */
--mute: #6b6b6b; /* 説明・ラベル */
--line: rgba(38, 38, 38, 0.12); /* 板の縁 */
--gap: 12px; /* ナビと板のあいだ */
position: relative; /* 項目の offsetLeft をナビからの距離にする */
height: 48px;
color: var(--ink);
font-size: 14px;
font-weight: 600;
letter-spacing: 0.04em;
}
.morph-nav__menu { display: flex; height: 100%; margin: 0; padding: 0; list-style: none; }
.morph-nav__trigger {
display: flex;
align-items: center;
gap: 6px;
height: 100%; /* 当たり判定をナビの高さいっぱいにする */
padding: 0 14px;
border: 0;
background: none;
color: inherit;
font: inherit;
letter-spacing: inherit;
white-space: nowrap;
cursor: pointer;
}
/* 下向きの矢印 */
.morph-nav__trigger::after {
content: "";
width: 5px;
height: 5px;
border: solid currentColor;
border-width: 0 1.5px 1.5px 0;
transform: translateY(-2px) rotate(45deg);
}
.morph-nav__trigger:focus-visible,
.morph-nav__pane a:focus-visible {
outline: 2px solid var(--ink);
outline-offset: -2px;
border-radius: 4px;
}
/* 板と三角の入れ物。上の余白は、ナビから板へ下りるあいだにポインタが外へ出ないための橋 */
.morph-nav__dropdown {
position: absolute;
top: 100%;
left: 0;
z-index: 10;
padding-top: var(--gap);
visibility: hidden;
opacity: 0;
}
/* 1 枚の板。幅・高さはスクリプトが中身に合わせて補間し、はみ出した中身は切る */
.morph-nav__plate {
position: relative;
overflow: hidden;
border-radius: 4px;
background: var(--surface);
box-shadow: inset 0 0 0 1px var(--line);
}
/* 上の 2 辺に縁を引いた正方形を 45° 回し、下半分で板の縁の線を隠す */
.morph-nav__caret {
position: absolute;
top: var(--gap);
left: -7px;
z-index: 1;
width: 14px;
height: 14px;
margin-top: -7px;
border: solid var(--line);
border-width: 1px 0 0 1px;
border-top-left-radius: 2px;
background: var(--surface);
transform: rotate(45deg);
}
.morph-nav__pane { position: absolute; top: 0; left: 0; width: max-content; max-width: calc(100vw - 32px); padding: 20px 24px; box-sizing: border-box; visibility: hidden; }
.morph-nav__pane--grid { display: grid; grid-template-columns: repeat(2, auto); column-gap: 32px; }
.morph-nav__pane a { display: block; padding: 8px 0; border-radius: 4px; color: inherit; text-decoration: none; }
.morph-nav__pane a:hover strong { text-decoration: underline; text-underline-offset: 3px; }
.morph-nav__pane strong { font-size: 15px; font-weight: 700; letter-spacing: -0.01em; }
.morph-nav__pane span { display: block; margin-top: 2px; color: var(--mute); font-size: 13px; font-weight: 400; word-break: auto-phrase; }
.morph-nav__label { margin: 8px 0 4px; color: var(--mute); font-size: 12px; font-weight: 700; letter-spacing: 0.06em; }
@media (max-width: 600px) {
.morph-nav__trigger { gap: 4px; padding: 0 6px; font-size: 13px; letter-spacing: 0.02em; }
.morph-nav__pane--grid { grid-template-columns: 1fr; }
}
import { gsap } from "gsap";
// 動きを減らす設定では、時間を 0 にしてその場で切り替える
const D = window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 1;
const SHIFT = 40; // 中身が入れ替わるときの横のずれ(px)
const EASE = "power3.out";
document.querySelectorAll(".morph-nav").forEach((nav) => {
const triggers = [...nav.querySelectorAll(".morph-nav__trigger")];
const dropdown = nav.querySelector(".morph-nav__dropdown");
const plate = nav.querySelector(".morph-nav__plate");
const caret = nav.querySelector(".morph-nav__caret");
const panes = triggers.map((trigger) => document.getElementById(trigger.getAttribute("aria-controls")));
let current = -1; // 開いている項目
let hovered = false; // ポインタを乗せて開いたか(乗せて開いた直後の click では閉じない)
let closing = null;
// 項目 i の真下に置く板の位置と大きさ(ナビの幅からはみ出さない)。三角の位置は板の左端から
const place = (i) => {
const w = panes[i].offsetWidth;
const center = triggers[i].offsetLeft + triggers[i].offsetWidth / 2;
const x = Math.max(0, Math.min(center - w / 2, nav.clientWidth - w));
return { x, w, h: panes[i].offsetHeight, caret: center - x };
};
const show = (i) => {
closing?.kill();
if (i === current) return;
const to = place(i);
const from = current;
// 待っている古い動き(遅れて始まる入れ替えなど)を止めてから、新しい動きを積む
gsap.killTweensOf([dropdown, plate, caret, ...panes]);
if (from < 0) {
// 閉じていたら、板はその場の大きさで開く
gsap.set(panes, { autoAlpha: 0, x: 0 });
gsap.set(dropdown, { x: to.x });
gsap.set(plate, { width: to.w, height: to.h });
gsap.set(caret, { x: to.caret });
gsap.to(panes[i], { autoAlpha: 1, duration: 0.2 * D });
gsap.fromTo(dropdown, { y: -10 }, { autoAlpha: 1, y: 0, duration: 0.3 * D, ease: EASE });
} else {
// 板は 1 枚のまま、次の位置と大きさへ。中身は移る向きにずれながら、前のものが消えてから入れ替わる
const dir = Math.sign(i - from);
gsap.to(dropdown, { autoAlpha: 1, x: to.x, y: 0, duration: 0.45 * D, ease: EASE });
gsap.to(plate, { width: to.w, height: to.h, duration: 0.45 * D, ease: EASE });
gsap.to(caret, { x: to.caret, duration: 0.45 * D, ease: EASE });
gsap.to(panes[from], { autoAlpha: 0, duration: 0.12 * D, ease: "power1.out" });
gsap.to(panes[from], { x: -dir * SHIFT, duration: 0.3 * D, ease: "power2.in" });
gsap.fromTo(panes[i], { autoAlpha: 0, x: dir * SHIFT }, { autoAlpha: 1, x: 0, duration: 0.45 * D, delay: 0.12 * D, ease: EASE });
}
triggers.forEach((trigger, k) => trigger.setAttribute("aria-expanded", String(k === i)));
current = i;
};
const hide = () => {
if (current < 0) return;
triggers[current].setAttribute("aria-expanded", "false");
current = -1;
gsap.to(dropdown, { autoAlpha: 0, y: -6, duration: 0.2 * D, ease: "power2.in" });
};
triggers.forEach((trigger, i) => {
// ポインタは乗せただけで開く。タッチとキーボードは押して開け閉めする
trigger.addEventListener("pointerenter", (event) => {
hovered = event.pointerType === "mouse";
if (hovered) show(i);
});
trigger.addEventListener("click", () => {
current === i && !hovered ? hide() : show(i);
hovered = false;
});
// 開いた項目から Tab を押すと、その中身へ入る(中身は DOM ではメニューの後ろにある)
trigger.addEventListener("keydown", (event) => {
if (event.key !== "Tab" || event.shiftKey || current !== i) return;
event.preventDefault();
panes[i].querySelector("a")?.focus();
});
// 中身の端から Tab で出るときは、項目の並びへ戻す
panes[i].addEventListener("keydown", (event) => {
const links = panes[i].querySelectorAll("a");
const edge = event.shiftKey ? links[0] : links[links.length - 1];
const next = event.shiftKey ? trigger : triggers[i + 1];
if (event.key !== "Tab" || document.activeElement !== edge || !next) return;
event.preventDefault();
if (!event.shiftKey) hide();
next.focus();
});
});
// 出てすぐには閉じない(斜めに板へ移るあいだに閉じないよう、0.12 秒待つ)
nav.addEventListener("pointerleave", (event) => {
hovered = false;
if (event.pointerType === "mouse") closing = gsap.delayedCall(0.12, hide);
});
nav.addEventListener("pointerenter", () => closing?.kill());
nav.addEventListener("keydown", (event) => {
if (event.key !== "Escape" || current < 0) return;
triggers[current].focus(); // 焦点を項目に戻してから閉じる
hide();
});
nav.addEventListener("focusout", (event) => !nav.contains(event.relatedTarget) && hide());
document.addEventListener("pointerdown", (event) => !nav.contains(event.target) && hide());
});