CSSとJSで作る、ガラス風UIの展開式Dockナビゲーション

アニメーション

CSSとJSで作る、ガラス風UIの展開式Dockナビゲーション

投稿日2026/04/02

更新日2026/4/1

Webサイトのナビゲーションを「ただのメニュー」ではなく、操作そのものを体験に変えるUIとして設計したDock型ナビゲーションです。
ガラス風の背景表現とスムーズなアニメーションにより、デスクトップアプリのような操作感を実現しています。

タブをクリックすると対応するパネルが展開し、カテゴリ・タグ・メニューなどを整理して表示可能。CSSによる視覚表現とJavaScriptによる状態管理を組み合わせた、実用性とデザイン性を両立したUIです。

Preview プレビュー

Code コード

<div class="kumonosu-wrapper">
	<aside class="kumonosu-dock" id="kumonosu-dock">
		<div class="kumonosu-panel-stack">
			<!-- メニューパネル -->
			<section class="kumonosu-panel" data-panel="menu">
				<div class="kumonosu-item">
					<div class="kumonosu-text-group">
						<div class="kumonosu-title">サイトについて</div>
						<div class="kumonosu-subtitle">活動内容やビジョンのご紹介</div>
					</div>
				</div>
				<div class="kumonosu-item">
					<div class="kumonosu-text-group">
						<div class="kumonosu-title">お問い合わせ</div>
						<div class="kumonosu-subtitle">フォームよりお気軽にご連絡ください</div>
					</div>
				</div>
			</section>
			<!-- カテゴリパネル -->
			<section class="kumonosu-panel" data-panel="category">
				<div class="kumonosu-item">
					<div class="kumonosu-icon-ghost"><i data-lucide="folder"></i></div>
					<div class="kumonosu-text-group">
						<div class="kumonosu-title">カテゴリ1</div>
						<div class="kumonosu-subtitle">最新のニュースとトピック</div>
					</div>
					<div class="kumonosu-tag">最新</div>
				</div>
				<div class="kumonosu-item">
					<div class="kumonosu-icon-ghost"><i data-lucide="folder"></i></div>
					<div class="kumonosu-text-group">
						<div class="kumonosu-title">カテゴリ2</div>
						<div class="kumonosu-subtitle">深く掘り下げた特集記事</div>
					</div>
				</div>
			</section>
			<!-- タグパネル -->
			<section class="kumonosu-panel" data-panel="tags">
				<div class="kumonosu-item">
					<div class="kumonosu-icon-ghost"><i data-lucide="hash"></i></div>
					<div class="kumonosu-text-group">
						<div class="kumonosu-title">タグ1</div>
						<div class="kumonosu-subtitle">注目度の高いキーワード</div>
					</div>
				</div>
				<div class="kumonosu-item">
					<div class="kumonosu-icon-ghost"><i data-lucide="hash"></i></div>
					<div class="kumonosu-text-group">
						<div class="kumonosu-title">タグ2</div>
						<div class="kumonosu-subtitle">過去のアーカイブ一覧</div>
					</div>
				</div>
			</section>
		</div>
		<div class="kumonosu-divider"></div>
		<nav class="kumonosu-nav">
			<span class="kumonosu-pill" id="kumonosu-pill"></span>
			<button class="kumonosu-tab" data-panel="menu">
				<i data-lucide="menu"></i><span>メニュー</span>
			</button>
			<button class="kumonosu-tab" data-panel="category">
				<i data-lucide="grid"></i><span>カテゴリ</span>
			</button>
			<button class="kumonosu-tab" data-panel="tags">
				<i data-lucide="tag"></i><span>タグ</span>
			</button>
		</nav>
	</aside>
</div>
@layer reset {
	*, *::before, *::after {
		box-sizing: border-box;
	}
	html {
		-moz-text-size-adjust: none;
		-webkit-text-size-adjust: none;
		text-size-adjust: none;
	}
	body {
		margin: 0;
		min-block-size: 100vh;
		line-height: 1.6;
		background: #0a0a0a;
		color: #fff;
		overflow-x: hidden;
	}
	button {
		font: inherit;
		cursor: pointer;
		border: none;
		background: none;
		color: inherit;
	}
}
:root {
	/* 接頭辞をつけた変数名で定義 */
	--kumonosu-bg-dock: rgba(25, 25, 25, 0.75);
	--kumonosu-glass-border: rgba(255, 255, 255, 0.12);
	--kumonosu-text-main: #ffffff;
	--kumonosu-text-sub: #a0a0a0;
	--kumonosu-tab-idle: #808080;
	--kumonosu-tab-active: #000000;
	--kumonosu-pill: #ffffff;
	--kumonosu-hover: rgba(255, 255, 255, 0.08);
	--kumonosu-font-base: "Inter", "Hiragino Kaku Gothic ProN", "Hiragino Sans", sans-serif;
	--kumonosu-radius-dock: 28px;
	--kumonosu-radius-item: 16px;
	--kumonosu-duration: 0.35s;
	--kumonosu-ease: cubic-bezier(0.2, 0.8, 0.2, 1);
}
.kumonosu-wrapper {
	display: flex;
	align-items: flex-end;
	justify-content: center;
	min-height: 100vh;
	padding-bottom: 4rem;
	font-family: var(--kumonosu-font-base);
	background: radial-gradient(circle at center, #1a1a1a 0%, #050505 100%);
}
/* ドックのメインコンテナ */
.kumonosu-dock {
	background: var(--kumonosu-bg-dock);
	backdrop-filter: blur(20px) saturate(180%);
	-webkit-backdrop-filter: blur(20px) saturate(180%);
	border: 1px solid var(--kumonosu-glass-border);
	border-radius: var(--kumonosu-radius-dock);
	padding: 6px;
	display: flex;
	flex-direction: column;
	position: relative;
	transition: gap var(--kumonosu-duration) var(--kumonosu-ease);
	box-shadow: 0 25px 50px rgba(0, 0, 0, 0.5);
	z-index: 10;
}
/* 背景拡張 */
.kumonosu-dock::before {
	content: "";
	background: var(--kumonosu-bg-dock);
	backdrop-filter: blur(20px);
	border: 1px solid var(--kumonosu-glass-border);
	border-radius: var(--kumonosu-radius-dock);
	inset: 0;
	position: absolute;
	transition: inset 0.4s var(--kumonosu-ease);
	z-index: -1;
}
.kumonosu-dock.kumonosu-is-open {
	gap: 12px;
}
.kumonosu-dock.kumonosu-is-open::before {
	inset: -1.5rem -1.2rem -0.2rem -1.2rem;
}
.kumonosu-panel-stack {
	overflow: hidden;
	width: 360px;
	position: relative;
}
.kumonosu-panel {
	height: 0;
	opacity: 0;
	pointer-events: none;
	transform: translateY(10px);
	transition: height 0.4s var(--kumonosu-ease), opacity 0.3s, transform 0.3s;
	display: flex;
	flex-direction: column;
}
.kumonosu-panel.kumonosu-is-active {
	height: auto;
	opacity: 1;
	pointer-events: auto;
	transform: translateY(0);
	padding: 8px 0;
}
.kumonosu-item {
	display: flex;
	align-items: center;
	gap: 1rem;
	padding: 12px 16px;
	border-radius: var(--kumonosu-radius-item);
	position: relative;
	cursor: pointer;
}
.kumonosu-item-hover-indicator {
	background: var(--kumonosu-hover);
	border-radius: var(--kumonosu-radius-item);
	position: absolute;
	z-index: -1;
	opacity: 0;
	pointer-events: none;
}
.kumonosu-icon-ghost {
	width: 24px;
	height: 24px;
	display: flex;
	align-items: center;
	justify-content: center;
	color: var(--kumonosu-text-sub);
	transition: transform 0.3s var(--kumonosu-ease);
	flex-shrink: 0;
}
.kumonosu-item:hover .kumonosu-icon-ghost,
.kumonosu-item:hover .kumonosu-text-group {
	transform: translateX(8px);
}
.kumonosu-text-group {
	display: flex;
	flex-direction: column;
	transition: transform 0.3s var(--kumonosu-ease);
}
.kumonosu-title {
	font-weight: 600;
	color: var(--kumonosu-text-main);
	font-size: 1rem;
	line-height: 1.2;
}
.kumonosu-subtitle {
	font-size: 0.8rem;
	color: var(--kumonosu-text-sub);
	margin-top: 4px;
}
.kumonosu-tag {
	margin-left: auto;
	border: 1px solid var(--kumonosu-glass-border);
	color: var(--kumonosu-text-sub);
	font-size: 0.7rem;
	padding: 2px 8px;
	border-radius: 10px;
	background: rgba(255, 255, 255, 0.05);
	flex-shrink: 0;
}
.kumonosu-divider {
	background: var(--kumonosu-glass-border);
	height: 0;
	opacity: 0;
	margin: 0 10px;
	transition: height 0.3s, opacity 0.3s;
}
.kumonosu-dock.kumonosu-is-open .kumonosu-divider {
	height: 1px;
	opacity: 1;
}
.kumonosu-nav {
	display: grid;
	grid-template-columns: repeat(3, 1fr);
	gap: 4px;
	position: relative;
}
.kumonosu-pill {
	background: var(--kumonosu-pill);
	border-radius: 14px;
	position: absolute;
	height: calc(100% - 4px);
	top: 2px;
	z-index: 0;
	opacity: 0;
	pointer-events: none;
}
.kumonosu-tab {
	display: flex;
	align-items: center;
	justify-content: center;
	gap: 8px;
	padding: 10px 0;
	color: var(--kumonosu-tab-idle);
	font-weight: 600;
	font-size: 0.9rem;
	position: relative;
	z-index: 1;
	transition: color 0.3s;
}
.kumonosu-tab.kumonosu-is-active {
	color: var(--kumonosu-tab-active);
}
.kumonosu-tab i {
	width: 16px;
	height: 16px;
	stroke-width: 2.5px;
}
class KumonosuDockNavigation {
	constructor() {
		this.state = {
			activePanelId: null,
			isOpen: false
		};
		this.elements = {};
		this.panelHoverState = new Map();
		this.init();
	}
	init() {
		lucide.createIcons();
		this.cacheElements();
		this.setupPanels();
		this.bindEvents();
	}
	cacheElements() {
		this.elements = {
			dock: document.getElementById('kumonosu-dock'),
			pill: document.getElementById('kumonosu-pill'),
			tabs: Array.from(document.querySelectorAll('.kumonosu-tab')),
			panels: Array.from(document.querySelectorAll('.kumonosu-panel')),
			panelsById: {}
		};
		this.elements.panels.forEach(p => {
			this.elements.panelsById[p.dataset.panel] = p;
		});
	}
	setupPanels() {
		this.elements.panels.forEach(panel => {
			const indicator = document.createElement("span");
			indicator.className = "kumonosu-item-hover-indicator";
			panel.prepend(indicator);
			const hoverEntry = {
				indicator,
				activeItem: null
			};
			this.panelHoverState.set(panel, hoverEntry);
			panel.addEventListener("pointerover", (e) => {
				const item = e.target.closest('.kumonosu-item');
				if (item && panel.contains(item)) this.renderHoverIndicator(panel, item);
			});
			panel.addEventListener("pointerleave", () => {
				gsap.to(indicator, {
					opacity: 0,
					duration: 0.2
				});
				hoverEntry.activeItem = null;
			});
		});
	}
	bindEvents() {
		this.elements.tabs.forEach(tab => {
			tab.addEventListener("click", () => this.handleTabSelect(tab.dataset.panel));
		});
		document.addEventListener("pointerdown", (e) => {
			if (this.state.isOpen && !this.elements.dock.contains(e.target)) {
				this.closeMenu();
			}
		});
	}
	handleTabSelect(panelId) {
		if (this.state.isOpen && this.state.activePanelId === panelId) {
			this.closeMenu();
		} else {
			this.openMenu(panelId);
		}
	}
	openMenu(panelId) {
		this.state.isOpen = true;
		this.state.activePanelId = panelId;
		this.updateUI();
	}
	closeMenu() {
		this.state.isOpen = false;
		this.state.activePanelId = null;
		this.updateUI();
	}
	updateUI() {
		const {
			dock,
			tabs,
			pill,
			panelsById
		} = this.elements;
		// クラス名に接頭辞を使用
		dock.classList.toggle('kumonosu-is-open', this.state.isOpen);
		tabs.forEach(tab => {
			const isActive = this.state.isOpen && tab.dataset.panel === this.state.activePanelId;
			tab.classList.toggle('kumonosu-is-active', isActive);
			if (isActive) {
				gsap.to(pill, {
					left: tab.offsetLeft,
					width: tab.offsetWidth,
					opacity: 1,
					duration: 0.3,
					ease: "power3.inOut"
				});
			}
		});
		if (!this.state.isOpen) gsap.to(pill, {
			opacity: 0,
			duration: 0.2
		});
		Object.keys(panelsById).forEach(id => {
			panelsById[id].classList.toggle('kumonosu-is-active', this.state.isOpen && id === this.state.activePanelId);
		});
	}
	renderHoverIndicator(panel, item) {
		const entry = this.panelHoverState.get(panel);
		if (!entry || entry.activeItem === item) return;
		entry.activeItem = item;
		gsap.to(entry.indicator, {
			top: item.offsetTop,
			left: item.offsetLeft,
			width: item.offsetWidth,
			height: item.offsetHeight,
			opacity: 1,
			duration: 0.25,
			ease: "power2.out"
		});
	}
}
// 初期化
new KumonosuDockNavigation();
https://unpkg.com/lucide@latest
https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js

Explanation 詳しい説明

基本構造

このUIは3つのレイヤーで構成されています。

  • Dockコンテナ(全体UI)
  • 切り替え可能なパネルスタック
  • タブナビゲーション

タブ操作によって表示パネルを切り替えるステート管理型ナビゲーションになっています。

JavaScriptクラス KumonosuDockNavigation がUI全体の状態を制御します。

Dockナビゲーションの仕組み

状態は次の2つで管理されています。

  • isOpen(Dockの開閉状態)
  • activePanelId(現在表示中のパネル)

クリック時の挙動:

  1. タブクリック
  2. パネルID取得
  3. 状態更新
  4. UIクラス切替
  5. GSAPアニメーション実行

これによりDOMを書き換えずUIのみ更新する設計になっています。

スライダー的挙動(パネル切替アニメーション)

一般的なスライダーではなく、パネル展開型UIとして動作します。

特徴:

  • 高さ0 → auto へ展開
  • opacityフェード
  • translateYによる浮き上がり演出
height + opacity + transform

を組み合わせた軽量トランジションです。

Glassmorphism(ガラス風UI)

Dock背景は以下で構成されています。

  • 半透明背景
  • backdrop-filter blur
  • 境界線の透明表現
  • 深いシャドウ

主な指定:

  • backdrop-filter: blur(20px)
  • saturate(180%)
  • 半透明RGBAカラー

これにより背景と自然に融合するUIになります。

アクティブタブのピルアニメーション

選択中タブは「pill」と呼ばれる背景要素がスライドして追従します。

GSAPで以下をアニメーション:

  • left位置
  • width
  • opacity

タブサイズに自動追従するため、レスポンシブでも崩れません。

Hoverインジケーター

各パネルにはホバー追従UIが追加されています。

処理内容:

  • pointeroverで対象item取得
  • 要素サイズを計算
  • GSAPで背景を移動

hover時に項目全体がハイライトされ、操作対象が明確になります。

カスタム方法

パネル追加
<section class="kumonosu-panel" data-panel="new">

data-panel名をタブ側と一致させるだけで追加可能です。

タブ追加
<button class="kumonosu-tab" data-panel="new">

JS修正なしで動作します。

カラー変更
:root {
--kumonosu-bg-dock:
--kumonosu-pill:
}

変数設計のためテーマ変更が容易です。

Dockサイズ変更
.kumonosu-panel-stack {
width: 360px;
}

数値変更のみで全体幅を調整できます。

アニメーション速度調整
--kumonosu-duration: 0.35s;

UI全体の体感速度を一括制御できます。

注意点

backdrop-filterの対応

古いブラウザではblurが効かない場合があります。

フォールバック背景色を設定すると安全です。

height:autoアニメーション

CSSのみでは完全な高さ補間はできないため、要素量が極端に多い場合はJS制御への変更を検討してください。

GSAP依存

以下ライブラリが必須です。

  • GSAP
  • Lucide Icons

CDN削除時は動作しません。

position構造

Dockは重なりUIのため:

  • z-index管理
  • overflow:hidden
  • fixed要素との重なり

に注意してください。

このUIが向いている用途

  • ブログカテゴリナビ
  • ポートフォリオメニュー
  • SaaS管理UI
  • ドキュメントサイト
  • アプリ風Webサイト