patterns
Nav Underline Indicator
ナビ下線がスッと移動する。サイトの品位が上がる。
navigationcss+js
Customize
--mk-nav-duration / --mk-nav-underline-height など。
A11y
aria-current 等は既存のナビ実装で管理してください。
Install
“壊れにくさ”優先のため、依存は増やさずに ファイルをコピーして使う 形式です。
Files
まずは下記ファイルをプロジェクトに配置してください。
required-files.md
- tailwind-motion-kit/css/00_tokens.css (css)
- tailwind-motion-kit/css/15_nav-indicator.css (css)
- tailwind-motion-kit/js/nav-indicator.js (js)Option A
まずは Bundle を読み込んで、動作を確認するのがおすすめです。
globals.css
/* Bundle: まずはこれが一番簡単 */
@import "./tailwind-motion-kit/css/99_bundle.css";Option B
必要な CSS だけ読み込みたい場合はこちら。
globals.css
/* 1) CSS: グローバルCSSに追記 */
@import "./tailwind-motion-kit/css/00_tokens.css";
@import "./tailwind-motion-kit/css/15_nav-indicator.css";JavaScript
動きやUI制御が必要な場合のみ、初期化を追加します。
init.js
import { initNavIndicator } from "./tailwind-motion-kit/js/nav-indicator.js";
initNavIndicator();Snippets
Copy & paste
usage.html
<nav class="mk-nav-indicator" data-mk-nav-indicator>
<a class="mk-nav-item is-active" href="#">Home</a>
<a class="mk-nav-item" href="#">About</a>
<span class="mk-nav-underline" aria-hidden="true"></span>
</nav>usage.js
import { initNavIndicator } from "./tailwind-motion-kit/js/nav-indicator.js";
initNavIndicator();Files
この部品が参照する実ファイルです。
tailwind-motion-kit/css/00_tokens.csscss
tailwind-motion-kit/css/00_tokens.css
/*!
* Tailwind Motion Kit (mk) - Tokens
* --------------------------------
* 目的: 速度・距離・イージング等の“モーション言語”を統一し、
* パーツを増やしても品位が崩れない状態を作ります。
*
* 使い方:
* - :root の変数を上書きするだけで全体の動きを一括調整できます。
* - 個別要素は style="" か data-mk-* 属性で上書きできます(JSがある場合)。
*/
:root {
/* --- イージング(加減速) --- */
--mk-ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--mk-ease-in: cubic-bezier(0.32, 0, 0.67, 0);
--mk-ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
/* “少しだけ遊び”が欲しい時用(多用は上品さを損ねます) */
--mk-ease-out-back: cubic-bezier(0.34, 1.56, 0.64, 1);
/* --- 時間 --- */
--mk-duration-fast: 160ms;
--mk-duration-medium: 280ms;
--mk-duration-slow: 600ms;
--mk-duration-slower: 900ms;
/* コンポーネントが参照する“共通デフォルト” */
--mk-duration: var(--mk-duration-medium);
--mk-ease: var(--mk-ease-out);
--mk-delay: 0ms;
/* --- 移動量 --- */
--mk-distance-1: 6px;
--mk-distance-2: 12px;
--mk-distance-3: 20px;
--mk-distance: var(--mk-distance-2);
/* --- ぼかし --- */
--mk-blur-0: 0px;
--mk-blur-1: 6px;
--mk-blur-2: 10px;
--mk-blur: var(--mk-blur-1);
/* --- 影(浮遊感) --- */
--mk-shadow-lift: 0 12px 30px rgba(0, 0, 0, 0.14);
--mk-shadow-soft: 0 10px 22px rgba(0, 0, 0, 0.10);
/* --- アンダーライン --- */
--mk-underline-thickness: 2px;
--mk-underline-offset: 0.2em;
/* --- フォーカスリング --- */
--mk-ring-color: rgba(59, 130, 246, 0.35);
--mk-ring-size: 3px;
/* --- スケルトン(ロード中) --- */
--mk-skeleton-a: rgba(0, 0, 0, 0.06);
--mk-skeleton-b: rgba(0, 0, 0, 0.12);
--mk-skeleton-speed: 1250ms;
/* --- スピナー --- */
--mk-spinner-size: 1.2em;
--mk-spinner-border: 2px;
/* --- テキスト分割表示 --- */
--mk-char-step: 25ms; /* 1文字ごとの遅延 */
/* --- ダイアログ / ドロワー --- */
--mk-dialog-duration: var(--mk-duration-slow);
--mk-dialog-ease: var(--mk-ease-out);
--mk-dialog-backdrop-opacity: 0.45;
--mk-dialog-offset: var(--mk-distance-3);
--mk-dialog-scale-from: 0.995;
--mk-dialog-radius: 18px;
/* --- 折りたたみ(アコーディオン) --- */
--mk-collapse-duration: var(--mk-duration-medium);
--mk-collapse-ease: var(--mk-ease);
/* --- Toast / 通知 --- */
--mk-toast-gap: 10px;
--mk-toast-width: min(360px, calc(100vw - 24px));
--mk-toast-duration: var(--mk-duration-medium);
--mk-toast-ease: var(--mk-ease-out);
/* --- スクロール進捗(トップバー) --- */
--mk-scroll-progress-top: 0px;
--mk-scroll-progress-height: 3px;
--mk-scroll-progress-ease: linear;
/* --- ナビ下線インジケータ --- */
--mk-nav-underline-height: 2px;
--mk-nav-underline-radius: 999px;
--mk-nav-duration: var(--mk-duration-medium);
--mk-nav-ease: var(--mk-ease-out);
/* --- UI state(idle/loading/success/error) --- */
--mk-state-hold-success: 900ms;
--mk-state-hold-error: 1400ms;
/* --- Tabs --- */
--mk-tabs-gap: 10px;
--mk-tabs-radius: 999px;
--mk-tabs-duration: var(--mk-duration-medium);
--mk-tabs-ease: var(--mk-ease-out);
--mk-tabs-underline-height: 2px;
--mk-tabs-underline-color: currentColor;
/* --- Tooltip --- */
--mk-tooltip-bg: rgba(0, 0, 0, 0.82);
--mk-tooltip-fg: #fff;
--mk-tooltip-radius: 10px;
--mk-tooltip-padding: 10px 12px;
--mk-tooltip-gap: 8px;
--mk-tooltip-shadow: 0 18px 60px rgba(0,0,0,0.35);
--mk-tooltip-max-width: 260px;
/* --- Stepper --- */
--mk-stepper-gap: 14px;
--mk-step-size: 28px;
--mk-step-line: rgba(255, 255, 255, 0.14);
--mk-step-done: rgba(59, 130, 246, 1);
--mk-step-current: rgba(255, 255, 255, 0.92);
--mk-step-todo: rgba(255, 255, 255, 0.5);
}
/* motion減衰設定のあるユーザーには、動きを最小化して“結果”を出します */
@media (prefers-reduced-motion: reduce) {
:root {
--mk-duration-fast: 0ms;
--mk-duration-medium: 0ms;
--mk-duration-slow: 0ms;
--mk-duration-slower: 0ms;
--mk-duration: 0ms;
--mk-delay: 0ms;
}
}
tailwind-motion-kit/css/15_nav-indicator.csscss
tailwind-motion-kit/css/15_nav-indicator.css
/*!
* Tailwind Motion Kit (mk) - Nav Indicator
* ---------------------------------------
* 目的: ナビゲーションの下線が “スッ” と移動する、上質な定番演出。
*
* マークアップ:
* <nav class="mk-nav-indicator" data-mk-nav-indicator>
* <a class="mk-nav-item is-active" href="/">Home</a>
* <a class="mk-nav-item" href="/projects">Projects</a>
* <span class="mk-nav-underline" aria-hidden="true"></span>
* </nav>
*
* JS:
* import { initNavIndicator } from "./js/nav-indicator.js";
* initNavIndicator();
*/
.mk-nav-indicator {
position: relative;
--mk-nav-x: 0px;
--mk-nav-w: 0px;
}
.mk-nav-underline {
position: absolute;
left: 0;
bottom: 0;
height: var(--mk-nav-underline-height);
width: var(--mk-nav-w);
border-radius: var(--mk-nav-underline-radius);
background: currentColor;
opacity: 0.55;
transform: translate3d(var(--mk-nav-x), 0, 0);
will-change: transform, width;
transition:
transform var(--mk-nav-duration) var(--mk-nav-ease),
width var(--mk-nav-duration) var(--mk-nav-ease),
opacity var(--mk-nav-duration) var(--mk-nav-ease);
pointer-events: none;
}
/* 初期測定が終わるまで隠す(チラつき防止) */
.mk-nav-indicator:not([data-mk-nav-ready="true"]) .mk-nav-underline {
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.mk-nav-underline {
transition: none !important;
}
}
tailwind-motion-kit/js/nav-indicator.jsjs
tailwind-motion-kit/js/nav-indicator.js
// Tailwind Motion Kit (mk) - Nav Indicator
// ---------------------------------------
// ナビの“下線”をアクティブ項目の下へ移動させます。
// - container: [data-mk-nav-indicator]
// - items: ".mk-nav-item, a, button" (default)
// - active: ".is-active" / [aria-current] / [data-mk-active="true"]
function itemSelectorFrom(nav) {
return nav.getAttribute("data-mk-nav-items") || ".mk-nav-item, a, button";
}
function findUnderline(nav) {
let u = nav.querySelector(".mk-nav-underline");
if (u) return u;
u = document.createElement("span");
u.className = "mk-nav-underline";
u.setAttribute("aria-hidden", "true");
nav.appendChild(u);
return u;
}
function findActive(nav, items) {
const current = nav.querySelector("[aria-current='page'],[aria-current='true'],[data-mk-active='true'],.is-active");
if (current) return current;
return items[0] || null;
}
function measure(nav, target) {
if (!nav || !target) return;
const navRect = nav.getBoundingClientRect();
const tRect = target.getBoundingClientRect();
const x = tRect.left - navRect.left;
const w = tRect.width;
nav.style.setProperty("--mk-nav-x", `${x}px`);
nav.style.setProperty("--mk-nav-w", `${w}px`);
nav.setAttribute("data-mk-nav-ready", "true");
}
export function initNavIndicator({
selector = "[data-mk-nav-indicator]",
follow = "hover", // "hover" | "active-only"
} = {}) {
const navs = Array.from(document.querySelectorAll(selector));
const cleanups = [];
navs.forEach((nav) => {
findUnderline(nav);
const items = Array.from(nav.querySelectorAll(itemSelectorFrom(nav)));
if (items.length === 0) return;
let active = findActive(nav, items);
measure(nav, active);
const updateActive = (el) => {
active = el;
items.forEach((it) => it.classList.toggle("is-active", it === active));
measure(nav, active);
};
const onEnter = (e) => {
if (follow !== "hover") return;
const el = e.target.closest(itemSelectorFrom(nav));
if (el && nav.contains(el)) measure(nav, el);
};
const onLeave = () => {
if (follow !== "hover") return;
measure(nav, active);
};
const onClick = (e) => {
const el = e.target.closest(itemSelectorFrom(nav));
if (el && nav.contains(el)) {
// aria-current がある場合は尊重したいので、基本は class だけ更新
updateActive(el);
}
};
nav.addEventListener("pointerenter", onEnter, true);
nav.addEventListener("pointermove", onEnter, true);
nav.addEventListener("pointerleave", onLeave);
nav.addEventListener("focusin", onEnter);
nav.addEventListener("focusout", onLeave);
nav.addEventListener("click", onClick);
let ro = null;
const refresh = () => measure(nav, active);
if ("ResizeObserver" in window) {
ro = new ResizeObserver(refresh);
ro.observe(nav);
items.forEach((it) => ro.observe(it));
} else {
window.addEventListener("resize", refresh);
}
cleanups.push(() => {
nav.removeEventListener("pointerenter", onEnter, true);
nav.removeEventListener("pointermove", onEnter, true);
nav.removeEventListener("pointerleave", onLeave);
nav.removeEventListener("focusin", onEnter);
nav.removeEventListener("focusout", onLeave);
nav.removeEventListener("click", onClick);
if (ro) ro.disconnect();
else window.removeEventListener("resize", refresh);
});
});
return {
destroy() { cleanups.forEach((fn) => fn()); },
};
}
Preview
hover / click で下線が移動します。
Tips
- • デザイン側は CSS 変数(tokens)で一括調整できます。
- • 動きは “短く・小さく・同じ癖” を優先すると品位が保てます。
- • prefers-reduced-motion を必ず考慮してください。
