ナビゲーション
HoverNav
Motion
Vercel / Linear 風のトップナビ — ホバーやキーボードフォーカスに合わせて柔らかなハイライトがスプリングで滑り、現在のページにはドット / 下線が残ります。フローティングのピルとフラットの 2 スタイル。
Nimbus
AI に貼るだけで導入Claude Code / Cursor / ChatGPT などにそのまま貼り付けてください。ソースコード一式と現在の設定、組み込み手順が含まれています(約34 KB)。
# HoverNav をこのプロジェクトに追加してください
このプロンプトは自己完結しています。コンポーネントのソースコード一式が下に含まれているので、外部サイトへのアクセスや shadcn CLI は不要です。手順どおりにファイルを作成し、依存パッケージを追加し、使用例を配置して、そのまま動作する状態にしてください。
## コンポーネントについて
- 名前: HoverNav
- 説明: Vercel / Linear 風のトップナビ — ホバーやキーボードフォーカスに合わせて柔らかなハイライトがスプリングで滑り、現在のページにはドット / 下線が残ります。フローティングのピルとフラットの 2 スタイル。
- 実行時に必要な npm パッケージ: framer-motion
- 構成ファイル: `components/hover-nav/index.tsx`, `components/hover-nav/HoverNav.module.css`
## 前提と注意
- React 19 / TypeScript / Next.js(App Router)のプロジェクトを想定しています。Next.js 以外の React プロジェクト(Vite など)でもそのまま動きます。
- スタイルは同梱の CSS Modules(ある場合)で自己完結しており、Tailwind CSS の有無や設定には依存しません。Tailwind の設定変更やグローバル CSS の追加は、この依頼文に明記された分だけにしてください。
- ブラウザ API やインタラクションを使うクライアントコンポーネントです。ファイル先頭の "use client" を維持してください。
- コンポーネント自体の背景は透過です。配置先ページの背景がそのまま透けて見える前提で作られているので、白背景や単色の `div` などで囲わず、コンポーネントだけをそのまま配置してください。
- ソースコードの内容は変更しないでください。例外は、`@/` パスエイリアスが無いプロジェクトで import パスを相対パスに直す場合だけです。
## 手順
### 1. 依存パッケージを追加する
pnpm / yarn / bun を使っているプロジェクトでは、そのパッケージマネージャーのコマンドに置き換えてください。
```bash
npm install framer-motion
```
### 2. ファイルを作成する
`@/components` が指すディレクトリ(多くは `src/components/` か `components/`。shadcn の `components.json` があれば `aliases.components` の指す場所)に、以下の 2 ファイルを**一字一句そのまま**作成してください。
#### `components/hover-nav/index.tsx`
```tsx
"use client";
import {
useEffect,
useId,
useLayoutEffect,
useRef,
type CSSProperties,
type FocusEvent,
type KeyboardEvent,
type MouseEvent,
type PointerEvent,
} from "react";
import {
MotionConfig,
animate,
motion,
useMotionValue,
useReducedMotion,
type MotionValue,
} from "framer-motion";
import styles from "./HoverNav.module.css";
/** Lets CSS custom properties pass the CSSProperties type check. */
type CSSVars = CSSProperties & Record<`--${string}`, string | number>;
export type HoverNavItem = {
label: string;
href: string;
/** Marks the current page. Ignored when `activeHref` is passed. */
active?: boolean;
};
export type HoverNavCta = {
label: string;
href: string;
};
export type HoverNavProps = {
items: HoverNavItem[];
/** The current page's href. Takes precedence over `item.active`. */
activeHref?: string;
/**
* "floating" (default): a rounded pill with a hairline border, backdrop
* blur and a soft shadow. "flat": no container — links sit straight on
* the page header.
*/
variant?: "floating" | "flat";
/** Tint of the hover blob, the active indicator and the focus ring. */
highlightColor?: string;
/** Persistent marker on the active item. */
indicator?: "dot" | "underline" | "none";
/** Item height: sm 28px / md 34px. */
size?: "sm" | "md";
/** Small solid button at the right end of the bar. */
cta?: HoverNavCta;
/**
* When nothing is hovered or focused, park the blob on the active item
* (default true) instead of fading it out.
*/
restOnActive?: boolean;
/** Spring stiffness of the blob's glide between items. */
springStiffness?: number;
/** Spring damping of the blob's glide between items. */
springDamping?: number;
/**
* Runs before an item — or the CTA — navigates. Call
* `event.preventDefault()` to handle routing yourself (client routers,
* controlled previews, analytics).
*/
onNavigate?: (
item: HoverNavItem | HoverNavCta,
event: MouseEvent<HTMLAnchorElement>,
) => void;
className?: string;
/** Accessible name of the navigation landmark. */
"aria-label"?: string;
};
const DEFAULT_HIGHLIGHT = "#ffffff";
/** Fast with a hint of overshoot — the blob should feel attached to the pointer. */
const DEFAULT_SPRING_STIFFNESS = 420;
const DEFAULT_SPRING_DAMPING = 34;
const FADE_IN = { duration: 0.16, ease: "easeOut" } as const;
const FADE_OUT = { duration: 0.22, ease: "easeOut" } as const;
type Blob = {
x: MotionValue<number>;
width: MotionValue<number>;
opacity: MotionValue<number>;
};
type Spring = { type: "spring"; stiffness: number; damping: number; mass: number };
/**
* Moves the blob over `link`. Measured from bounding-rect deltas (plus the
* track's scroll offset) rather than offsetLeft, so it doesn't depend on
* which ancestor happens to be the offsetParent. `spring === null` jumps.
*/
function placeBlob(
track: HTMLElement,
link: HTMLElement,
blob: Blob,
spring: Spring | null,
) {
const t = track.getBoundingClientRect();
const l = link.getBoundingClientRect();
const x = l.left - t.left + track.scrollLeft;
if (spring) {
animate(blob.x, x, spring);
animate(blob.width, l.width, spring);
} else {
blob.x.jump(x);
blob.width.jump(l.width);
}
}
/**
* Vercel / Linear-style top navigation: a soft highlight blob glides to
* whichever link is hovered or keyboard-focused, while the current page
* keeps its own dot / underline. Plain <a> links — every one stays in the
* tab order, and ArrowLeft / ArrowRight / Home / End move focus along the
* bar. Touch input never shows the blob; taps get a press-scale instead.
*/
export function HoverNav({
items,
activeHref,
variant = "floating",
highlightColor,
indicator = "dot",
size = "md",
cta,
restOnActive = true,
springStiffness = DEFAULT_SPRING_STIFFNESS,
springDamping = DEFAULT_SPRING_DAMPING,
onNavigate,
className,
"aria-label": ariaLabel = "メイン",
}: HoverNavProps) {
// layoutId is page-global in framer-motion; two navs on one page would
// otherwise trade indicators.
const indicatorId = `${useId()}-hover-nav-indicator`;
const trackRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const linkRefs = useRef<(HTMLAnchorElement | null)[]>([]);
// Hover / focus targets live in refs: the blob is driven through motion
// values, so moving it never re-renders the nav.
const pointerIndexRef = useRef<number | null>(null);
const focusIndexRef = useRef<number | null>(null);
const restIndexRef = useRef<number | null>(null);
const x = useMotionValue(0);
const width = useMotionValue(0);
// Hidden until the first measurement, so it never flashes at x = 0.
const opacity = useMotionValue(0);
// `animate()` is imperative and doesn't read MotionConfig, so reduced
// motion has to be honored by hand: the blob then jumps between items.
const reduceMotion = useReducedMotion() ?? false;
const activeIndex = items.findIndex((item) =>
activeHref !== undefined ? item.href === activeHref : Boolean(item.active),
);
const restIndex = restOnActive && activeIndex >= 0 ? activeIndex : null;
const spring: Spring = {
type: "spring",
stiffness: springStiffness,
damping: springDamping,
mass: 0.8,
};
const blob: Blob = { x, width, opacity };
/** Glides to `index` — or appears there, if the blob is currently hidden. */
const showAt = (index: number) => {
const track = trackRef.current;
const link = linkRefs.current[index];
if (!track || !link) return;
const visible = opacity.get() > 0.02;
placeBlob(track, link, blob, visible && !reduceMotion ? spring : null);
animate(opacity, 1, FADE_IN);
};
/** Pointer beats keyboard focus beats the resting (active) item. */
const settle = () => {
const index =
pointerIndexRef.current ?? focusIndexRef.current ?? restIndexRef.current;
if (index === null) animate(opacity, 0, FADE_OUT);
else showAt(index);
};
// Resting position: on mount the blob appears on the active item before
// the first paint (so a static screenshot already shows the effect);
// when the active item changes while nothing is hovered, it glides over.
useLayoutEffect(() => {
restIndexRef.current = restIndex;
if (pointerIndexRef.current !== null || focusIndexRef.current !== null) {
return;
}
const track = trackRef.current;
const link = restIndex === null ? null : linkRefs.current[restIndex];
if (!track || !link) {
animate(opacity, 0, FADE_OUT);
return;
}
const visible = opacity.get() > 0.02;
placeBlob(
track,
link,
{ x, width, opacity },
visible && !reduceMotion
? { type: "spring", stiffness: springStiffness, damping: springDamping, mass: 0.8 }
: null,
);
if (!visible) opacity.jump(1);
}, [restIndex, reduceMotion, springStiffness, springDamping, x, width, opacity]);
// Label widths shift when web fonts finish loading and when the bar is
// resized; re-snap the blob to whatever it is sitting on.
useEffect(() => {
const track = trackRef.current;
const list = listRef.current;
if (!track || !list || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => {
const index =
pointerIndexRef.current ?? focusIndexRef.current ?? restIndexRef.current;
const link = index === null ? null : linkRefs.current[index];
if (link) placeBlob(track, link, { x, width, opacity }, null);
});
observer.observe(track);
observer.observe(list);
return () => observer.disconnect();
}, [x, width, opacity]);
const onPointerEnterItem = (index: number) => (event: PointerEvent) => {
// Touch has no hover; a tap would otherwise leave the blob stranded.
if (event.pointerType === "touch") return;
pointerIndexRef.current = index;
showAt(index);
};
const onPointerLeaveTrack = (event: PointerEvent) => {
if (event.pointerType === "touch") return;
pointerIndexRef.current = null;
settle();
};
const onFocusItem = (index: number) => (event: FocusEvent<HTMLAnchorElement>) => {
// Mouse clicks focus links too (in Chromium); only keyboard focus,
// which matches :focus-visible, should pull the blob.
if (!event.currentTarget.matches(":focus-visible")) return;
focusIndexRef.current = index;
if (pointerIndexRef.current === null) showAt(index);
};
const onBlurTrack = (event: FocusEvent<HTMLDivElement>) => {
const next = event.relatedTarget as Node | null;
if (next && event.currentTarget.contains(next)) return;
focusIndexRef.current = null;
settle();
};
const onKeyDown = (event: KeyboardEvent<HTMLUListElement>) => {
const links = linkRefs.current.slice(0, items.length);
const current = links.findIndex((link) => link === document.activeElement);
if (current === -1) return;
const last = items.length - 1;
let next: number | null = null;
switch (event.key) {
case "ArrowRight":
next = current === last ? 0 : current + 1;
break;
case "ArrowLeft":
next = current === 0 ? last : current - 1;
break;
case "Home":
next = 0;
break;
case "End":
next = last;
break;
}
if (next === null) return;
event.preventDefault();
links[next]?.focus();
};
// The CSS module carries the defaults; custom properties are only set
// for non-default values so the default render matches the stylesheet.
const vars: CSSVars = {};
if (highlightColor && highlightColor.toLowerCase() !== DEFAULT_HIGHLIGHT) {
vars["--hn-highlight"] = highlightColor;
}
return (
// Follows the OS "reduce motion" setting: the active indicator's
// layout animation becomes an instant swap.
<MotionConfig reducedMotion="user">
<nav
className={className ? `${styles.root} ${className}` : styles.root}
data-hover-nav
data-variant={variant}
data-size={size}
aria-label={ariaLabel}
style={vars}
>
<div className={styles.bar}>
<div
ref={trackRef}
className={styles.track}
onPointerLeave={onPointerLeaveTrack}
onBlur={onBlurTrack}
>
<motion.span
className={styles.blob}
aria-hidden
style={{ x, width, opacity }}
/>
<ul ref={listRef} className={styles.list} onKeyDown={onKeyDown}>
{items.map((item, index) => {
const active = index === activeIndex;
return (
<li key={item.href} className={styles.item}>
<a
ref={(el) => {
linkRefs.current[index] = el;
}}
href={item.href}
className={styles.link}
data-active={active || undefined}
aria-current={active ? "page" : undefined}
onPointerEnter={onPointerEnterItem(index)}
onFocus={onFocusItem(index)}
onClick={onNavigate ? (e) => onNavigate(item, e) : undefined}
>
<span className={styles.label}>{item.label}</span>
{active && indicator !== "none" ? (
<motion.span
layoutId={indicatorId}
className={styles.indicator}
data-kind={indicator}
aria-hidden
// Framer corrects only the radius it owns during
// the scale-based layout animation.
style={{ borderRadius: 999 }}
transition={spring}
/>
) : null}
</a>
</li>
);
})}
</ul>
</div>
{cta ? (
<a
href={cta.href}
className={styles.cta}
onClick={onNavigate ? (e) => onNavigate(cta, e) : undefined}
>
<span>{cta.label}</span>
<svg
className={styles.ctaArrow}
viewBox="0 0 16 16"
width="14"
height="14"
aria-hidden
>
<path
d="M3 8h9M8.5 4.5 12 8l-3.5 3.5"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</a>
) : null}
</div>
</nav>
</MotionConfig>
);
}
export default HoverNav;
```
#### `components/hover-nav/HoverNav.module.css`
```css
/* ==========================================================================
Hover nav — top navigation pill with a gliding hover blob
The blob's position and width are framer-motion values (inline transform
/ width on .blob); everything else — colors, the press scale, the CTA
arrow nudge — is plain CSS so it costs nothing per frame.
========================================================================== */
.root {
/* Spec values. The component only sets these inline for non-default
values, so the default render is exactly this stylesheet. */
--hn-highlight: #ffffff;
--hn-item-h: 34px;
--hn-pad: 5px; /* bar edge → items (floating) */
--hn-px: 14px; /* item side padding */
--hn-font: 14px;
--hn-dot-bottom: 3px;
display: flex;
min-width: 0;
max-width: 100%;
font-family:
"Inter", -apple-system, BlinkMacSystemFont, "Hiragino Sans",
"Hiragino Kaku Gothic ProN", "Noto Sans JP", "Segoe UI", Roboto,
sans-serif;
color: #fff;
-webkit-font-smoothing: antialiased;
}
.root[data-size="sm"] {
--hn-item-h: 28px;
--hn-pad: 4px;
--hn-px: 11px;
--hn-font: 13px;
--hn-dot-bottom: 2px;
}
/* --- The bar. Floating: a dark smoked pill with a hairline border. --- */
.bar {
position: relative;
isolation: isolate;
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 0;
max-width: 100%;
box-sizing: border-box;
}
.root[data-variant="floating"] .bar {
padding: var(--hn-pad);
border-radius: 999px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.015)),
rgba(14, 14, 18, 0.62);
/* Unprefixed only: the build adds -webkit-, and writing both makes
Lightning CSS drop the unprefixed one (no blur in Chrome). */
backdrop-filter: blur(16px) saturate(160%);
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.08),
0 1px 2px rgba(0, 0, 0, 0.4),
0 14px 40px -10px rgba(0, 0, 0, 0.7);
}
/* Film grain — keeps the smoked glass from reading as a flat vector fill. */
.root[data-variant="floating"] .bar::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
border-radius: inherit;
pointer-events: none;
opacity: 0.06;
mix-blend-mode: overlay;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
/* Top rim light that fades out toward the ends — the Linear-style edge. */
.root[data-variant="floating"] .bar::after {
content: "";
position: absolute;
top: 0;
left: 14%;
right: 14%;
height: 1px;
pointer-events: none;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.32),
transparent
);
}
@supports not (backdrop-filter: blur(1px)) {
.root[data-variant="floating"] .bar {
background: rgba(22, 22, 28, 0.96);
}
}
/* --- Track: the positioning context of the blob. It scrolls sideways
when the bar is narrower than its links (phones), scrollbar hidden. --- */
.track {
position: relative;
flex: 0 1 auto;
min-width: 0;
overflow-x: auto;
overscroll-behavior-x: contain;
scrollbar-width: none;
border-radius: 999px;
}
.track::-webkit-scrollbar {
display: none;
}
.root[data-variant="flat"] .track {
border-radius: 10px;
}
/* --- Hover blob. x / width / opacity come from framer-motion inline. --- */
.blob {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 0;
border-radius: 999px;
pointer-events: none;
background:
linear-gradient(
180deg,
color-mix(in srgb, var(--hn-highlight) 9%, transparent),
color-mix(in srgb, var(--hn-highlight) 5%, transparent)
);
box-shadow:
inset 0 1px 0 color-mix(in srgb, var(--hn-highlight) 12%, transparent),
inset 0 0 0 1px color-mix(in srgb, var(--hn-highlight) 7%, transparent);
will-change: transform, width;
}
.root[data-variant="flat"] .blob {
border-radius: 10px;
}
/* --- Links. --- */
.list {
position: relative; /* paints above the blob */
display: flex;
margin: 0;
padding: 0;
list-style: none;
}
.item {
flex: none;
}
.link {
position: relative;
display: flex;
align-items: center;
box-sizing: border-box;
height: var(--hn-item-h);
padding: 0 var(--hn-px);
border-radius: 999px;
font-size: var(--hn-font);
font-weight: 500;
line-height: 1;
letter-spacing: -0.005em;
white-space: nowrap;
color: rgba(255, 255, 255, 0.6);
text-decoration: none;
transition: color 0.2s ease;
-webkit-tap-highlight-color: transparent;
}
.root[data-variant="flat"] .link {
border-radius: 10px;
}
.link[data-active] {
color: #fff;
}
/* Hover brightening only where hover exists — on touch it would stick. */
@media (hover: hover) {
.link:hover {
color: #fff;
}
}
.link:focus-visible {
color: #fff;
outline: 2px solid color-mix(in srgb, var(--hn-highlight) 60%, transparent);
/* Inset: the track clips overflow, so an outer ring would be cut off. */
outline-offset: -2px;
}
.label {
display: block;
transition: transform 0.14s ease;
}
/* Tap / click feedback. On touch this is the only feedback (no blob). */
.link:active .label {
transform: scale(0.95);
}
/* --- Active indicator. Animated between items via framer layoutId, so
no CSS transform here (framer owns it). --- */
.indicator {
position: absolute;
pointer-events: none;
background: var(--hn-highlight);
box-shadow: 0 0 10px color-mix(in srgb, var(--hn-highlight) 70%, transparent);
}
.indicator[data-kind="dot"] {
left: 50%;
bottom: var(--hn-dot-bottom);
width: 4px;
height: 4px;
margin-left: -2px;
}
.indicator[data-kind="underline"] {
left: var(--hn-px);
right: var(--hn-px);
bottom: 1px;
height: 2px;
}
/* --- CTA: small solid pill at the right end. --- */
.cta {
position: relative;
display: inline-flex;
flex: none;
align-items: center;
gap: 6px;
box-sizing: border-box;
height: var(--hn-item-h);
margin-left: 2px;
padding: 0 calc(var(--hn-px) - 2px) 0 var(--hn-px);
border-radius: 999px;
font-size: calc(var(--hn-font) - 1px);
font-weight: 600;
line-height: 1;
letter-spacing: -0.005em;
white-space: nowrap;
color: #0b0b0f;
text-decoration: none;
background: linear-gradient(180deg, #ffffff, #e7e7ec);
box-shadow:
inset 0 -1px 0 rgba(0, 0, 0, 0.12),
0 1px 2px rgba(0, 0, 0, 0.35),
0 0 0 1px rgba(255, 255, 255, 0.1);
transition:
transform 0.14s ease,
filter 0.2s ease;
-webkit-tap-highlight-color: transparent;
}
.ctaArrow {
flex: none;
transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@media (hover: hover) {
.cta:hover {
filter: brightness(0.94);
}
.cta:hover .ctaArrow {
transform: translateX(2px);
}
}
.cta:active {
transform: scale(0.96);
}
.cta:focus-visible {
outline: 2px solid color-mix(in srgb, var(--hn-highlight) 70%, transparent);
outline-offset: 2px;
}
/* Reduced motion: no press scale or arrow nudge. Color changes stay —
they carry the hover / focus state. */
@media (prefers-reduced-motion: reduce) {
.label,
.cta,
.ctaArrow {
transition: none;
}
.link:active .label,
.cta:active {
transform: none;
}
.cta:hover .ctaArrow {
transform: none;
}
}
```
代替手段: `components.json` がありシェルを実行できる環境なら、shadcn CLI でも同じファイルと依存パッケージが入ります。
```bash
npx shadcn@latest add https://anima-js.vercel.app/r/hover-nav.json
```
### 3. 使用例を配置する
以下は配信元のプレイグラウンドで設定されていた値をそのまま反映した使用例です。これを基に、適切なページ・レイアウトへ配置してください。コード中の TODO コメントは、対応するか、何をすべきかを説明してください。
```tsx
import { HoverNav } from "@/components/hover-nav";
// TODO: items の label / href をプロジェクトのルートに合わせて差し替えてください
// TODO: activeHref は固定値ではなく現在のパスを渡してください(例: "use client" の親で usePathname() の値)
// TODO: クライアント遷移にするなら onNavigate={(item, e) => { e.preventDefault(); router.push(item.href); }} を渡してください(関数なので親を "use client" に)
<HoverNav
variant="floating"
highlightColor="#ffffff"
indicator="dot"
size="md"
restOnActive={true}
springStiffness={420}
springDamping={34}
items={[
{ label: "プロダクト", href: "/product" },
{ label: "料金", href: "/pricing" },
{ label: "ドキュメント", href: "/docs" },
{ label: "ブログ", href: "/blog" },
{ label: "採用", href: "/careers" },
]}
activeHref="/pricing"
cta={{ label: "無料で始める", href: "/signup" }}
/>
```
### 4. 組み込み手順
1. ヘッダーのコンポーネントを `"use client"` にして `<HoverNav items={[…]} activeHref={pathname} />` を置く。`pathname` は `usePathname()`(`next/navigation`)の値。`onNavigate` は関数なので、Server Component から props として渡すことはできない。
2. 中央寄せのヘッダーなら `display: grid; grid-template-columns: 1fr auto 1fr` にしてロゴ / HoverNav / 右側要素を並べる。HoverNav のルートは `display: flex` で、自分の幅しか取らない。
3. リンクは素の `<a>` なのでフルページ遷移になる。クライアント遷移にするなら `onNavigate={(item, e) => { e.preventDefault(); router.push(item.href); }}` を渡す。`onNavigate` は CTA のクリックでも呼ばれる(第 1 引数が `cta` オブジェクト)。
4. 現在地は `activeHref` を優先し、無ければ `items[].active` を見る。一致した項目に `aria-current="page"` とドット / 下線が付く。下層ページ(`/docs/intro` など)でも親を光らせたいなら、`activeHref` に一致させたい項目の href を計算して渡す。
5. `restOnActive`(既定 true)はホバーしていないときブロブを現在地に置く。Vercel のように「ホバー中だけ出す」なら `restOnActive={false}`。
6. `variant="flat"` は背景も枠もない。既に背景のあるヘッダーに直接載せるとき用。
7. `npm run build` が通ることを確認し、「ホバーでブロブが滑る」「Tab / ← → でフォーカスとブロブが移動する」「スマホではブロブが出ずタップで縮むだけ」「OS の視差効果を減らす設定でブロブが瞬間移動になる」を確認する。
#### 触ってはいけないところ
| 症状 | 原因 | 対処 |
| --- | --- | --- |
| 視差効果を減らす設定でもブロブが滑る | `animate(motionValue, …)` は命令的 API で `MotionConfig` を読まない | `useReducedMotion()` が true のときは `.jump()` で瞬間移動させる(`MotionConfig reducedMotion="user"` はインジケーター用に残す) |
| ナビを 2 つ置くとインジケーターが行き来する | `layoutId` はページ全体で共有される | `layoutId` に `useId()` を前置する |
| ブロブの位置がずれる(フォント読み込み後・リサイズ後) | 和文 Web フォントは遅れて読み込まれ、ラベル幅が変わる | `ResizeObserver` でトラックとリストを監視し、乗っている項目に `jump` で合わせ直す |
| ブロブが一瞬左端に出てから移動する | 計測前に表示している | 初期 opacity 0、`useLayoutEffect` で計測して位置を `jump` してから表示する |
| スマホでブロブがタップした項目に取り残される | タッチでも `pointerenter` が発火する | `pointerType === "touch"` は無視。文字色の `:hover` も `@media (hover: hover)` の中だけに書く |
| ホバーのたびに再レンダーされる | ホバー位置を `useState` に入れている | 位置は `useMotionValue` と ref に持ち、React の state にしない |
| Chrome でバーのぼかしが効かない | `backdrop-filter` と `-webkit-backdrop-filter` を併記すると Next.js(Lightning CSS)が unprefixed を削る | **unprefixed だけ書く**。プレフィックスはビルド時に自動付与される |
| Server Component から渡すとエラー | `onNavigate` は関数でシリアライズできない | HoverNav を描画する親を `"use client"` にする |
### 5. 完了条件
- 型チェックとビルド(`npm run build` 相当)が通る
- 使用例を置いたページでコンポーネントが表示され、操作に反応する
- "use client" が維持され、不透明な背景のラッパーが追加されていない
- 使用例の TODO コメントが解消されている(または対応方法が説明されている)
- 「組み込み手順」にある作業がすべて済んでいる
## 付録: 見た目と挙動の仕様(レビュー用)
正となるのは上のソースコードです。以下は、実装後に見た目と挙動がギャラリーと一致しているかを確認するための仕様です。ソースを使えない事情がある場合は、この仕様を満たすように同じコンポーネントを実装してください。
### 前提
- Next.js(App Router)、React 19、TypeScript、CSS Modules、`framer-motion`(v12 以降)。アイコンライブラリは不要(CTA の矢印はインライン SVG)
- ファイル: `components/hover-nav/HoverNav.tsx`(`"use client"`)+ `HoverNav.module.css` + `index.ts`
- props: `items`(`{ label, href, active? }[]`)、`activeHref?`、`variant`(`"floating" | "flat"`、既定 floating)、`highlightColor`(既定 `#ffffff`)、`indicator`(`"dot" | "underline" | "none"`、既定 dot)、`size`(`"sm" | "md"`、既定 md)、`cta?`(`{ label, href }`)、`restOnActive`(既定 true)、`springStiffness`(420)/ `springDamping`(34)、`onNavigate?(item, event)`、`className`、`aria-label`(既定「メイン」)
- リンクは素の `<a>`(`next/link` は使わない)
### 見た目
- 構造: `<nav aria-label>` > `.bar` > [`.track`(ブロブ `<span>` + `<ul>`), CTA `<a>`]。ブロブは `<ul>` の兄弟(`<ul>` 直下に `<span>` は置かない)
- サイズ: md = 項目の高さ 34px・文字 14px・左右 padding 14px・バーの内側余白 5px / sm = 28px・13px・11px・4px
- floating のバー: 角丸 999px、background `linear-gradient(180deg, 白 .07, 白 .015)` + `rgba(14,14,18,.62)`、`backdrop-filter: blur(16px) saturate(160%)`(unprefixed のみ)、box-shadow `inset 0 0 0 1px 白 .08` / `0 1px 2px 黒 .4` / `0 14px 40px -10px 黒 .7`
- `::before` に feTurbulence の SVG ノイズ(opacity .06、overlay)、`::after` に上端 1px のリムライト(左右 14% を空けて中央が白 .32、両端は透明)
- flat: 背景・枠・影なし、内側余白 0、ブロブと項目の角丸は 10px
- 項目: 文字 500、色 白 .60 → アクティブ / ホバー / フォーカスで白(color 200ms)。`white-space: nowrap`
- ブロブ: 上下いっぱい、角丸 999px、`linear-gradient(180deg, ハイライト 9%, ハイライト 5%)`(`color-mix` で透過)、`inset 0 1px 0 ハイライト 12%` / `inset 0 0 0 1px ハイライト 7%`
- インジケーター: dot = 項目下端から 3px(sm 2px)・4px の円、underline = ラベル幅(左右 padding 分を空ける)・下端 1px・高さ 2px。どちらもハイライト色 + `0 0 10px` のグロー
- CTA: 項目と同じ高さ、白→`#e7e7ec` のグラデーションのピル、文字 `#0b0b0f` 600、ラベル右に 14px の矢印 SVG。ホバーで `brightness(.94)` と矢印が 2px 右へ
- フォーカスリング: `outline: 2px solid` ハイライト 60%、`outline-offset: -2px`(トラックが横スクロールで overflow を切るため内側に描く)
- 狭い画面ではトラックが横スクロール(スクロールバー非表示)
### モーション
- ブロブは `useMotionValue` の x / width / opacity で動かす(state にしない)。位置は `link.getBoundingClientRect().left - track.getBoundingClientRect().left + track.scrollLeft`、幅は link の幅
- 表示中なら `animate(x / width, 目標, { type: "spring", stiffness: 420, damping: 34, mass: .8 })` で滑る。非表示から出るときは位置を `jump` して opacity を 160ms でフェードイン(左端から飛んでこない)
- 優先順位: ポインタで乗っている項目 → キーボードフォーカス中の項目 → 現在地(`restOnActive`)。どれも無ければ 220ms でフェードアウト
- マウント時は `useLayoutEffect` で現在地に置いて即 opacity 1(最初のフレームから表示)。現在地が変わり、ホバー中でなければ新しい現在地へ滑る
- `pointerenter`(`pointerType === "touch"` は無視)で移動、トラックの `pointerleave` で優先順位に従って戻る
- `focus` は `:focus-visible` に一致するときだけブロブを引き寄せる(Chromium はクリックでもリンクにフォーカスするため)。フォーカスがトラック外に出たら戻る
- インジケーターは `layoutId`(`useId()` を前置)で現在地の変更に合わせて滑る。角丸は `style={{ borderRadius: 999 }}` で framer に補正させる
- タップ / クリック: ラベルを `:active` で `scale(.95)`(140ms)、CTA は `scale(.96)`
- `ResizeObserver` でトラックとリストを監視し、乗っている項目に `jump` で合わせ直す
- `prefers-reduced-motion`: `useReducedMotion()` が true ならブロブは `jump`(瞬間移動、フェードのみ残る)、インジケーターは `<MotionConfig reducedMotion="user">` で瞬時に切り替え、CSS の press scale と矢印の動きも無効
### アクセシビリティ
- `<nav aria-label="メイン">` + `<ul>` / `<li>` / `<a>`。全リンクが Tab 順に入る(ロービング tabindex にはしない)
- ← / → でリンク間のフォーカス移動(端でループ)、Home / End で先頭 / 末尾。現在地は変えない
- 現在地のリンクに `aria-current="page"`。ブロブとインジケーターは `aria-hidden`
- マウント時にフォーカスを奪わない
### 受け入れ条件
- 初期表示(ホバーなし)で現在地の項目にブロブとドットが表示されている
- ホバーで項目間をスプリングで滑り、バーから出ると現在地に戻る(`restOnActive={false}` ならフェードアウト)
- Tab / ← → でフォーカスした項目にブロブが移動し、フォーカスリングが見える
- `onNavigate` で `preventDefault()` すると遷移せず、項目でも CTA でも呼ばれる
- タッチ端末ではブロブが出ず、タップでラベルが縮む
- 視差効果を減らす設定でブロブ・インジケーターが瞬間移動になる
- フォント読み込みやリサイズ後もブロブが項目からずれないインストール
npx shadcn@latest add https://anima-js.vercel.app/r/hover-nav.json生成コード
import { HoverNav } from "@/components/hover-nav";
// TODO: items の label / href をプロジェクトのルートに合わせて差し替えてください
// TODO: activeHref は固定値ではなく現在のパスを渡してください(例: "use client" の親で usePathname() の値)
// TODO: クライアント遷移にするなら onNavigate={(item, e) => { e.preventDefault(); router.push(item.href); }} を渡してください(関数なので親を "use client" に)
<HoverNav
variant="floating"
highlightColor="#ffffff"
indicator="dot"
size="md"
restOnActive={true}
springStiffness={420}
springDamping={34}
items={[
{ label: "プロダクト", href: "/product" },
{ label: "料金", href: "/pricing" },
{ label: "ドキュメント", href: "/docs" },
{ label: "ブログ", href: "/blog" },
{ label: "採用", href: "/careers" },
]}
activeHref="/pricing"
cta={{ label: "無料で始める", href: "/signup" }}
/>