デモ
デモを読み込めませんでした。
仕様
- 用途
- ナビゲーション
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
ナビの「機能」にマウスを乗せると、パネルが少し上から降りて開き、中の項目が上から順に落ちてきて小さく弾みます。矢印は 180° 回り、「機能」の文字は明るい色に変わります。開閉は 1 本のタイムラインにまとめてあり、マウスが出ると同じ動きを 1.6 倍の速さで逆再生するので、項目が下の段から消えてからパネルが上がります。
パネルは 0.35 秒の power3.out で、14px 上から降ろしながら 0.97 倍から戻します。起点をパネルの左上寄り(横 20%・上端)にしておくと、「機能」の下から広がって見えます。項目は 0.08 秒後から 0.06 秒ずつずらし、18px 上から back.out(1.6) で落とします。弾みを強くしすぎると、行が隣の行に重なって見えます。
ボタンとパネルは同じ li に入れ、パネルの上の余白をナビとのあいだの橋にします。こうすると、ボタンからパネルへ下りるあいだにマウスが外へ出ず、途中で閉じません。斜めに移ると一瞬外へ出ることがあるので、出てから 0.12 秒待って閉じます。
ホバーの無い指の端末とキーボードでは、ボタンを押して開け閉めします。開いているかは aria-expanded で伝え、開いたら Tab で中のリンクへ進めます。Esc で閉じてボタンに焦点を戻し、Tab でメニューの外へ出たときや、画面のほかの所を押したときも閉じます。このデモはマウスを乗せて試せます(キーボードと指の操作は、コードのほうで受けます)。
ずらす間隔を 0.04 秒ほどに詰めると、項目の多いメニューでも待たされません。乗った行の淡い地と矢印は CSS の transition で出し、動きを減らす設定では、開閉を 20 倍の速さにしてほぼ一瞬で切り替えます。ヘッダーの製品メニュー、アカウントメニュー、並べ替えの選択肢などに向いています。
コード
<header class="drop-header">
<a class="drop-header__logo" href="/">Hi! I'm HIGASHIYUKI®</a>
<nav class="drop-nav" aria-label="メイン">
<ul class="drop-nav__menu">
<!-- ボタンとパネルを同じ li に入れる。パネルへ下りるあいだも li の中なので、途中で閉じない -->
<li class="drop-nav__item">
<button class="drop-nav__trigger" type="button" aria-expanded="false" aria-controls="menu-features">
機能
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M6 9l6 6 6-6" /></svg>
</button>
<!-- 閉じている間は visibility: hidden なので、タブ移動と読み上げから外れる -->
<div class="drop-nav__panel" id="menu-features">
<ul class="drop-nav__list">
<li class="drop-nav__row">
<a class="drop-nav__link" href="/analytics/">
<svg class="drop-nav__icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M2 15V9M8 15V1M14 15V5" /></svg>
<span class="drop-nav__text"><strong>アクセス解析</strong><span>ここに説明文が入ります。</span></span>
<span class="drop-nav__arrow" aria-hidden="true">→</span>
</a>
</li>
<li class="drop-nav__row">
<a class="drop-nav__link" href="/automation/">
<svg class="drop-nav__icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><circle cx="8" cy="8" r="7" /></svg>
<span class="drop-nav__text"><strong>業務の自動化</strong><span>ここに説明文が入ります。</span></span>
<span class="drop-nav__arrow" aria-hidden="true">→</span>
</a>
</li>
<li class="drop-nav__row">
<a class="drop-nav__link" href="/integrations/">
<svg class="drop-nav__icon drop-nav__icon--dots" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M3 3h0M13 3h0M3 13h0M13 13h0" /></svg>
<span class="drop-nav__text"><strong>外部サービス連携</strong><span>ここに説明文が入ります。</span></span>
<span class="drop-nav__arrow" aria-hidden="true">→</span>
</a>
</li>
<li class="drop-nav__row">
<a class="drop-nav__link" href="/security/">
<svg class="drop-nav__icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><rect x="3" y="3" width="10" height="10" rx="1.5" transform="rotate(45 8 8)" /></svg>
<span class="drop-nav__text"><strong>セキュリティ</strong><span>ここに説明文が入ります。</span></span>
<span class="drop-nav__arrow" aria-hidden="true">→</span>
</a>
</li>
</ul>
</div>
</li>
<li><a href="/cases/">導入事例</a></li>
<li><a href="/pricing/">料金プラン</a></li>
<li><a href="/company/">会社概要</a></li>
</ul>
</nav>
</header>
.drop-header {
--ground: #111111; /* ヘッダーの地 */
--surface: #262626; /* パネルの面 */
--ink: #eeece6; /* 文字・開いている項目 */
--mute: #a8a6a0; /* ほかの項目・説明 */
--line: rgba(238, 236, 230, 0.14); /* パネルとアイコンの縁 */
--hover: rgba(238, 236, 230, 0.08); /* 乗った行の地 */
position: relative;
z-index: 10; /* 開いたパネルを、下のページより上に出す */
display: flex;
flex-wrap: wrap;
align-items: center;
column-gap: 40px;
padding: 32px clamp(16px, 7.5vw, 96px);
background-color: var(--ground);
color: var(--ink);
}
.drop-header a { color: inherit; text-decoration: none; }
.drop-header__logo { font-size: 18px; font-weight: 700; letter-spacing: 0.12em; white-space: nowrap; }
.drop-nav { color: var(--mute); font-size: 14px; font-weight: 600; letter-spacing: 0.04em; }
.drop-nav__menu, .drop-nav__list { margin: 0; padding: 0; list-style: none; }
.drop-nav__menu { display: flex; gap: 32px; height: 48px; white-space: nowrap; }
.drop-nav__menu > li { display: flex; align-items: center; }
.drop-nav__item { position: relative; } /* パネルの位置の基準 */
/* 当たり判定をナビの高さいっぱいにする(パネルとのあいだに隙間を作らない) */
.drop-nav__trigger {
display: flex;
align-items: center;
gap: 4px;
height: 100%;
padding: 0;
border: 0;
background: none;
color: inherit;
font: inherit;
letter-spacing: inherit;
cursor: pointer;
}
.drop-nav__trigger svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }
.drop-nav a:focus-visible,
.drop-nav__trigger:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; border-radius: 4px; }
/* 上の余白 8px は、ナビからパネルへ下りるあいだの橋。左は、アイコンの左端を「機能」の字にそろえる(8px + 16px) */
.drop-nav__panel {
position: absolute;
top: 100%;
left: -24px;
width: min(368px, calc(100vw - 32px));
padding-top: 8px;
transform-origin: 20% 8px;
visibility: hidden;
opacity: 0;
}
.drop-nav__list { padding: 8px; border-radius: 4px; background-color: var(--surface); box-shadow: inset 0 0 0 1px var(--line); color: var(--ink); }
.drop-nav__link {
display: grid;
grid-template-columns: 40px 1fr auto;
align-items: center;
column-gap: 16px;
padding: 12px 16px;
border-radius: 4px;
letter-spacing: 0;
white-space: normal;
transition: background-color 0.2s linear;
}
.drop-nav__link:hover,
.drop-nav__link:focus-visible { background-color: var(--hover); }
.drop-nav .drop-nav__link:focus-visible { outline-offset: -2px; }
/* 枠つきのアイコン。線は 2px、角と線端は丸く */
.drop-nav__icon { width: 40px; height: 40px; box-sizing: border-box; padding: 12px; overflow: visible; border-radius: 4px; box-shadow: inset 0 0 0 1px var(--line); fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.drop-nav__icon--dots { stroke-width: 6; } /* 長さ 0 の線を、丸い線端で点にする */
.drop-nav__text strong { display: block; font-size: 16px; font-weight: 700; line-height: 1.4; }
.drop-nav__text span { display: block; margin-top: 2px; color: var(--mute); font-size: 14px; font-weight: 400; line-height: 1.6; letter-spacing: 0.04em; }
/* 乗った行の矢印は、左から少しずれて入る */
.drop-nav__arrow { font-size: 16px; opacity: 0; transform: translateX(-10px); transition: opacity 0.25s, transform 0.25s cubic-bezier(0.33, 1, 0.68, 1); }
.drop-nav__link:hover .drop-nav__arrow,
.drop-nav__link:focus-visible .drop-nav__arrow { opacity: 1; transform: none; }
@media (max-width: 600px) {
.drop-header { padding: 24px 16px; }
.drop-nav { font-size: 13px; letter-spacing: 0.02em; }
.drop-nav__menu { gap: 20px; }
.drop-nav__panel { left: -8px; } /* 画面の左端で切れないように */
}
@media (prefers-reduced-motion: reduce) {
.drop-nav__link,
.drop-nav__arrow { transition: none; }
}
import { gsap } from "gsap";
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SPEED = reduceMotion ? 20 : 1; // 動きを減らす設定では、ほぼ一瞬で開け閉めする
const CLOSE_SPEED = 1.6; // 閉じるほうは速く
const CLOSE_DELAY = 0.12; // マウスが出てから閉じ始めるまで(秒)
document.querySelectorAll(".drop-nav__item").forEach((item) => {
const trigger = item.querySelector(".drop-nav__trigger");
const panel = document.getElementById(trigger.getAttribute("aria-controls"));
// 色は CSS が正。GSAP は var(--name) のままでは補間できないので、値にして渡す
const style = getComputedStyle(item);
const [mute, ink] = ["--mute", "--ink"].map((name) => style.getPropertyValue(name).trim());
// 開閉は 1 本のタイムライン。閉じるときは逆再生するので、項目は下の段から消えてからパネルが上がる
const open = gsap
.timeline({ paused: true })
.fromTo(panel, { autoAlpha: 0, y: -14, scale: 0.97 }, { autoAlpha: 1, y: 0, scale: 1, duration: 0.35, ease: "power3.out" }, 0)
// 項目は上から順に、少し上から落ちてきて小さく弾む
.fromTo(panel.querySelectorAll(".drop-nav__row"), { autoAlpha: 0, y: -18 }, { autoAlpha: 1, y: 0, duration: 0.4, ease: "back.out(1.6)", stagger: 0.06 }, 0.08)
.fromTo(trigger.querySelector("svg"), { rotation: 0 }, { rotation: 180, duration: 0.3, ease: "power2.out" }, 0)
.fromTo(trigger, { color: mute }, { color: ink, duration: 0.2, ease: "none" }, 0);
let isOpen = false;
let hovered = false; // マウスを乗せて開いたか(乗せて開いた直後の click では閉じない)
let closing = null;
const setOpen = (next) => {
closing?.kill();
if (next === isOpen) return;
isOpen = next;
trigger.setAttribute("aria-expanded", String(next));
if (next) open.timeScale(SPEED).play();
else open.timeScale(SPEED * CLOSE_SPEED).reverse();
};
// マウスは乗せただけで開き、出たら少し待って閉じる(斜めにパネルへ移るあいだに閉じない)
item.addEventListener("pointerenter", (event) => {
if (event.pointerType !== "mouse") return;
hovered = !isOpen;
setOpen(true);
});
item.addEventListener("pointerleave", (event) => {
hovered = false;
if (event.pointerType === "mouse") closing = gsap.delayedCall(CLOSE_DELAY, () => setOpen(false));
});
// 指とキーボードは押して開け閉めする
trigger.addEventListener("click", () => {
setOpen(hovered || !isOpen);
hovered = false;
});
// Esc で閉じて、焦点を項目のボタンへ戻す
item.addEventListener("keydown", (event) => {
if (event.key !== "Escape" || !isOpen) return;
trigger.focus();
setOpen(false);
});
// Tab で li の外へ出たら閉じる。指で外を押したときも閉じる
item.addEventListener("focusout", (event) => !item.contains(event.relatedTarget) && setOpen(false));
document.addEventListener("pointerdown", (event) => !item.contains(event.target) && setOpen(false));
});