磁吸按钮
Magnetic Button指针靠近时,按钮会轻轻倾向你
完整版:适合从零新建组件,规格、契约、验收全都在。
实时预览
指针交互参数
边调边看,提示词同步更新。
手感曲线
- 超调
- 8.0%
- 稳定
- 0.36s
角色
你是一名资深前端工程师,工作在现有的项目中,技术栈是 React 19 + TypeScript + Tailwind CSS v4 + Motion v14(motion 包)。只实现下面这一个微交互:不要加额外功能,不要顺手重构周围代码。
任务
实现磁吸按钮:指针进入按钮中心 140px 的磁场范围后,按钮朝指针方向被拉动,最大偏移 16px;指针离开后由弹簧带回原位。按钮内文字以 35% 的比例跟随,形成轻微视差。
文件契约
- 只新建一个文件:components/effects/MagneticButton.tsx。
- 依赖:项目已安装 motion@14,从 motion/react 引入。不要新增任何其他包。
- 不要修改 package.json、全局 CSS、Tailwind 配置或任何其他已有文件。
实现要点
- 文件顶部加 "use client"。
- 用 useMotionValue + animate()(来自 motion/react)驱动 x / y 与 scale;用 useReducedMotion() 判断降级。
- 导出 MagneticButton,props 为 radius、pull、stiffness、damping、scale、glow、children、className。
- 布局用 Tailwind;精确数值用任意值(如 h-[56px] rounded-full);光晕层用 useMotionTemplate 生成 radial-gradient,CSS 变量 --mx / --my 跟随指针。
规格 —— 精确数值,不许四舍五入或“优化”
- 磁场半径:以按钮中心为圆心 140px。
- 吸附强度:strength = (1 − min(距离 / 140, 1)) ^ 1.6;偏移 = 方向 × strength × 16px。
- 弹簧,跟随与回弹共用:刚度 260、阻尼 20、质量 1。回弹必须由同一个弹簧驱动,不允许用 CSS transition 或缓动曲线替代。
- 悬停缩放:1.03(弹簧:刚度 300、阻尼 20、质量 1)。
- 文字视差:内部文字的偏移 = 按钮偏移 × 0.35,由同一个弹簧驱动。
- 光晕:按钮内叠加 220px 的径向渐变 rgba(201, 244, 90, 0.34),圆心跟随指针;opacity 在指针进入时弹到 1,离开时弹回 0。
- 按钮视觉:高 56px,左右内边距 32px,圆角 999px,背景 #F2F0E9,文字颜色 #0B0C0E,字号 16px,字重 500,字距 -0.01em,光标 pointer。
- 磁场容器:固定 280px × 280px,按钮在其中居中;按钮移动时容器尺寸不变。
- 只允许动画 transform 与 opacity。
行为
- 用 Pointer Events 绑定在磁场容器上(不要用 mouse 事件);在 pointerenter 和每次 pointermove 交互开始时读取按钮 rect——父容器可能滚动。
- pointerleave:立刻用弹簧回到偏移 0,按钮绝不允许停在偏移状态。
- event.pointerType === "touch" 时不吸附;按下缩放 0.98,抬起 / 取消时回 1。
- 容器不能吞掉点击:点击按钮仍然触发它的 onClick,按钮保留 type="button"。
- 键盘:按钮可聚焦;focus-visible 显示 2px #C9F45A、外扩 3px 的描边;聚焦时不产生位移。
- prefers-reduced-motion: reduce 时禁用吸附、缩放与光晕动画,但保留 hover / focus 样式。
- 卸载时清理动画与监听;不要在 document/window 上绑监听;不要每帧触发状态更新。
禁止
- 不要用动画改变 width / height / top / left / margin / box-shadow。
- 弹簧驱动 transform 的同时,不要再给它加 CSS transition。
- 不要留 TODO、不要写 // 其余代码、不要用 any、不要 console.log、不要留注释掉的实验代码。
- 不要把组件包进页面、布局、演示壳或 Provider。
- 不要改动全局 CSS、Tailwind 配置或上面文件之外的任何文件。
- 除 Motion 外不要引入任何动画库,更不要用 GSAP。
验收 —— 回答前逐项自查
1. 指针靠近时按钮朝指针方向吸附,约 0.5s 内稳定,无抖动。
2. 离开磁场后偏移精确回到 0,缩放回到 1。
3. 点击、键盘 Enter/Space、焦点环都仍然可用。
4. DevTools 里只有 transform 与 opacity 变化,零布局位移。
5. prefers-reduced-motion 下不吸附、不缩放,但按钮仍可用。
6. 没有 console 报错或警告;卸载后所有监听与动画都被清理。
7. 渲染出的数值与规格完全一致:磁场、吸附、刚度、阻尼、缩放。
输出
- 用一个代码块输出完整文件内容,不要 diff、不要省略号,代码块之前不要解释。
- 如果缺少必要依赖,先停下来问我,不要擅自安装。
- 代码块之后最后输出一行:self-check: 1 ✓ · 2 ✓ · 3 ✓ · 4 ✓ · 5 ✓ · 6 ✓ · 7 ✓,未通过的项用 ✗ 并附一句原因。
"use client";
import {
animate,
motion,
useMotionTemplate,
useMotionValue,
useReducedMotion,
useTransform,
} from "motion/react";
import { useCallback, useRef } from "react";
import { MAGNETIC } from "@/content/effects/magnetic-button/constants";
export type MagneticButtonProps = {
children: React.ReactNode;
/** radius of the invisible field, in px */
radius?: number;
/** maximum offset toward the pointer, in px */
pull?: number;
stiffness?: number;
damping?: number;
/** hover scale, e.g. 1.03 */
scale?: number;
/** pointer-following glow layer */
glow?: boolean;
className?: string;
onClick?: () => void;
};
const clamp = (value: number, min: number, max: number) =>
Math.min(Math.max(value, min), max);
export function MagneticButton({
children,
radius = MAGNETIC.defaults.radius,
pull = MAGNETIC.defaults.pull,
stiffness = MAGNETIC.defaults.stiffness,
damping = MAGNETIC.defaults.damping,
scale = MAGNETIC.defaults.scale,
glow = MAGNETIC.defaults.glow,
className,
onClick,
}: MagneticButtonProps) {
const buttonRef = useRef<HTMLButtonElement>(null);
const fieldRect = useRef<DOMRect | null>(null);
const reduced = useReducedMotion();
const x = useMotionValue(0);
const y = useMotionValue(0);
const hoverScale = useMotionValue(1);
const glowX = useMotionValue(50);
const glowY = useMotionValue(50);
const glowOpacity = useMotionValue(0);
const labelX = useTransform(x, (value) => value * MAGNETIC.labelFollow);
const labelY = useTransform(y, (value) => value * MAGNETIC.labelFollow);
const glowBackground = useMotionTemplate`radial-gradient(${MAGNETIC.glow.diameter}px circle at ${glowX}% ${glowY}%, ${MAGNETIC.glow.color}, transparent 65%)`;
const spring = {
type: "spring" as const,
stiffness,
damping,
mass: MAGNETIC.mass,
};
const hoverSpring = {
type: "spring" as const,
stiffness: MAGNETIC.hoverSpring.stiffness,
damping: MAGNETIC.hoverSpring.damping,
mass: MAGNETIC.mass,
};
const readRect = useCallback(() => {
const rect = buttonRef.current?.getBoundingClientRect();
if (rect) fieldRect.current = rect;
}, []);
const handlePointerEnter = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
readRect();
if (reduced || event.pointerType === "touch") return;
if (glow) animate(glowOpacity, 1, hoverSpring);
},
[glow, hoverSpring, readRect, reduced],
);
const handlePointerMove = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (reduced || event.pointerType === "touch") return;
const rect = fieldRect.current ?? buttonRef.current?.getBoundingClientRect();
if (!rect) return;
const dx = event.clientX - (rect.left + rect.width / 2);
const dy = event.clientY - (rect.top + rect.height / 2);
const distance = Math.hypot(dx, dy);
if (distance > radius) {
animate(x, 0, spring);
animate(y, 0, spring);
return;
}
const strength = Math.pow(
1 - Math.min(distance / radius, 1),
MAGNETIC.falloff,
);
const safeDistance = distance || 1;
animate(x, (dx / safeDistance) * strength * pull, spring);
animate(y, (dy / safeDistance) * strength * pull, spring);
glowX.set(clamp(((event.clientX - rect.left) / rect.width) * 100, 0, 100));
glowY.set(clamp(((event.clientY - rect.top) / rect.height) * 100, 0, 100));
},
[glowX, glowY, pull, radius, reduced, spring, x, y],
);
const handlePointerLeave = useCallback(() => {
fieldRect.current = null;
animate(x, 0, spring);
animate(y, 0, spring);
animate(hoverScale, 1, hoverSpring);
animate(glowOpacity, 0, hoverSpring);
}, [glowOpacity, hoverScale, hoverSpring, spring, x, y]);
const handleButtonEnter = useCallback(
(event: React.PointerEvent<HTMLButtonElement>) => {
if (reduced || event.pointerType === "touch") return;
animate(hoverScale, scale, hoverSpring);
if (!glow) return;
animate(glowOpacity, 1, hoverSpring);
},
[glow, glowOpacity, hoverScale, hoverSpring, reduced, scale],
);
const handleTouchDown = useCallback(
(event: React.PointerEvent<HTMLButtonElement>) => {
if (reduced || event.pointerType !== "touch") return;
animate(hoverScale, MAGNETIC.touchScale, hoverSpring);
},
[hoverScale, hoverSpring, reduced],
);
const handleTouchUp = useCallback(() => {
animate(hoverScale, 1, hoverSpring);
}, [hoverScale, hoverSpring]);
return (
<div
className="relative grid place-items-center"
style={{ width: radius * 2, height: radius * 2 }}
onPointerEnter={handlePointerEnter}
onPointerMove={handlePointerMove}
onPointerLeave={handlePointerLeave}
>
<motion.button
ref={buttonRef}
type="button"
onClick={onClick}
style={{
x,
y,
scale: hoverScale,
height: MAGNETIC.button.height,
paddingInline: MAGNETIC.button.paddingX,
borderRadius: MAGNETIC.button.radius,
background: MAGNETIC.button.bg,
color: MAGNETIC.button.fg,
fontSize: MAGNETIC.button.fontSize,
fontWeight: MAGNETIC.button.fontWeight,
letterSpacing: MAGNETIC.button.letterSpacing,
}}
onPointerEnter={handleButtonEnter}
onPointerDown={handleTouchDown}
onPointerUp={handleTouchUp}
onPointerCancel={handleTouchUp}
className={[
"relative flex cursor-pointer touch-manipulation items-center justify-center overflow-hidden",
"select-none will-change-transform",
"focus-visible:outline-2 focus-visible:outline-offset-[3px] focus-visible:outline-[#C9F45A]",
className ?? "",
].join(" ")}
>
{glow ? (
<motion.span
aria-hidden
className="pointer-events-none absolute inset-0 rounded-full"
style={{ background: glowBackground, opacity: glowOpacity }}
/>
) : null}
<motion.span className="relative z-10" style={{ x: labelX, y: labelY }}>
{children}
</motion.span>
</motion.button>
</div>
);
}
构建时从真实文件读取 —— 这是你真的在看的那份代码,不是另写的示例。
技术规格
在按钮周围建立一个看不见的磁场:指针进入范围内,按钮被朝指针方向拉动,离开后由弹簧带回原位;按钮内文字以 35% 的比例跟随,形成轻微视差。
- 磁场半径
- 140 px
- 吸附距离
- 16 px
- 弹簧刚度
- 260
- 阻尼
- 20
- 悬停缩放
- 1.03 ×
- 指针光晕
- 开启
- 磁场容器
- 280 × 280 px
- 质量
- 1
- 文字视差
- 35%
- 吸附衰减
- ^1.6
- 光晕
- 220px · rgba(201, 244, 90, 0.34)
- 按钮
- 56px · 999px
验收清单
提示词要求 AI 在输出末尾逐条自检。
- 指针靠近时按钮朝指针方向吸附,约 0.5s 内稳定,无抖动。
- 离开磁场后偏移精确回到 0,缩放回到 1。
- 点击、键盘 Enter/Space、焦点环都仍然可用。
- DevTools 里只有 transform 与 opacity 变化,零布局位移。
- prefers-reduced-motion 下不吸附、不缩放,但按钮仍可用。
- 没有 console 报错或警告;卸载后所有监听与动画都被清理。
- 渲染出的数值与规格完全一致:磁场、吸附、刚度、阻尼、缩放。
待回归验证提示词与演示已同源生成;尚未在真实 Cursor / Claude Code 项目中做回归。
提示词如何还原这个效果
不是把效果描写一遍,而是把效果写成一份可执行的工程需求。
- 数字规格
- 每个像素、每个时间参数都来自同一个参数源——滑块改什么,提示词就写什么。
- 文件契约
- 只允许新建指定文件、指定依赖白名单,避免它顺手改你的全局样式或装一堆包。
- 验收清单
- 提示词末尾强制 AI 自检布局位移、键盘可用、reduced-motion 等 7 项。