CSSとJSで作る、可愛く跳ねるドット付きナビゲーション

アニメーション

CSSとJSで作る、可愛く跳ねるドット付きナビゲーション

投稿日2026/03/19

更新日2026/3/16

ナビゲーションのUIに少し遊び心を加えるだけで、サイト全体の印象は大きく変わります。

このサンプルでは、メニューをホバーすると小さなドットが跳ねながら移動するナビゲーションを実装しています。
ただ位置が切り替わるだけではなく、バウンドする動きを加えることで操作に対する反応が直感的に伝わり、可愛さと気持ちよさを両立したUIになります。

CSSで見た目を制御し、JavaScriptでは位置計算のみを行う構成のため、見た目以上にシンプルで応用しやすいのも特徴です。

Preview プレビュー

Code コード

<header class="kumonosu-header">
	<nav class="kumonosu-nav">
		<ul class="kumonosu-nav-list">
			<li><a href="#kumonosu-section1" class="kumonosu-nav-link">Home</a></li>
			<li><a href="#kumonosu-section2" class="kumonosu-nav-link">About</a></li>
			<li><a href="#kumonosu-section3" class="kumonosu-nav-link">Services</a></li>
			<li><a href="#kumonosu-section4" class="kumonosu-nav-link">Contact</a></li>
		</ul>
	</nav>
</header>
<section class="kumonosu-container" id="kumonosu-section1">
	<img src="https://images.unsplash.com/photo-1497215728101-856f4ea42174?auto=format&fit=crop&w=1920&q=80" alt="Home">
</section>
<section class="kumonosu-container" id="kumonosu-section2">
	<img src="https://images.unsplash.com/photo-1522202176988-66273c2fd55f?auto=format&fit=crop&w=1920&q=80" alt="About">
</section>
<section class="kumonosu-container" id="kumonosu-section3">
	<img src="https://images.unsplash.com/photo-1519389950473-47ba0277781c?auto=format&fit=crop&w=1920&q=80" alt="Services">
</section>
<section class="kumonosu-container" id="kumonosu-section4">
	<img src="https://images.unsplash.com/photo-1423666639041-f56000c27a9a?auto=format&fit=crop&w=1920&q=80" alt="Contact">
</section>
<svg class="kumonosu-filters" xmlns="http://www.w3.org/2000/svg">
	<defs>
		<filter id="kumonosu-wave-distort" x="0%" y="0%" width="100%" height="100%">
			<feTurbulence type="fractalNoise" baseFrequency="0.0038 0.0038" numOctaves="1" seed="2" result="roughNoise" />
			<feGaussianBlur in="roughNoise" stdDeviation="8.5" result="softNoise" />
			<feComposite operator="arithmetic" k1="0" k2="1" k3="2" k4="0" in="softNoise" result="mergedMap" />
			<feDisplacementMap in="SourceGraphic" in2="mergedMap" scale="-42" xChannelSelector="G" yChannelSelector="G" />
		</filter>
	</defs>
</svg>
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap');
* {
	margin: 0;
	padding: 0;
	box-sizing: border-box;
}
body {
	font-family: 'Poppins', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
	-webkit-font-smoothing: antialiased;
	-moz-osx-font-smoothing: grayscale;
	background-color: #000;
}
html, body {
	scroll-behavior: smooth;
}
.kumonosu-container {
	width: 100%;
	height: 100vh;
	display: flex;
	justify-content: center;
	background-color: #000;
	color: #fff;
	overflow: hidden;
	position: relative;
}
.kumonosu-container img {
	width: 100%;
	height: 100%;
	object-fit: cover;
	filter: brightness(0.7);
}
.kumonosu-header {
	position: fixed;
	top: 0;
	left: 0;
	right: 0;
	z-index: 1000;
	display: flex;
	justify-content: center;
}
.kumonosu-nav {
	position: relative;
	width: fit-content;
	margin-top: 30px;
	padding: 0 30px;
	border-radius: 16px;
	background: rgba(255, 255, 255, 0.4);
	overflow: hidden;
	border: 1px solid rgba(255, 255, 255, 0.2);
	box-shadow:
		0 8px 32px rgba(0, 0, 0, 0.2),
		0 4px 16px rgba(0, 0, 0, 0.1);
	backdrop-filter: blur(8px);
}
.kumonosu-nav::before {
	content: '';
	position: absolute;
	inset: 0;
	width: 100%;
	height: 100%;
	backdrop-filter: url(#kumonosu-wave-distort);
	z-index: -1;
}
.kumonosu-nav-list {
	position: relative;
	list-style: none;
	display: flex;
	justify-content: center;
	height: 55px;
	isolation: isolate;
	padding: 0 15px;
}
.kumonosu-nav-list::after {
	content: '';
	position: absolute;
	left: 0;
	bottom: 6px;
	width: 12px;
	height: 12px;
	background: white;
	border-radius: 50%;
	transform: translateX(var(--kumonosu-translate-x, 0)) translateY(var(--kumonosu-translate-y, 0)) rotate(var(--kumonosu-rotate-x, 0deg));
	transition: none;
	opacity: 0;
	z-index: -1;
	box-shadow:
		0 4px 16px rgba(255, 255, 255, 0.6),
		0 2px 8px rgba(255, 255, 255, 0.4),
		inset 0 1px 0 rgba(255, 255, 255, 0.8);
	border: 1px solid rgba(255, 255, 255, 0.8);
}
.kumonosu-show-indicator.kumonosu-nav-list::after {
	opacity: 1;
}
.kumonosu-nav-link {
	position: relative;
	width: 100%;
	height: 100%;
	display: flex;
	justify-content: center;
	align-items: center;
	color: #000;
	text-decoration: none;
	font-weight: 600;
	font-size: 0.95rem;
	padding-inline: 20px;
	transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.kumonosu-nav-link:hover,
.kumonosu-nav-link.kumonosu-active {
	color: #fff;
	text-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
	transform: translateY(-2px);
}
.kumonosu-filters {
	display: none;
}
document.addEventListener('DOMContentLoaded', () => {
            const navList = document.querySelector('.kumonosu-nav-list');
            const navLinks = document.querySelectorAll('.kumonosu-nav-link');
            let anim = null;
            let currentActiveItem = null;

            const animateIndicator = (from, to) => {
                if (anim) clearInterval(anim);
                const start = Date.now();
                anim = setInterval(() => {
                    const duration = 500;
                    const p = Math.min((Date.now() - start) / duration, 1);
                    const e = 1 - Math.pow(1 - p, 3);
                    const x = from + (to - from) * e;
                    const y = -40 * (4 * e * (1 - e));
                    const r = 200 * Math.sin(p * Math.PI);

                    navList.style.setProperty('--kumonosu-translate-x', `${x}px`);
                    navList.style.setProperty('--kumonosu-translate-y', `${y}px`);
                    navList.style.setProperty('--kumonosu-rotate-x', `${r}deg`);

                    if (p >= 1) {
                        clearInterval(anim);
                        anim = null;
                        navList.style.setProperty('--kumonosu-translate-y', '0px');
                        navList.style.setProperty('--kumonosu-rotate-x', '0deg');
                    }
                }, 16);
            };

            const getCurrentPosition = () => parseFloat(navList.style.getPropertyValue('--kumonosu-translate-x')) || 0;

            const getItemCenter = (item) => {
                const navRect = navList.getBoundingClientRect();
                const itemRect = item.getBoundingClientRect();
                return itemRect.left + (itemRect.width / 2) - navRect.left - 6;
            };

            const moveToItem = (item) => {
                const current = getCurrentPosition();
                const center = getItemCenter(item);
                animateIndicator(current, center);
                navList.classList.add('kumonosu-show-indicator');
            };

            const setActiveItem = (item) => {
                if (currentActiveItem) currentActiveItem.classList.remove('kumonosu-active');
                currentActiveItem = item;
                item.classList.add('kumonosu-active');
                moveToItem(item);
            };

            navLinks.forEach(link => {
                link.addEventListener('mouseenter', () => moveToItem(link));
                link.addEventListener('mouseleave', () => {
                    if (currentActiveItem) moveToItem(currentActiveItem);
                });
                link.addEventListener('click', () => setActiveItem(link));
            });

            // 初期化
            setTimeout(() => {
                if (navLinks.length > 0) setActiveItem(navLinks[0]);
            }, 100);

            // スクロール検知によるアクティブ切り替え
            window.addEventListener('scroll', () => {
                let currentId = "";
                const sections = document.querySelectorAll(".kumonosu-container");
                sections.forEach(section => {
                    const sectionTop = section.offsetTop;
                    if (pageYOffset >= sectionTop - 100) {
                        currentId = section.getAttribute("id");
                    }
                });

                navLinks.forEach(link => {
                    if (link.getAttribute("href") === `#${currentId}`) {
                        if (currentActiveItem !== link) {
                            if (currentActiveItem) currentActiveItem.classList.remove('kumonosu-active');
                            currentActiveItem = link;
                            link.classList.add('kumonosu-active');
                            const center = getItemCenter(link);
                            navList.style.setProperty('--kumonosu-translate-x', `${center}px`);
                        }
                    }
                });
            });
        });

Explanation 詳しい説明

■ 仕様

このナビゲーションでは、メニュー下に表示されるドットが現在位置を示します。

主な動作:

  • メニューをホバーするとドットが跳ねながら移動
  • クリックした項目をアクティブ状態として保持
  • マウスが離れると選択中の位置へ戻る
  • スクロール位置に応じて自動的に現在位置を更新
  • 固定ヘッダーとして常に上部に表示
  • スムーススクロール対応

ドットの動きは放物線計算を使い、「ぴょん」と跳ねるような自然なアニメーションになっています。

■ カスタム方法

① 跳ねる高さを変更する(動きの印象が変わる)

const y = -40 * (4 * e * (1 - e));

数値を調整:

印象
-20控えめ
-40標準(現在)
-70元気に跳ねる

② アニメーション速度

const duration = 500;
  • 小さく → 軽快なUI
  • 大きく → ゆったり可愛い動き

例:

300 = サクサク
700 = ふんわり

③ ドットサイズ変更

.kumonosu-nav-list::after {
  width: 12px;
  height: 12px;
}

おすすめ例:
  • 8px → ミニマル
  • 14px → 可愛さUP
  • 18px → ポップUI

④ ガラス風デザインの調整

background: rgba(255,255,255,0.4);
backdrop-filter: blur(8px);

例:

blur(12px);
rgba(255,255,255,0.25);

透明感が強くなります。

⑤ 跳ねる回転量の調整

const r = 200 * Math.sin(p * Math.PI);
  • 小さく → 落ち着いた動き
  • 大きく → ポップな印象

■ 注意点

① CSS transitionではなくJS制御

ジャンプ動作はCSSだけでは作りにくいため、JavaScriptで補間計算を行っています。

そのため:

  • 動きの自由度が高い
  • カスタムしやすい

構造になっています。

② transformで動かしている理由

位置変更は left ではなく:

transform: translate()

を使用しているため、描画パフォーマンスが高く滑らかに動作します。

③ セクションIDの一致が必要

ナビリンク:

href="#kumonosu-section1"

<section id="kumonosu-section1">

を一致させてください。

④ SVGフィルターはやや負荷あり

歪みエフェクト(feTurbulence)は端末によって負荷が上がる場合があります。
必要に応じて削除してもドットアニメーション自体は問題なく動作します。