# 磁吸指针（Magnetic Hover）

- 类型 / 材质：`cursor` / `magnetic`（磁吸）
- 状态：done ｜ 最后提交：2026-11-01
- 灵感来源：Luma / Cuberto 的磁吸按钮（元素朝指针偏移，光标保持原生）
- 依据标准：`/design#interaction` ｜ 组件页：`/showcase/component/cursor/magnetic` ｜ 条目：`/r/cursor--magnetic.json`
- 必缺状态：焦点 / 按压
- 整页场景：尚未接入

## 规格

- 指针靠近时元素向指针偏移，光标保持原生
- 位移 = 中心距 × strength 并限幅，离开用 --ease-settle 回位
- 只在 (hover: hover) and (pointer: fine)，reduced-motion 下不动
- 位移只写 transform，键盘焦点与回车不受影响

## 参数（CSS 变量）

`--color-accent` `--color-divider` `--color-surface` `--color-text-muted` `--dur-slow` `--ease-settle` `--shadow-sm`

## Props 契约

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `children` | `ReactNode` | 必填 | — |
| `strength` | `number` | 可选 | 位移 = 指针到中心的距离 × strength |
| `maxOffset` | `number` | 可选 | 位移上限（px） |
| `radius` | `number` | 可选 | 隐形感应区半径（px）：靠 padding + 负 margin 扩大，不改布局 |
| `className` | `string` | 可选 | — |

## 提示词（可直接粘给智能体）

```
复现「磁吸指针」— Magnetic Hover（光标 / Interaction）
灵感来源：Luma / Cuberto 的磁吸按钮（元素朝指针偏移，光标保持原生）
规格：
- 指针靠近时元素向指针偏移，光标保持原生
- 位移 = 中心距 × strength 并限幅，离开用 --ease-settle 回位
- 只在 (hover: hover) and (pointer: fine)，reduced-motion 下不动
- 位移只写 transform，键盘焦点与回车不受影响
参数（CSS 变量）：--color-accent --color-divider --color-surface --color-text-muted --dur-slow --ease-settle --shadow-sm
必缺状态：焦点 / 按压
整页场景：（尚未接进整页场景）
无障碍：焦点环 ≥2px 等效周长（SC 2.4.13）；指针目标 ≥24×24（SC 2.5.8）；动效带 prefers-reduced-motion 降级
参考：本站实样 /showcase/component/cursor/magnetic · 标准 interaction /design#interaction · 调研笔记 /showcase/component/cursor/magnetic#notes
```

## 调研笔记

import { useEffect, useRef, type PointerEvent, type ReactNode } from 'react';
import { prefersReducedMotion } from '@/lib/ui-echo/motion';

interface Props {
  children: ReactNode;
  /** 位移 = 指针到中心的距离 × strength */
  strength?: number;
  /** 位移上限（px） */
  maxOffset?: number;
  /** 隐形感应区半径（px）：靠 padding + 负 margin 扩大，不改布局 */
  radius?: number;
  className?: string;
}

const clamp = (v: number, max: number) => Math.max(-max, Math.min(max, v));

const canHover = () =>
  typeof window !== 'undefined' &&
  window.matchMedia('(hover: hover) and (pointer: fine)').matches;

/**
 * 磁吸：指针靠近时元素朝指针偏移，光标保持原生。
 * 只在精确指针 + 允许 hover 时生效；键盘焦点与回车不受影响，离开用 --ease-settle 回位。
 */
export function Magnetic({
  children,
  strength = 0.3,
  maxOffset = 12,
  radius = 12,
  className = '',
}: Props) {
  const wrapRef = useRef<HTMLDivElement>(null);
  const innerRef = useRef<HTMLDivElement>(null);
  const rafRef = useRef(0);

  useEffect(() => () => cancelAnimationFrame(rafRef.current), []);

  const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {
    if (e.pointerType !== 'mouse' || prefersReducedMotion() || !canHover()) return;
    const x = e.clientX;
    const y = e.clientY;
    cancelAnimationFrame(rafRef.current);
    rafRef.current = requestAnimationFrame(() => {
      const wrap = wrapRef.current;
      const inner = innerRef.current;
      if (!wrap || !inner) return;
      const r = wrap.getBoundingClientRect();
      const tx = clamp((x - (r.left + r.width / 2)) * strength, maxOffset);
      const ty = clamp((y - (r.top + r.height / 2)) * strength, maxOffset);
      inner.style.transition = 'none';
      inner.style.transform = `translate3d(${tx}px, ${ty}px, 0)`;
    });
  };

  const onPointerLeave = () => {
    cancelAnimationFrame(rafRef.current);
    const inner = innerRef.current;
    if (!inner) return;
    inner.style.transition = 'transform var(--dur-slow) var(--ease-settle)';
    inner.style.transform = 'translate3d(0, 0, 0)';
  };

  return (
    <div
      ref={wrapRef}
      data-magnetic
      onPointerMove={onPointerMove}
      onPointerLeave={onPointerLeave}
      className={`inline-flex ${className}`}
      style={{ padding: radius, margin: -radius }}
    >
      <div ref={innerRef} data-magnetic-inner className="inline-flex">
        {children}
      </div>
    </div>
  );
}
