磁吸指针

cursor · magnetic · 灵感来源:Luma / Cuberto 的磁吸按钮(元素朝指针偏移,光标保持原生)

灵感稿

3 个版本

令牌集

115 个变量

指针靠近就吸过去 · 原生光标保留

复用卡

Magnetic Hover · 磁吸指针 · 光标 / Interaction

规格 · 术语提示词(给 AI 用)

做一个 Magnetic Hover(磁吸指针):
- 指针靠近时元素向指针偏移,光标保持原生
- 位移 = 中心距 × 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
灵感参考:Luma / Cuberto 的磁吸按钮(元素朝指针偏移,光标保持原生)

参数(CSS 变量)

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

必缺状态

焦点 / 按压

整页场景

(尚未接进整页场景)

安装(一条链接)

调研笔记

磁吸指针

拆解

  • 位移 = 指针到元素中心的距离 × strength,再按 maxOffset 限幅——靠近快、远离稳
  • 感应区比元素大一圈:padding: radius + 负 margin 扩命中区,不改布局
  • 离开用 --dur-slow / --ease-settle 补间回位;指针移动期间不挂过渡,避免拖影
  • 位移写在 transform: translate3d() 上,只走合成层;不碰 left/top 与宽高
  • 光标始终是系统的原生光标,只做元素的响应,不做光标替身

边界

  • 只在 (hover: hover) and (pointer: fine) 且 prefers-reduced-motion 未开启时生效
  • 键盘焦点与回车不受影响;磁吸只是悬停修饰,不是唯一反馈
  • 触屏不响应,避免滑动时元素乱跑

限制

  • v1 只做平移,不做旋转与缩放;强度和半径以 props 暴露
  • 感应区用负 margin 扩大,密集排列时相邻感应区会贴合,必要时靠间距调开

实现

cursor/magnetic
import { useEffect, useRef, type PointerEvent, type ReactNode } from 'react';
import { prefersReducedMotion } from '~/lib/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>
  );
}

元信息

标识
cursor/magnetic
状态
done
最后提交
2026-11-01
风格轴
magnetic
依赖 token
--color-accent --color-divider --color-surface --color-text-muted --dur-slow --ease-settle --shadow-sm
在工作台打开 源码与 notes 都在 src/components/cursor/magnetic/