anima.js
ギャラリー
ナビゲーション

MagnifyDock

Motion

macOS 風の拡大ドック — ポインタに近いタイルほどスプリングで大きくなり、ラベルが上にポップ。キーボードフォーカスでも拡大し、タッチではタップ縮小に切り替わります。

AI に貼るだけで導入Claude Code / Cursor / ChatGPT などにそのまま貼り付けてください。ソースコード一式と現在の設定、組み込み手順が含まれています(約29 KB)。
# MagnifyDock をこのプロジェクトに追加してください

このプロンプトは自己完結しています。コンポーネントのソースコード一式が下に含まれているので、外部サイトへのアクセスや shadcn CLI は不要です。手順どおりにファイルを作成し、依存パッケージを追加し、使用例を配置して、そのまま動作する状態にしてください。

## コンポーネントについて
- 名前: MagnifyDock
- 説明: macOS 風の拡大ドック — ポインタに近いタイルほどスプリングで大きくなり、ラベルが上にポップ。キーボードフォーカスでも拡大し、タッチではタップ縮小に切り替わります。
- 実行時に必要な npm パッケージ: framer-motion, lucide-react
- 構成ファイル: `components/magnify-dock/index.tsx`, `components/magnify-dock/MagnifyDock.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 lucide-react
```

### 2. ファイルを作成する
`@/components` が指すディレクトリ(多くは `src/components/` か `components/`。shadcn の `components.json` があれば `aliases.components` の指す場所)に、以下の 2 ファイルを**一字一句そのまま**作成してください。

#### `components/magnify-dock/index.tsx`
```tsx
"use client";

import {
  useRef,
  type CSSProperties,
  type FocusEvent,
  type PointerEvent,
} from "react";
import {
  MotionConfig,
  motion,
  useMotionValue,
  useReducedMotion,
  useSpring,
  useTransform,
  type MotionValue,
} from "framer-motion";
import type { LucideIcon } from "lucide-react";
import styles from "./MagnifyDock.module.css";

/** Lets CSS custom properties pass the CSSProperties type check. */
type CSSVars = CSSProperties & Record<`--${string}`, string | number>;

/** One app tile. Renders an <a> when `href` is set, otherwise a <button>. */
export type MagnifyDockAction = {
  id: string;
  label: string;
  icon: LucideIcon;
  href?: string;
  onClick?: () => void;
  /** Shows the indicator dot (a "running app") and sets aria-current. */
  active?: boolean;
  /** Tile background for tileStyle="colorful"; defaults to the palette. */
  gradient?: string;
};

/** A thin vertical divider between groups (Finder | apps | Trash). */
export type MagnifyDockSeparator = { type: "separator"; id: string };

export type MagnifyDockItem = MagnifyDockAction | MagnifyDockSeparator;

export type MagnifyDockProps = {
  items: MagnifyDockItem[];
  /** Tile size at rest, in px. */
  baseSize?: number;
  /** Tile size right under the pointer, in px. */
  magnification?: number;
  /** How far (px) from the pointer tiles still grow. */
  distance?: number;
  /** Space between tiles, in px. */
  gap?: number;
  /** "hover": on hover / keyboard focus. "always": every tile. "never": none. */
  showLabels?: "hover" | "always" | "never";
  /** Dots under items marked `active`. */
  showIndicators?: boolean;
  springStiffness?: number;
  springDamping?: number;
  springMass?: number;
  /** "glass-dark": smoked glass tiles. "colorful": app-icon gradients. */
  tileStyle?: "glass-dark" | "colorful";
  className?: string;
  "aria-label"?: string;
};

const DEFAULT_BASE_SIZE = 52;
const DEFAULT_GAP = 10;

/** App-icon gradients for tileStyle="colorful", cycled by item index. */
const PALETTE = [
  "linear-gradient(160deg, #6ee7ff 0%, #2f7bff 100%)",
  "linear-gradient(160deg, #c4b5fd 0%, #7c3aed 100%)",
  "linear-gradient(160deg, #7dd3fc 0%, #0ea5e9 100%)",
  "linear-gradient(160deg, #fde68a 0%, #f97316 100%)",
  "linear-gradient(160deg, #fda4af 0%, #e11d48 100%)",
  "linear-gradient(160deg, #d1d5db 0%, #6b7280 100%)",
  "linear-gradient(160deg, #86efac 0%, #16a34a 100%)",
  "linear-gradient(160deg, #f9a8d4 0%, #db2777 100%)",
];

const isSeparator = (item: MagnifyDockItem): item is MagnifyDockSeparator =>
  "type" in item && item.type === "separator";

/** Horizontal center of an element in viewport px (same space as clientX). */
function centerX(el: Element) {
  const rect = el.getBoundingClientRect();
  return rect.left + rect.width / 2;
}

type TileProps = {
  item: MagnifyDockAction;
  index: number;
  mouseX: MotionValue<number>;
  baseSize: number;
  magnification: number;
  distance: number;
  spring: { stiffness: number; damping: number; mass: number };
  colorful: boolean;
  showIndicator: boolean;
};

/**
 * One tile. Its own component because every tile needs its own
 * useTransform / useSpring chain — hooks can't run inside items.map.
 */
function DockTile({
  item,
  index,
  mouseX,
  baseSize,
  magnification,
  distance,
  spring,
  colorful,
  showIndicator,
}: TileProps) {
  const ref = useRef<HTMLLIElement>(null);

  // Recomputed in framer's preRender step only when mouseX changes, so
  // the rect read never interleaves with the size writes of the same
  // frame and there is no feedback loop while springs settle.
  const size = useTransform(mouseX, (x) => {
    const el = ref.current;
    if (!el || !Number.isFinite(x)) return baseSize;
    const d = Math.abs(x - centerX(el));
    if (d >= distance) return baseSize;
    // Cosine falloff: flat at the peak and at the edge, like the real
    // dock, instead of a linear tent with a sharp tip.
    const t = (1 + Math.cos((Math.PI * d) / distance)) / 2;
    return baseSize + (magnification - baseSize) * t;
  });
  const animatedSize = useSpring(size, spring);

  const Icon = item.icon;
  const tileStyle: CSSVars | undefined = colorful
    ? { "--mdk-tile-bg": item.gradient ?? PALETTE[index % PALETTE.length] }
    : undefined;

  const content = (
    <>
      <span className={styles.tile} style={tileStyle} aria-hidden>
        <Icon className={styles.icon} strokeWidth={1.75} />
      </span>
      <span className={styles.tip} aria-hidden>
        {item.label}
      </span>
    </>
  );

  return (
    // The <li> is the flex slot: animating its width pushes the
    // neighbors aside, which is what makes it read as a dock and not a
    // row of zooming icons.
    <motion.li
      ref={ref}
      className={styles.slot}
      style={{ width: animatedSize, height: animatedSize }}
    >
      {item.href ? (
        <a
          href={item.href}
          className={styles.item}
          aria-label={item.label}
          aria-current={item.active ? "true" : undefined}
          onClick={item.onClick}
        >
          {content}
        </a>
      ) : (
        <button
          type="button"
          className={styles.item}
          aria-label={item.label}
          aria-current={item.active ? "true" : undefined}
          onClick={item.onClick}
        >
          {content}
        </button>
      )}
      {showIndicator && item.active ? (
        <span className={styles.dot} aria-hidden />
      ) : null}
    </motion.li>
  );
}

/**
 * macOS-style magnifying dock. Tiles grow with pointer proximity (a
 * shared mouseX motion value, no React state per frame), labels pop above
 * the hovered or keyboard-focused tile, and keyboard focus magnifies too
 * so the effect isn't mouse-only. Touch gets a tap scale instead of
 * magnification; reduced motion keeps every tile at rest size.
 */
export function MagnifyDock({
  items,
  baseSize = DEFAULT_BASE_SIZE,
  magnification = 84,
  distance = 150,
  gap = DEFAULT_GAP,
  showLabels = "hover",
  showIndicators = true,
  springStiffness = 260,
  springDamping = 20,
  springMass = 0.3,
  tileStyle = "glass-dark",
  className,
  "aria-label": ariaLabel = "ドック",
}: MagnifyDockProps) {
  // Infinity = "no pointer": every tile resolves to baseSize.
  const mouseX = useMotionValue(Number.POSITIVE_INFINITY);
  const pointerInside = useRef(false);
  const reduceMotion = useReducedMotion();
  // Never below the rest size, or tiles would shrink under the pointer.
  const peak = Math.max(magnification, baseSize);

  const onPointerMove = (e: PointerEvent<HTMLUListElement>) => {
    // Touch has no hover: magnifying under a finger only hides the tile
    // being tapped. Touch gets the CSS :active scale instead.
    if (e.pointerType === "touch" || reduceMotion) return;
    pointerInside.current = true;
    mouseX.set(e.clientX);
  };

  const onPointerLeave = () => {
    pointerInside.current = false;
    mouseX.set(Number.POSITIVE_INFINITY);
  };

  // Keyboard focus magnifies the focused tile as if the pointer were on
  // it. :focus-visible filters out focus from mouse clicks.
  const onFocus = (e: FocusEvent<HTMLUListElement>) => {
    if (reduceMotion || pointerInside.current) return;
    const target = e.target as HTMLElement;
    if (!target.matches(":focus-visible")) return;
    mouseX.set(centerX(target));
  };

  const onBlur = (e: FocusEvent<HTMLUListElement>) => {
    if (pointerInside.current) return;
    // Focus moving to another tile is handled by its own focus event.
    if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
    mouseX.set(Number.POSITIVE_INFINITY);
  };

  const spring = {
    stiffness: springStiffness,
    damping: springDamping,
    mass: springMass,
  };

  // 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 (baseSize !== DEFAULT_BASE_SIZE) vars["--mdk-base"] = `${baseSize}px`;
  if (gap !== DEFAULT_GAP) vars["--mdk-gap"] = `${gap}px`;

  return (
    <MotionConfig reducedMotion="user">
      <nav
        className={className ? `${styles.root} ${className}` : styles.root}
        aria-label={ariaLabel}
        data-labels={showLabels}
        data-tiles={tileStyle}
        style={vars}
      >
        <ul
          className={styles.bar}
          onPointerMove={onPointerMove}
          onPointerLeave={onPointerLeave}
          onFocus={onFocus}
          onBlur={onBlur}
        >
          {items.map((item, index) =>
            isSeparator(item) ? (
              // Purely visual grouping; hidden so the list reads as N links.
              <li key={item.id} className={styles.separator} aria-hidden />
            ) : (
              <DockTile
                key={item.id}
                item={item}
                index={index}
                mouseX={mouseX}
                baseSize={baseSize}
                magnification={peak}
                distance={Math.max(distance, 1)}
                spring={spring}
                colorful={tileStyle === "colorful"}
                showIndicator={showIndicators}
              />
            ),
          )}
        </ul>
      </nav>
    </MotionConfig>
  );
}

export default MagnifyDock;
```

#### `components/magnify-dock/MagnifyDock.module.css`
```css
/* ==========================================================================
   Magnify dock — macOS-style bar of tiles that grow toward the pointer

   A smoked-glass bar on a dark page. Tile size is driven by framer-motion
   (inline width/height on each slot); everything else — tooltips, tap
   scale, indicator dots — 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. */
  --mdk-base: 52px; /* tile size at rest */
  --mdk-gap: 10px;
  --mdk-pad: 10px; /* bar edge → resting tiles */
  --mdk-tile-bg: linear-gradient(
    180deg,
    rgba(255, 255, 255, 0.16),
    rgba(255, 255, 255, 0.05)
  );

  display: flex;
  justify-content: center;
  font-family:
    -apple-system, BlinkMacSystemFont, "Hiragino Sans",
    "Hiragino Kaku Gothic ProN", "Noto Sans JP", "Segoe UI", Roboto,
    sans-serif;
  color: #fff;
}

/* --- The bar. Fixed height; tiles are bottom-aligned and grow upward out
   of it, so the bar never jumps vertically while magnifying. --- */
.bar {
  position: relative;
  isolation: isolate;
  display: flex;
  align-items: flex-end;
  gap: var(--mdk-gap);
  box-sizing: border-box;
  height: calc(var(--mdk-base) + var(--mdk-pad) * 2);
  margin: 0;
  padding: var(--mdk-pad);
  list-style: none;
  border-radius: calc(var(--mdk-base) * 0.36 + var(--mdk-pad));
  background:
    linear-gradient(180deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.03)),
    rgba(18, 18, 24, 0.42);
  /* Unprefixed only: the build adds -webkit-, and writing both makes
     Lightning CSS drop the unprefixed one (no blur in Chrome). */
  backdrop-filter: blur(24px) saturate(180%);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.2),
    inset 0 0 0 1px rgba(255, 255, 255, 0.08),
    0 2px 8px rgba(0, 0, 0, 0.3),
    0 24px 60px rgba(0, 0, 0, 0.5);
}

/* Film grain — keeps the glass from looking like a flat vector fill. */
.bar::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  border-radius: inherit;
  pointer-events: none;
  opacity: 0.07;
  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");
}

@supports not (backdrop-filter: blur(1px)) {
  .bar {
    background: rgba(28, 28, 34, 0.94);
  }
}

/* --- Slot: the flex item whose width/height framer animates. --- */
.slot {
  position: relative;
  flex: none;
  /* Rest size before hydration; the inline style takes over after. */
  width: var(--mdk-base);
  height: var(--mdk-base);
}

.item {
  position: relative;
  display: block;
  width: 100%;
  height: 100%;
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: 24%;
  background: none;
  color: inherit;
  font: inherit;
  text-decoration: none;
  cursor: pointer;
  outline: none;
  -webkit-tap-highlight-color: transparent;
}

.item:focus-visible {
  outline: 2px solid #fff;
  outline-offset: 3px;
}

/* --- Tile: squircle-ish rounded square holding the icon. --- */
.tile {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  overflow: hidden;
  border-radius: 24%;
  background: var(--mdk-tile-bg);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.28),
    inset 0 0 0 1px rgba(255, 255, 255, 0.08),
    0 6px 16px rgba(0, 0, 0, 0.35);
  transition: transform 160ms cubic-bezier(0.2, 0.8, 0.2, 1);
}

/* Specular gloss on the upper half, shared by both tile styles. */
.tile::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: linear-gradient(
    180deg,
    rgba(255, 255, 255, 0.22) 0%,
    rgba(255, 255, 255, 0.04) 45%,
    transparent 55%
  );
  pointer-events: none;
}

.icon {
  position: relative;
  width: 48%;
  height: 48%;
  color: rgba(255, 255, 255, 0.92);
}

[data-tiles="colorful"] .tile {
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.4),
    inset 0 -1px 0 rgba(0, 0, 0, 0.18),
    0 6px 16px rgba(0, 0, 0, 0.4);
}

[data-tiles="colorful"] .icon {
  color: #fff;
  filter: drop-shadow(0 1px 1.5px rgba(0, 0, 0, 0.3));
}

/* Tap feedback — the touch replacement for magnification. Lives on the
   inner tile so it never collides with framer's inline slot size. */
.item:active .tile {
  transform: scale(0.9);
}

/* --- Tooltip label above the tile. Positioned against the item, so it
   rides up as the tile grows. --- */
.tip {
  position: absolute;
  bottom: calc(100% + 12px);
  left: 50%;
  padding: 5px 10px;
  border-radius: 8px;
  background: rgba(30, 30, 36, 0.82);
  backdrop-filter: blur(12px);
  box-shadow:
    inset 0 0 0 1px rgba(255, 255, 255, 0.1),
    0 8px 24px rgba(0, 0, 0, 0.4);
  font-size: 12px;
  font-weight: 500;
  line-height: 1.2;
  letter-spacing: 0.01em;
  white-space: nowrap;
  color: rgba(255, 255, 255, 0.95);
  pointer-events: none;
  opacity: 0;
  transform: translate(-50%, 6px) scale(0.9);
  transform-origin: 50% 100%;
  transition:
    opacity 140ms ease-out,
    transform 260ms cubic-bezier(0.34, 1.56, 0.64, 1);
}

/* Small caret pointing at the tile. */
.tip::after {
  content: "";
  position: absolute;
  top: 100%;
  left: 50%;
  width: 8px;
  height: 8px;
  margin: -4px 0 0 -4px;
  background: inherit;
  transform: rotate(45deg);
  border-radius: 1px;
  clip-path: polygon(100% 0, 100% 100%, 0 100%);
}

[data-labels="hover"] .item:focus-visible .tip {
  opacity: 1;
  transform: translate(-50%, 0) scale(1);
}

/* Hover only where hover is real: on touch, :hover sticks after a tap. */
@media (hover: hover) {
  [data-labels="hover"] .item:hover .tip {
    opacity: 1;
    transform: translate(-50%, 0) scale(1);
  }
}

/* "always": quieter caption text instead of a stack of bubbles. */
[data-labels="always"] .tip {
  bottom: calc(100% + 6px);
  padding: 0;
  background: none;
  backdrop-filter: none;
  box-shadow: none;
  font-size: 11px;
  color: rgba(255, 255, 255, 0.78);
  text-shadow: 0 1px 4px rgba(0, 0, 0, 0.6);
  opacity: 1;
  transform: translate(-50%, 0);
}

[data-labels="always"] .tip::after,
[data-labels="never"] .tip {
  display: none;
}

/* --- "Running app" dot, in the bar's bottom padding. --- */
.dot {
  position: absolute;
  bottom: calc(var(--mdk-pad) * -1 + 3px);
  left: 50%;
  width: 4px;
  height: 4px;
  margin-left: -2px;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.85);
  box-shadow: 0 0 6px rgba(255, 255, 255, 0.6);
  pointer-events: none;
}

/* --- Separator between groups. --- */
.separator {
  flex: none;
  align-self: center;
  width: 1px;
  height: calc(var(--mdk-base) * 0.78);
  margin: 0 calc(var(--mdk-gap) * 0.3);
  background: linear-gradient(
    180deg,
    transparent,
    rgba(255, 255, 255, 0.26) 20%,
    rgba(255, 255, 255, 0.26) 80%,
    transparent
  );
}

@media (prefers-reduced-motion: reduce) {
  .tile {
    transition: none;
  }

  .tip {
    transform: translate(-50%, 0);
    transition: opacity 140ms ease-out;
  }

  [data-labels="hover"] .item:focus-visible .tip {
    transform: translate(-50%, 0);
  }
}

@media (prefers-reduced-motion: reduce) and (hover: hover) {
  [data-labels="hover"] .item:hover .tip {
    transform: translate(-50%, 0);
  }
}
```

代替手段: `components.json` がありシェルを実行できる環境なら、shadcn CLI でも同じファイルと依存パッケージが入ります。
```bash
npx shadcn@latest add https://anima-js.vercel.app/r/magnify-dock.json
```

### 3. 使用例を配置する
以下は配信元のプレイグラウンドで設定されていた値をそのまま反映した使用例です。これを基に、適切なページ・レイアウトへ配置してください。コード中の TODO コメントは、対応するか、何をすべきかを説明してください。
```tsx
import { MagnifyDock } from "@/components/magnify-dock";
import { House, Search, Mail, ImageIcon, Music, Settings, Trash2 } from "lucide-react";

// TODO: items の各アイテムに href(リンク)か onClick(ボタン)を渡して遷移・処理をつなぎ、label / icon を差し替えてください(アイコンは lucide-react から選べます)
// TODO: active は「起動中 / 現在地」のドットと aria-current に使われます。現在のページやアプリの状態に合わせて付け替えてください
<MagnifyDock
  baseSize={52}
  magnification={84}
  gap={10}
  tileStyle="glass-dark"
  showLabels="hover"
  showIndicators={true}
  distance={150}
  springStiffness={260}
  springDamping={20}
  springMass={0.3}
  items={[
    { id: "home", label: "ホーム", icon: House, active: true },
    { id: "search", label: "検索", icon: Search },
    { id: "mail", label: "メール", icon: Mail, active: true },
    { id: "photos", label: "写真", icon: ImageIcon },
    { id: "music", label: "音楽", icon: Music },
    { id: "settings", label: "設定", icon: Settings },
    { type: "separator", id: "sep" },
    { id: "trash", label: "ゴミ箱", icon: Trash2 },
  ]}
/>
```

### 4. 組み込み手順
1. ドックを置くコンポーネントを `"use client"` にして `<MagnifyDock items={[…]} />` を描画する。`icon`(lucide-react のコンポーネント)や `onClick` は関数なので、Server Component から props として渡すことはできない。
2. 画面下に固定するなら、ラッパーで配置する: `<div style={{ position: "fixed", insetInline: 0, bottom: 16, zIndex: 40 }}><MagnifyDock … /></div>`。ルートは `display: flex; justify-content: center` なので、幅いっぱいのラッパーに入れれば中央に来る。
3. 各アイテムに `href`(`<a>` になる)か `onClick`(`<button>` になる)を渡す。`href` は素の `<a>` なのでフルページ遷移になる。クライアント遷移が必要なら `onClick` で `router.push()` を呼ぶ。
4. 区切り線は `{ type: "separator", id: "sep" }` を items に入れる。`id` は React の key に使うので一意にする。
5. `active: true` のアイテムには下にドットが出て、`aria-current="true"` が付く。現在のページ / 起動中の状態に合わせて付け替える。
6. 拡大したタイルとラベルはバーの**上にはみ出す**。ドックの上に `magnification + 40px` 程度の空きを確保する。
7. `npm run build` が通ることを確認し、「ポインタで波のように拡大する」「Tab でフォーカスしたタイルも拡大しラベルが出る」「スマホではタップで縮むだけ」を確認する。

#### 触ってはいけないところ
| 症状 | 原因 | 対処 |
| --- | --- | --- |
| 拡大したタイルやラベルの上が切れる | 祖先要素に `overflow: hidden` がある | ドックの祖先では `overflow: visible` にするか、上に十分な余白を取る |
| 拡大すると左右に偏って伸びる | 固定幅の親の中で左寄せになっている | ドックを幅いっぱいのラッパーに入れ、ルートの `justify-content: center` を崩さない |
| Chrome でバーのぼかしが効かない | `backdrop-filter` と `-webkit-backdrop-filter` を併記すると Next.js(Lightning CSS)が unprefixed を削る | **unprefixed だけ書く**。プレフィックスはビルド時に自動付与される |
| Server Component から渡すとエラー | `icon` / `onClick` は関数でシリアライズできない | items を組み立てるコンポーネントを `"use client"` にする |
| タイルごとの `useTransform` / `useSpring` でフックのエラー | フックを `items.map` の中で直接呼んでいる | タイルは子コンポーネント(`DockTile`)に分け、フックはその中で呼ぶ |
| マウス移動でカクつく / 再レンダーが多い | ポインタ座標を `useState` に入れている | 座標は `useMotionValue` に `set` するだけにする(React の再レンダーを起こさない) |
| 視差効果オフでも拡大する | framer-motion は JS で動くので CSS の reduced-motion が効かない | `useReducedMotion()` が true のときはポインタ座標を更新しない。`<MotionConfig reducedMotion="user">` も外さない |

### 5. 完了条件
- 型チェックとビルド(`npm run build` 相当)が通る
- 使用例を置いたページでコンポーネントが表示され、操作に反応する
- "use client" が維持され、不透明な背景のラッパーが追加されていない
- 使用例の TODO コメントが解消されている(または対応方法が説明されている)
- 「組み込み手順」にある作業がすべて済んでいる

## 付録: 見た目と挙動の仕様(レビュー用)
正となるのは上のソースコードです。以下は、実装後に見た目と挙動がギャラリーと一致しているかを確認するための仕様です。ソースを使えない事情がある場合は、この仕様を満たすように同じコンポーネントを実装してください。
### 前提
- Next.js(App Router)、React 19、TypeScript、CSS Modules、`framer-motion`(v12 以降)、`lucide-react`(アイコン)
- ファイル: `components/magnify-dock/MagnifyDock.tsx`(`"use client"`)+ `MagnifyDock.module.css` + `index.ts`
- props: `items`(`{ id, label, icon: LucideIcon, href?, onClick?, active?, gradient? }` または `{ type: "separator", id }`)、`baseSize`(52)、`magnification`(84)、`distance`(150)、`gap`(10)、`showLabels`(`"hover" | "always" | "never"`、既定 hover)、`showIndicators`(true)、`springStiffness`(260)/ `springDamping`(20)/ `springMass`(0.3)、`tileStyle`(`"glass-dark" | "colorful"`)、`className`、`aria-label`(既定「ドック」)
- 横向きのみ

### 見た目
- ルート `<nav>`: `display: flex; justify-content: center`。フォント `-apple-system, BlinkMacSystemFont, "Hiragino Sans", "Noto Sans JP", "Segoe UI", sans-serif`
- バー `<ul>`: `display: flex; align-items: flex-end; gap`、高さは `baseSize + 20px` に固定(padding 10px)。タイルは下揃えで、拡大分はバーの**上にはみ出す**(バー自体は縦に動かない)
  - 角丸 `baseSize × .36 + 10px`
  - background: `linear-gradient(180deg, 白 .10, 白 .03)` + `rgba(18,18,24,.42)`、`backdrop-filter: blur(24px) saturate(180%)`(unprefixed のみ)
  - box-shadow: `inset 0 1px 0 白 .20` / `inset 0 0 0 1px 白 .08` / `0 2px 8px 黒 .3` / `0 24px 60px 黒 .5`
  - `::before` に feTurbulence の SVG ノイズ(opacity .07、overlay)でフィルムグレイン
- タイル: 角丸 24% の正方形、アイコンは 48% サイズ・線幅 1.75
  - glass-dark: `linear-gradient(180deg, 白 .16, 白 .05)`、`inset 0 1px 0 白 .28` / `inset 0 0 0 1px 白 .08` / `0 6px 16px 黒 .35`、アイコン白 .92
  - colorful: アイテム順にアプリアイコン風のグラデーション(シアン→青、ラベンダー→紫、オレンジ、ピンク→赤、グレー、緑…)。`gradient` で個別指定可
  - 共通: 上半分にスペキュラーの光沢(白 .22 → .04 at 45% → 0 at 55%)
- ラベル(hover): タイル上 12px にダークのすりガラス吹き出し(`rgba(30,30,36,.82)`、blur 12px、12px / 500、角丸 8px、下向きのキャレット)
- ラベル(always): 吹き出しなしの 11px キャプション(白 .78 + text-shadow)
- インジケーター: active のタイル下、バーの下 padding 内に 4px の白ドット(グロー付き)
- 区切り線: 幅 1px・高さ `baseSize × .78`、上下がフェードする白 .26 のライン

### モーション
- ポインタ X を `useMotionValue`(初期値・離脱時 `Infinity`)に保持。`<ul>` の `pointermove` で `clientX` を `set`(`pointerType === "touch"` は無視)、`pointerleave` で `Infinity`
- タイルごと: `useTransform(mouseX, x => …)` でタイル中心(`getBoundingClientRect`)との距離 d を出し、`d < distance` なら余弦カーブ `(1 + cos(π·d/distance)) / 2` で `baseSize → magnification` を補間。`useSpring`(stiffness 260 / damping 20 / mass 0.3)を通して `<li>` の width / height に適用(隣のタイルが押し広げられる)
- キーボード: `:focus-visible` のフォーカスでそのタイル中心を mouseX に入れて拡大。ドック外にフォーカスが出たら `Infinity`
- ラベル: CSS のみ。opacity 0 → 1(140ms)、`translateY(6px) scale(.9) → 0 / 1`(260ms、`cubic-bezier(.34,1.56,.64,1)` で軽くオーバーシュート)。`:hover` は `@media (hover: hover)` の中だけ
- タップ: `:active` で内側のタイルを `scale(.9)`(160ms)。タッチでは拡大しない
- `prefers-reduced-motion`: 拡大しない(mouseX を更新しない)、ラベルはフェードのみ、`<MotionConfig reducedMotion="user">`

### アクセシビリティ
- `<nav aria-label>` → `<ul>` → `<li>`。各アイテムは `href` があれば `<a>`、なければ `<button type="button">`。`aria-label={label}`、吹き出しとアイコンは `aria-hidden`
- `active` は `aria-current="true"`
- 区切り線は `aria-hidden`(リンク数だけが読み上げられる)
- `:focus-visible` で白 2px アウトライン(offset 3px)

### 受け入れ条件
- ポインタを横に動かすと、近いタイルほど大きくなる波がなめらかに追従し、離れると元のサイズへ戻る
- 拡大中もバーの高さと下端は動かず、タイルとラベルが上にはみ出す
- Tab で移動するとフォーカス中のタイルが拡大し、ラベルが出る
- タッチ端末では拡大せず、タップで縮むフィードバックだけが出る
- 視差効果を減らす設定ではすべて基本サイズのまま
インストール
npx shadcn@latest add https://anima-js.vercel.app/r/magnify-dock.json
生成コード
import { MagnifyDock } from "@/components/magnify-dock";
import { House, Search, Mail, ImageIcon, Music, Settings, Trash2 } from "lucide-react";

// TODO: items の各アイテムに href(リンク)か onClick(ボタン)を渡して遷移・処理をつなぎ、label / icon を差し替えてください(アイコンは lucide-react から選べます)
// TODO: active は「起動中 / 現在地」のドットと aria-current に使われます。現在のページやアプリの状態に合わせて付け替えてください
<MagnifyDock
  baseSize={52}
  magnification={84}
  gap={10}
  tileStyle="glass-dark"
  showLabels="hover"
  showIndicators={true}
  distance={150}
  springStiffness={260}
  springDamping={20}
  springMass={0.3}
  items={[
    { id: "home", label: "ホーム", icon: House, active: true },
    { id: "search", label: "検索", icon: Search },
    { id: "mail", label: "メール", icon: Mail, active: true },
    { id: "photos", label: "写真", icon: ImageIcon },
    { id: "music", label: "音楽", icon: Music },
    { id: "settings", label: "設定", icon: Settings },
    { type: "separator", id: "sep" },
    { id: "trash", label: "ゴミ箱", icon: Trash2 },
  ]}
/>