デモ
デモを読み込めませんでした。
仕様
- 用途
- ナビゲーション
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
リンクを隙間なく並べた列の中に、角丸の地(pill)を 1 枚だけ重ね、ポインタが乗ったリンクの左右の縁へ向かわせます。行き先は、リンクの offsetLeft と offsetWidth を測って決めます。pill の上の文字は、リンクの列を複製して暗い色にし、pill と同じ形の clip-path で切り抜いて重ねるので、pill に掛かった所だけが反転して見えます。
左右の縁は別々のトゥイーンで動かし、進む側の縁は 0.28 秒、後ろの縁は 0.5 秒で着かせます(どちらも power3.out)。この差のぶんだけ、動いているあいだ pill が伸びて、ポインタについてくるように見えます。外から入ったときはリンクの中央から 0.34 秒で広げ、列から出たら、縁を 8px ずつ寄せながら 0.26 秒で消します。
リンクの間に隙間があると、隣へ渡る途中で列から出た扱いになり、pill が消えかけます。間は padding で取り、リンクを隙間なく並べてください。乗るたびに縁と濃さのトゥイーンを作り直すので、overwrite を auto にして、前のトゥイーンの同じ値を止めます。pill と複製には pointer-events の none を付け、重なっている下のリンクにポインタを通します。
キーボードで移ったときも、同じように pill を滑らせ、焦点が列の外へ出たら消します。複製したリンクには、焦点が移らず読み上げにも出ないよう、inert と aria-hidden を付けておきます。今いるページのリンクには aria-current を付け、文字を明るくして示します。
つまみは、進む側と後ろの縁の時間、広がる時間、出るときに寄せる量、pill の色です。2 つの時間の差を広げるほど、よく伸びます。乗っているあいだだけ出る地なので、選んだ状態を残す下線のようには使わず、ホバーの手応えとして使います。SaaS のヘッダーのナビ、フッターのリンクの列、絞り込みのセグメントに向いています。
コード
<header class="site-header">
<a class="site-header__logo" href="/">Hi! I'm HIGASHIYUKI®</a>
<nav class="pill-nav" aria-label="メイン">
<!-- pill はこの枠の中に重ねる。pill の上に載せる暗い文字の複製は、JS がリンクの列から作る -->
<div class="pill-nav__row">
<!-- リンクは隙間なく並べ、間は padding で取る(隙間があると、渡る途中で列から出た扱いになる) -->
<ul class="pill-nav__list">
<li><a href="/" aria-current="page">概要</a></li>
<li><a href="/features/">機能</a></li>
<li><a href="/integrations/">連携サービス</a></li>
<li><a href="/pricing/">料金プラン</a></li>
<li><a href="/docs/">ドキュメント</a></li>
</ul>
<span class="pill-nav__pill" aria-hidden="true"></span>
</div>
</nav>
<a class="site-header__cta" href="/signup/">無料で試す</a>
</header>
.site-header {
--bg: #111111;
--ink: #eeece6; /* 文字と、今のページのリンク */
--mute: #a8a6a0; /* ほかのリンク */
--line: rgba(238, 236, 230, 0.14); /* リンクを囲む枠の線 */
--pill: #eeece6; /* pill の地(主ボタンと同じ明暗の反転) */
--pill-ink: #111111; /* pill の上の文字 */
/* 3 列にして、リンクの枠をちょうど中央に置く */
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 24px;
padding: 32px clamp(24px, 7vw, 96px);
background: var(--bg);
color: var(--ink);
font-size: 14px;
font-weight: 600;
letter-spacing: 0.04em;
white-space: nowrap;
}
.site-header a { color: inherit; text-decoration: none; }
.site-header__logo { justify-self: start; font-size: 18px; font-weight: 700; letter-spacing: 0.12em; }
.site-header__cta {
justify-self: end;
height: 40px;
padding: 0 24px;
border-radius: 999px;
background: var(--ink);
line-height: 40px;
}
.site-header .site-header__cta { color: var(--bg); }
.pill-nav {
padding: 4px;
border-radius: 999px;
box-shadow: inset 0 0 0 1px var(--line);
}
.pill-nav__row { position: relative; }
.pill-nav__list {
--link: var(--mute);
--current: var(--ink);
display: flex;
margin: 0;
padding: 0;
list-style: none;
}
.pill-nav__list a { display: block; padding: 0 16px; line-height: 40px; color: var(--link); }
.pill-nav__list a[aria-current="page"] { color: var(--current); }
.pill-nav__list a:focus-visible { outline: 2px solid var(--ink); outline-offset: -2px; border-radius: 999px; }
/* pill と、pill の上だけに見せる暗い文字の複製。どちらも下のリンクへポインタを通す */
.pill-nav__pill,
.pill-nav__ink {
position: absolute;
pointer-events: none;
opacity: 0;
}
/* 左端に置いておき、x と width を JS が乗ったリンクに合わせる */
.pill-nav__pill {
top: 0;
left: 0;
width: 0;
height: 100%;
border-radius: 999px;
background: var(--pill);
}
/* 複製は列と同じ位置に重ね、リンクの色を pill の上の文字の色に替えるだけ。形は JS が pill と同じに切り抜く */
.pill-nav__ink {
inset: 0;
--link: var(--pill-ink);
--current: var(--pill-ink);
}
/* 並びきらない幅では、リンクの枠を 2 段目に下ろし、枠の中で横にスクロールさせる */
@media (max-width: 860px) {
.site-header { grid-template-columns: 1fr auto; gap: 16px; padding: 24px 16px; }
.site-header__cta { grid-area: 1 / 2; }
.pill-nav { grid-area: 2 / 1 / 3 / -1; overflow-x: auto; scrollbar-width: none; }
.pill-nav__row { width: max-content; }
}
@media (max-width: 420px) {
.site-header { column-gap: 12px; }
.site-header__logo { font-size: 15px; }
.site-header__cta { padding: 0 16px; }
}
import { gsap } from "gsap";
const LEAD = 0.28; // 進む側の縁が着くまで(秒)
const TRAIL = 0.5; // 後ろの縁が着くまで(秒)。LEAD との差が、動くあいだの伸びになる
const GROW = 0.34; // 外から入ったとき、リンクの中央から広がる時間(秒)
const SHRINK = 8; // 出るときに、縁を内へ寄せる量(px)
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
document.querySelectorAll(".pill-nav").forEach((nav) => {
const row = nav.querySelector(".pill-nav__row");
const list = row.querySelector(".pill-nav__list");
const pill = row.querySelector(".pill-nav__pill");
// pill の上だけに見せる暗い文字の複製。焦点が移らず読み上げにも出ないよう、inert にする
const ink = list.cloneNode(true);
ink.classList.add("pill-nav__ink");
ink.inert = true;
ink.setAttribute("aria-hidden", "true");
row.append(ink);
// 縁(左・右)と濃さを 1 つのオブジェクトに持ち、変わるたびに pill と複製を描き直す
const edge = { l: 0, r: 0, a: 0 };
const render = () => {
gsap.set(pill, { x: edge.l, width: edge.r - edge.l, opacity: edge.a });
gsap.set(ink, { clipPath: `inset(0 ${row.offsetWidth - edge.r}px 0 ${edge.l}px round 999px)`, opacity: edge.a });
};
// 縁と濃さは別々のトゥイーンで動かし、どれも今の値から向かい直す(overwrite: "auto" で、前の同じ値のトゥイーンを止める)。
// 動きを減らす設定では、時間を 0 にして一瞬で移す
const tween = (vars) => gsap.to(edge, { ...vars, duration: reduceMotion ? 0 : vars.duration, overwrite: "auto", onUpdate: render });
const show = (link) => {
const l = link.offsetLeft; // row の中での位置。transform の影響を受けない
const r = l + link.offsetWidth;
if (edge.a < 0.05) {
// 見えていなければ、そのリンクの中央から広げる
const center = (l + r) / 2;
gsap.set(edge, { l: center - 12, r: center + 12 });
tween({ l, duration: GROW, ease: "power3.out" });
tween({ r, duration: GROW, ease: "power3.out" });
} else {
const toRight = l > edge.l; // 右へ進むなら、右の縁が先に着く
tween({ l, duration: toRight ? TRAIL : LEAD, ease: "power3.out" });
tween({ r, duration: toRight ? LEAD : TRAIL, ease: "power3.out" });
}
tween({ a: 1, duration: 0.22, ease: "power1.out" });
};
// 列から出たら、その場で縁を寄せながら消す
const hide = () => tween({ l: edge.l + SHRINK, r: edge.r - SHRINK, a: 0, duration: 0.26, ease: "power2.out" });
list.querySelectorAll("a").forEach((link) => {
link.addEventListener("pointerenter", () => show(link));
link.addEventListener("focus", () => show(link)); // キーボードで移っても、同じように滑る
});
row.addEventListener("pointerleave", hide);
// 焦点が列の外へ出たときも消す(列の中のリンクへ移るあいだは消さない)
row.addEventListener("focusout", (event) => {
if (!row.contains(event.relatedTarget)) hide();
});
});