ボタン
MagneticButton
Motion
アワードサイト風のマグネティック CTA — ポインターが近づくとスプリングで引き寄せられ、ラベルは枠より大きく動く視差つき。ホバーで入った点から色が円形に広がり文字色が反転する。
anima.js · Magnetic CTA
指先に、吸い寄せられる。
ポインターが近づくとボタンが引き寄せられ、触れた点から色が広がる。
AI に貼るだけで導入Claude Code / Cursor / ChatGPT などにそのまま貼り付けてください。ソースコード一式と現在の設定、組み込み手順が含まれています(約29 KB)。
# MagneticButton をこのプロジェクトに追加してください
このプロンプトは自己完結しています。コンポーネントのソースコード一式が下に含まれているので、外部サイトへのアクセスや shadcn CLI は不要です。手順どおりにファイルを作成し、依存パッケージを追加し、使用例を配置して、そのまま動作する状態にしてください。
## コンポーネントについて
- 名前: MagneticButton
- 説明: アワードサイト風のマグネティック CTA — ポインターが近づくとスプリングで引き寄せられ、ラベルは枠より大きく動く視差つき。ホバーで入った点から色が円形に広がり文字色が反転する。
- 実行時に必要な npm パッケージ: framer-motion, lucide-react
- 構成ファイル: `components/magnetic-button/index.tsx`, `components/magnetic-button/MagneticButton.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/magnetic-button/index.tsx`
```tsx
"use client";
import {
useEffect,
useRef,
type CSSProperties,
type MouseEventHandler,
type PointerEvent as ReactPointerEvent,
type ReactNode,
} from "react";
import {
MotionConfig,
motion,
useMotionValue,
useReducedMotion,
useSpring,
} from "framer-motion";
import { ArrowRight } from "lucide-react";
import styles from "./MagneticButton.module.css";
/** Lets CSS custom properties pass the CSSProperties type check. */
type CSSVars = CSSProperties & Record<`--${string}`, string | number>;
export type MagneticButtonVariant = "solid" | "outline" | "ghost";
export type MagneticButtonSize = "sm" | "md" | "lg";
export type MagneticButtonProps = {
children: ReactNode;
/** Renders an <a href> instead of a <button>. */
href?: string;
onClick?: MouseEventHandler<HTMLButtonElement | HTMLAnchorElement>;
variant?: MagneticButtonVariant;
size?: MagneticButtonSize;
/** 0–1. How far the button follows the pointer; 0 turns the magnet off. */
strength?: number;
/** Distance in px from the button's edge at which the pull starts. */
radius?: number;
/**
* Base color — the background of "solid", the border and label of
* "outline" / "ghost".
*/
color?: string;
/** Label color on the "solid" base (outline / ghost use `color`). */
textColor?: string;
/** Color of the hover blob that spreads from the pointer entry point. */
fillColor?: string;
/** Label color once the blob has filled the button. */
fillTextColor?: string;
/** Trailing arrow that nudges right on hover. */
showArrow?: boolean;
/** Spring stiffness of the magnetic follow. */
springStiffness?: number;
/** Spring damping of the magnetic follow. */
springDamping?: number;
disabled?: boolean;
/** <button> type. Ignored when `href` is set. */
type?: "button" | "submit" | "reset";
/** Applied to the outer wrapper (the static box that is measured). */
className?: string;
"aria-label"?: string;
};
const DEFAULT_COLOR = "#f5f5f7";
const DEFAULT_TEXT_COLOR = "#0a0a0a";
const DEFAULT_FILL_COLOR = "#7c5cff";
const DEFAULT_FILL_TEXT_COLOR = "#ffffff";
const DEFAULT_STRENGTH = 0.45;
const DEFAULT_RADIUS = 90;
const DEFAULT_SPRING_STIFFNESS = 220;
const DEFAULT_SPRING_DAMPING = 18;
/**
* Share of the pointer offset the shell follows; the label follows an
* extra share on top, so it travels further than its container — the
* parallax that makes the button feel like it has depth.
*/
const SHELL_PULL = 0.4;
const LABEL_PULL = 0.22;
const FINE_POINTER = "(hover: hover) and (pointer: fine)";
const differs = (value: string | undefined, fallback: string) =>
value !== undefined && value.toLowerCase() !== fallback;
/**
* Award-site style magnetic CTA. Within `radius` px the button springs
* toward the pointer (the label further than the shell), and on hover a
* fill blob grows from the exact point the pointer came in, flipping the
* label color. Touch gets only the press scale; reduced motion gets a
* plain color change.
*/
export function MagneticButton({
children,
href,
onClick,
variant = "solid",
size = "md",
strength = DEFAULT_STRENGTH,
radius = DEFAULT_RADIUS,
color,
textColor,
fillColor,
fillTextColor,
showArrow = false,
springStiffness = DEFAULT_SPRING_STIFFNESS,
springDamping = DEFAULT_SPRING_DAMPING,
disabled = false,
type = "button",
className,
"aria-label": ariaLabel,
}: MagneticButtonProps) {
const rootRef = useRef<HTMLSpanElement>(null);
const reduceMotion = useReducedMotion();
const magnetic = !disabled && !reduceMotion && strength > 0;
// Raw targets are written from the pointer loop; the springs smooth
// them. Nothing here goes through React state, so a pointer move never
// re-renders the component.
const spring = { stiffness: springStiffness, damping: springDamping, mass: 0.6 };
const rawX = useMotionValue(0);
const rawY = useMotionValue(0);
const rawLabelX = useMotionValue(0);
const rawLabelY = useMotionValue(0);
const x = useSpring(rawX, spring);
const y = useSpring(rawY, spring);
const labelX = useSpring(rawLabelX, spring);
const labelY = useSpring(rawLabelY, spring);
// Why a window listener and not an enlarged invisible hit area: a hit
// area `radius` px wide would sit on top of neighbouring links and eat
// their clicks and hovers. Instead one passive window listener runs —
// only while the button is on screen, with a fine pointer, and motion
// allowed — and the math is batched to one read per animation frame.
useEffect(() => {
const root = rootRef.current;
if (!root || !magnetic) return;
if (!window.matchMedia(FINE_POINTER).matches) return;
let frame = 0;
let pointerX = 0;
let pointerY = 0;
let listening = false;
const reset = () => {
rawX.set(0);
rawY.set(0);
rawLabelX.set(0);
rawLabelY.set(0);
};
const update = () => {
frame = 0;
// The wrapper never moves (the transform lives on the shell inside
// it), so its rect is the button's rest position and the pull can't
// feed back into its own measurement.
const rect = root.getBoundingClientRect();
const dx = pointerX - (rect.left + rect.width / 2);
const dy = pointerY - (rect.top + rect.height / 2);
// Distance from the edge rather than the centre: wide and narrow
// buttons then get the same reach.
const edgeX = Math.max(Math.abs(dx) - rect.width / 2, 0);
const edgeY = Math.max(Math.abs(dy) - rect.height / 2, 0);
const edge = Math.hypot(edgeX, edgeY);
if (edge >= radius) {
reset();
return;
}
// Fades to zero at the range boundary, so entering and leaving the
// field never snaps.
const falloff = 1 - edge / radius;
const pull = strength * falloff;
rawX.set(dx * pull * SHELL_PULL);
rawY.set(dy * pull * SHELL_PULL);
rawLabelX.set(dx * pull * LABEL_PULL);
rawLabelY.set(dy * pull * LABEL_PULL);
};
const onMove = (event: PointerEvent) => {
if (event.pointerType === "touch") return;
pointerX = event.clientX;
pointerY = event.clientY;
if (!frame) frame = requestAnimationFrame(update);
};
// Pointer left the window: let go instead of freezing mid-pull.
const onOut = (event: PointerEvent) => {
if (!event.relatedTarget) reset();
};
const start = () => {
if (listening) return;
listening = true;
window.addEventListener("pointermove", onMove, { passive: true });
document.addEventListener("pointerout", onOut);
};
const stop = () => {
if (!listening) return;
listening = false;
window.removeEventListener("pointermove", onMove);
document.removeEventListener("pointerout", onOut);
if (frame) cancelAnimationFrame(frame);
frame = 0;
reset();
};
// Off-screen buttons don't listen at all. The margin keeps the field
// alive for a button just past the viewport edge.
const observer = new IntersectionObserver(
([entry]) => (entry.isIntersecting ? start() : stop()),
{ rootMargin: `${radius}px` },
);
observer.observe(root);
return () => {
observer.disconnect();
stop();
};
}, [magnetic, radius, strength, rawX, rawY, rawLabelX, rawLabelY]);
// The blob: grows from where the pointer entered and shrinks toward
// where it left. Written straight to the DOM (custom properties + a data
// attribute) — CSS runs the transition, React stays out of it.
const setOrigin = (event: ReactPointerEvent<HTMLElement>) => {
const shell = event.currentTarget;
const rect = shell.getBoundingClientRect();
// Percentages, not px: they stay right inside a CSS-scaled container
// (e.g. a zoomed preview), where rect px and layout px differ.
const px = ((event.clientX - rect.left) / rect.width) * 100;
const py = ((event.clientY - rect.top) / rect.height) * 100;
shell.style.setProperty("--mb-x", `${px}%`);
shell.style.setProperty("--mb-y", `${py}%`);
};
const onPointerEnter = (event: ReactPointerEvent<HTMLElement>) => {
if (disabled || event.pointerType === "touch") return;
setOrigin(event);
rootRef.current?.setAttribute("data-hovered", "");
};
const onPointerLeave = (event: ReactPointerEvent<HTMLElement>) => {
if (event.pointerType === "touch") return;
setOrigin(event);
rootRef.current?.removeAttribute("data-hovered");
};
// 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 (differs(color, DEFAULT_COLOR)) vars["--mb-color"] = color!;
if (differs(textColor, DEFAULT_TEXT_COLOR)) vars["--mb-text"] = textColor!;
if (differs(fillColor, DEFAULT_FILL_COLOR)) vars["--mb-fill"] = fillColor!;
if (differs(fillTextColor, DEFAULT_FILL_TEXT_COLOR)) {
vars["--mb-fill-text"] = fillTextColor!;
}
const inner = (
<>
<span className={styles.fill} aria-hidden />
<motion.span className={styles.label} style={{ x: labelX, y: labelY }}>
<span className={styles.text}>{children}</span>
{showArrow ? (
<ArrowRight className={styles.arrow} strokeWidth={2.2} aria-hidden />
) : null}
</motion.span>
</>
);
const shared = {
className: styles.shell,
style: { x, y },
whileTap: disabled ? undefined : { scale: 0.96 },
transition: { type: "spring", stiffness: 500, damping: 30 },
onPointerEnter,
onPointerLeave,
onClick: disabled ? undefined : onClick,
"aria-label": ariaLabel,
} as const;
return (
// Follows the OS "reduce motion" setting for the press scale too.
<MotionConfig reducedMotion="user">
<span
ref={rootRef}
className={className ? `${styles.root} ${className}` : styles.root}
data-variant={variant}
data-size={size}
data-disabled={disabled || undefined}
style={vars}
>
{href !== undefined ? (
// A disabled link drops its href so it can't be followed or
// focused; aria-disabled keeps it announced as unavailable.
<motion.a
{...shared}
href={disabled ? undefined : href}
role={disabled ? "link" : undefined}
aria-disabled={disabled || undefined}
>
{inner}
</motion.a>
) : (
<motion.button {...shared} type={type} disabled={disabled}>
{inner}
</motion.button>
)}
</span>
</MotionConfig>
);
}
export default MagneticButton;
```
#### `components/magnetic-button/MagneticButton.module.css`
```css
/* ==========================================================================
Magnetic CTA button
Three layers inside a pill: the shell (base color, moved by the magnet),
the fill blob (clipped circle that grows from the pointer entry point)
and the label (moves further than the shell for parallax). The outer
.root never moves — it is the measured rest position.
========================================================================== */
.root {
/* Spec colors. The component only sets these inline for non-default
values, so the default render is exactly this stylesheet. */
--mb-color: #f5f5f7; /* solid background / outline + ghost label */
--mb-text: #0a0a0a; /* label on the solid base */
--mb-fill: #7c5cff; /* hover blob */
--mb-fill-text: #ffffff; /* label over the blob */
--mb-height: 48px;
--mb-pad: 24px;
--mb-font: 15px;
--mb-gap: 8px;
--mb-arrow: 17px;
position: relative;
display: inline-flex;
vertical-align: middle;
font-family:
"Inter", -apple-system, BlinkMacSystemFont, "Hiragino Sans",
"Hiragino Kaku Gothic ProN", "Noto Sans JP", "Segoe UI", Roboto,
sans-serif;
}
.root[data-size="sm"] {
--mb-height: 36px;
--mb-pad: 16px;
--mb-font: 13px;
--mb-gap: 6px;
--mb-arrow: 14px;
}
.root[data-size="lg"] {
--mb-height: 60px;
--mb-pad: 32px;
--mb-font: 17px;
--mb-gap: 10px;
--mb-arrow: 19px;
}
/* --- Shell: the pill. Moved by framer-motion (magnet + press scale). --- */
.shell {
position: relative;
isolation: isolate;
overflow: hidden; /* clips the blob; its own box-shadow glow still shows */
display: inline-flex;
align-items: center;
justify-content: center;
height: var(--mb-height);
padding: 0 var(--mb-pad);
margin: 0;
border: 0;
border-radius: 999px;
background: transparent;
font: inherit;
font-size: var(--mb-font);
font-weight: 560;
letter-spacing: -0.01em;
line-height: 1;
white-space: nowrap;
text-decoration: none;
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
will-change: transform;
transition:
box-shadow 0.4s ease,
opacity 0.2s ease;
}
.shell:focus-visible {
outline: 2px solid var(--mb-fill);
outline-offset: 3px;
}
/* Solid: strong base with a top sheen and a soft glow in the fill color,
so the resting state already reads as the primary action. */
.root[data-variant="solid"] .shell {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.22), rgba(255, 255, 255, 0) 55%),
var(--mb-color);
color: var(--mb-text);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.55),
inset 0 -1px 0 rgba(0, 0, 0, 0.12),
0 0 0 1px color-mix(in srgb, var(--mb-color) 40%, transparent),
0 8px 28px -6px color-mix(in srgb, var(--mb-fill) 55%, transparent),
0 0 48px -12px color-mix(in srgb, var(--mb-fill) 60%, transparent);
}
.root[data-variant="solid"][data-hovered] .shell {
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.35),
inset 0 -1px 0 rgba(0, 0, 0, 0.12),
0 0 0 1px color-mix(in srgb, var(--mb-fill) 60%, transparent),
0 12px 36px -6px color-mix(in srgb, var(--mb-fill) 70%, transparent),
0 0 64px -10px color-mix(in srgb, var(--mb-fill) 70%, transparent);
}
/* Outline: hairline ring and a faint glass tint. */
.root[data-variant="outline"] .shell {
background: rgba(255, 255, 255, 0.03);
color: var(--mb-color);
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--mb-color) 28%, transparent),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.root[data-variant="outline"][data-hovered] .shell {
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--mb-fill) 70%, transparent),
0 8px 28px -10px color-mix(in srgb, var(--mb-fill) 60%, transparent);
}
/* Ghost: label only until the blob arrives. */
.root[data-variant="ghost"] .shell {
color: var(--mb-color);
}
/* --- Fill blob: a circle clipped from the pointer entry point. 150% of
circle()'s reference length covers the farthest corner of any pill. --- */
.fill {
position: absolute;
inset: 0;
z-index: -1;
border-radius: inherit;
background: var(--mb-fill);
clip-path: circle(0% at var(--mb-x, 50%) var(--mb-y, 50%));
transition: clip-path 0.55s cubic-bezier(0.22, 1, 0.36, 1);
pointer-events: none;
}
.root[data-hovered] .fill {
clip-path: circle(150% at var(--mb-x, 50%) var(--mb-y, 50%));
}
/* --- Label: moved by framer-motion (parallax), flips color on hover. --- */
.label {
display: inline-flex;
align-items: center;
gap: var(--mb-gap);
/* On leave the color waits for the shrinking blob to pass the text,
otherwise it reverts while still sitting on the fill. */
transition: color 0.25s ease 0.2s;
}
.root[data-hovered] .label {
color: var(--mb-fill-text);
transition-delay: 0.06s; /* let the blob reach the text first */
}
.text {
display: inline-block;
}
.arrow {
width: var(--mb-arrow);
height: var(--mb-arrow);
flex: none;
transition: transform 0.45s cubic-bezier(0.22, 1, 0.36, 1);
}
.root[data-hovered] .arrow {
transform: translateX(4px);
}
/* Keyboard users get the same color flip, grown from the center. */
.shell:focus-visible .fill {
clip-path: circle(150% at 50% 50%);
}
.shell:focus-visible .label {
color: var(--mb-fill-text);
}
/* --- Disabled --- */
.root[data-disabled] .shell {
opacity: 0.45;
cursor: not-allowed;
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--mb-color) 28%, transparent);
}
/* --- Reduced motion: no growing blob or arrow nudge — the fill simply
fades in, so the color change still happens. (The magnet itself is
switched off in JS.) --- */
@media (prefers-reduced-motion: reduce) {
.fill {
clip-path: none;
opacity: 0;
transition: opacity 0.2s ease;
}
.root[data-hovered] .fill,
.shell:focus-visible .fill {
clip-path: none;
opacity: 1;
}
.label {
transition: color 0.2s ease;
}
.root[data-hovered] .label {
transition-delay: 0s;
}
.arrow {
transition: none;
}
.root[data-hovered] .arrow {
transform: none;
}
}
```
代替手段: `components.json` がありシェルを実行できる環境なら、shadcn CLI でも同じファイルと依存パッケージが入ります。
```bash
npx shadcn@latest add https://anima-js.vercel.app/r/magnetic-button.json
```
### 3. 使用例を配置する
以下は配信元のプレイグラウンドで設定されていた値をそのまま反映した使用例です。これを基に、適切なページ・レイアウトへ配置してください。コード中の TODO コメントは、対応するか、何をすべきかを説明してください。
```tsx
import { MagneticButton } from "@/components/magnetic-button";
// TODO: onClick で処理をつなぐか、href を渡してリンクにしてください(href があると <a> で描画されます)
<MagneticButton
showArrow={true}
variant="solid"
size="lg"
color="#f5f5f7"
textColor="#0a0a0a"
fillColor="#7c5cff"
fillTextColor="#ffffff"
strength={0.45}
radius={90}
springStiffness={220}
springDamping={18}
children="はじめる"
/>
```
### 4. 組み込み手順
1. 置きたい場所で `<MagneticButton onClick={…}>はじめる</MagneticButton>` を使う。リンクにするなら `href` を渡す(`<a>` で描画される。`next/link` は使っていないので、クライアント遷移が必要なら `onClick` で `router.push` する)。
2. コンポーネント自体が `"use client"` なので、Server Component のページにそのまま置ける。ただし `onClick` を渡すなら呼び出し側も Client Component にする(関数は Server → Client に渡せない)。
3. 吸着は「ボタンの縁から `radius` px」で始まり、ボタン自体も最大で `strength × 0.4 ×` ポインターとの距離だけ動く。隣のボタンと近い場合は `radius` を小さめ(60px 前後)にすると、両方が同時に引っ張られて見えるのを防げる。
4. 親に `overflow: hidden` があると、引き寄せられたボタンとソリッドの発光(box-shadow)が切れる。周囲に `radius × strength × 0.4` 程度の余白を確保する。
5. 色は `color`(ベース)/ `textColor`(ソリッドの文字)/ `fillColor`(ホバーの塗り+発光)/ `fillTextColor`(塗り上の文字)で変える。ソリッドの発光は `fillColor` から作られる。
6. フォーム送信ボタンにするなら `type="submit"`。既定は `"button"`(フォーム内で誤送信しない)。
7. `npm run build` が通ることを確認し、マウスで「近づくと吸い付く」「入った点から色が広がり、出た点に向かって縮む」、スマホで「吸着せず押下時に縮むだけ」を確認する。
#### 触ってはいけないところ
| 症状 | 原因 | 対処 |
| --- | --- | --- |
| ボタンがプルプル震える / 吸着が暴れる | 動いている要素自身の `getBoundingClientRect()` で中心を測り、移動量が測定にフィードバックしている | 計測は**動かない外側の `<span>`(.root)**で行い、transform は内側の `.shell` にだけかける構造を崩さない |
| ポインターを動かすと全体が重い | 移動量を `useState` に入れて毎回再レンダーしている | `useMotionValue` → `useSpring` に書き込むだけにする。計算は `requestAnimationFrame` で 1 フレーム 1 回 |
| 隣のリンクがクリックできない | 吸着範囲を「透明な大きいヒットエリア」で作っている | 範囲判定は `window` の passive な `pointermove` で行う(画面内にある間だけ IntersectionObserver で購読) |
| スマホでタップ後にボタンがずれたまま | タッチの pointer イベントで吸着・塗りを動かしている | `pointerType === "touch"` は無視し、`(hover: hover) and (pointer: fine)` のときだけ購読する |
| 塗りが角まで届かない | `clip-path: circle()` の半径が足りない | `circle(150% at x y)`。circle の % は対角線 / √2 が基準なので、入口が角でも 150% で覆える |
| 視差効果オフでも動く | framer-motion の JS アニメーションには CSS の reduced-motion が効かない | `useReducedMotion()` で吸着を止め、`<MotionConfig reducedMotion="user">` を外さない。塗りは CSS の `@media (prefers-reduced-motion)` でフェードに切り替わる |
### 5. 完了条件
- 型チェックとビルド(`npm run build` 相当)が通る
- 使用例を置いたページでコンポーネントが表示され、操作に反応する
- "use client" が維持され、不透明な背景のラッパーが追加されていない
- 使用例の TODO コメントが解消されている(または対応方法が説明されている)
- 「組み込み手順」にある作業がすべて済んでいる
## 付録: 見た目と挙動の仕様(レビュー用)
正となるのは上のソースコードです。以下は、実装後に見た目と挙動がギャラリーと一致しているかを確認するための仕様です。ソースを使えない事情がある場合は、この仕様を満たすように同じコンポーネントを実装してください。
### 前提
- Next.js(App Router)、React 19、TypeScript、CSS Modules、`framer-motion`(v12 以降)、`lucide-react`
- ファイル: `components/magnetic-button/MagneticButton.tsx`(`"use client"`)+ `MagneticButton.module.css` + `index.ts`
- props: `children`、`href?`、`onClick?`、`variant`(`"solid" | "outline" | "ghost"`)、`size`(`"sm" | "md" | "lg"`)、`strength`(0–1、既定 0.45)、`radius`(px、既定 90)、`color` / `textColor` / `fillColor` / `fillTextColor`、`showArrow`、`springStiffness`(220)/ `springDamping`(18)、`disabled`、`type`、`className`
- `href` があれば `motion.a`、無ければ `motion.button`(`type` 既定 `"button"`)
### 構造
```
span.root … 動かない。計測用・CSS 変数とバリアントの data 属性を持つ
motion.button.shell … 吸着の x/y と押下 scale。overflow: hidden のピル
span.fill … ホバーの塗り(aria-hidden)
motion.span.label … 視差の x/y。テキスト + 任意の ArrowRight
```
### 見た目
- ピル(`border-radius: 999px`)。高さ / 左右 padding / 文字: sm 36 / 16 / 13px、md 48 / 24 / 15px、lg 60 / 32 / 17px。字重 560、字間 -0.01em
- CSS 変数(.root に既定値、既定と違う値だけインラインで上書き): `--mb-color: #f5f5f7`、`--mb-text: #0a0a0a`、`--mb-fill: #7c5cff`、`--mb-fill-text: #fff`
- **solid**: 背景 `linear-gradient(180deg, 白 .22, 透明 55%)` + `--mb-color`、文字 `--mb-text`。box-shadow: `inset 0 1px 0 白 .55` / `inset 0 -1px 0 黒 .12` / `0 0 0 1px color-mix(color 40%)` / `0 8px 28px -6px color-mix(fill 55%)` / `0 0 48px -12px color-mix(fill 60%)`。ホバー前から発光していて「主ボタン」に見えること
- **outline**: 背景 白 .03、`inset 0 0 0 1px color-mix(color 28%)`、文字 `--mb-color`。ホバーで枠が fill 色 70% になり淡い発光
- **ghost**: 文字だけ。塗りはホバーで出る
- 塗り: `position: absolute; inset: 0; z-index: -1; background: var(--mb-fill)`、`clip-path: circle(0% at var(--mb-x) var(--mb-y))` → ホバーで `circle(150% at …)`、`0.55s cubic-bezier(.22,1,.36,1)`
- ホバー中は文字色を `--mb-fill-text` へ(0.3s、0.06s 遅延して塗りが届いてから)。矢印は `translateX(4px)`
- `:focus-visible`: `outline: 2px solid var(--mb-fill); outline-offset: 3px`、塗りが中央から満ちて文字色も反転
- disabled: opacity .45、`cursor: not-allowed`、吸着・塗りなし。リンクの場合は `href` を外して `aria-disabled="true"`
### モーション
- 吸着: `window` の passive `pointermove` を、IntersectionObserver(`rootMargin: radius px`)で画面内にある間だけ購読。`(hover: hover) and (pointer: fine)` でない端末・reduced motion・disabled・`strength = 0` では購読しない
- 計算は rAF で 1 フレーム 1 回: .root の rect 中心との差 `dx, dy`、縁からの距離 `edge`。`edge >= radius` なら 0 へ。範囲内は `pull = strength × (1 - edge / radius)`、shell は `d × pull × 0.4`、label は追加で `d × pull × 0.22`(枠より大きく動く)
- 値は `useMotionValue` → `useSpring({ stiffness, damping, mass: 0.6 })`。React state は使わない。ウィンドウ外へ出たら 0 に戻す
- 塗りの起点: `pointerenter` / `pointerleave` で shell 内の座標を `--mb-x` / `--mb-y` に直接書き、.root の `data-hovered` を付け外し(入った点から広がり、出た点へ縮む)。`pointerType === "touch"` は無視
- 押下: `whileTap={{ scale: .96 }}`(タッチでもこれだけは効く)
- `<MotionConfig reducedMotion="user">` で包む。reduced motion の CSS では塗りを clip-path なしの opacity フェード、矢印は動かさない
### アクセシビリティ
- ネイティブの `<button>` / `<a>`。キーボード操作・フォーカスはブラウザ標準のまま
- 塗りと矢印は `aria-hidden`。アイコンだけのボタンにする場合は `aria-label` を渡す
- 吸着でボタンが動いてもクリック判定は動いた位置に追従する(transform のため)
### 受け入れ条件
- マウスがボタンの縁から `radius` px 以内に入ると、ボタンが滑らかに引き寄せられ、ラベルがさらに少し先へ動く。範囲外に出ると揺れ戻りながら元の位置へ
- 入った点から塗りが円形に広がり文字色が反転、出た点に向かって縮む
- スマホでは吸着・塗りがなく、押すと縮むだけ
- reduced motion では吸着せず、色だけがフェードで切り替わる
- 初期表示(ホバーなし)でもソリッドは発光をまとった主ボタンに見えるインストール
npx shadcn@latest add https://anima-js.vercel.app/r/magnetic-button.json生成コード
import { MagneticButton } from "@/components/magnetic-button";
// TODO: onClick で処理をつなぐか、href を渡してリンクにしてください(href があると <a> で描画されます)
<MagneticButton
showArrow={true}
variant="solid"
size="lg"
color="#f5f5f7"
textColor="#0a0a0a"
fillColor="#7c5cff"
fillTextColor="#ffffff"
strength={0.45}
radius={90}
springStiffness={220}
springDamping={18}
children="はじめる"
/>