cursor · magnetic · 灵感来源:Luma / Cuberto 的磁吸按钮(元素朝指针偏移,光标保持原生)
灵感稿
3 个版本
令牌集
115 个变量
指针靠近就吸过去 · 原生光标保留
Magnetic Hover · 磁吸指针 · 光标 / Interaction
做一个 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 的磁吸按钮(元素朝指针偏移,光标保持原生)--color-accent --color-divider --color-surface --color-text-muted --dur-slow --ease-settle --shadow-sm
焦点 / 按压
(尚未接进整页场景)
/r/cursor--magnetic.json
免外壳的 markdown 版:/llms/components/cursor--magnetic.mdstrength,再按 maxOffset 限幅——靠近快、远离稳padding: radius + 负 margin 扩命中区,不改布局--dur-slow / --ease-settle 补间回位;指针移动期间不挂过渡,避免拖影transform: translate3d() 上,只走合成层;不碰 left/top 与宽高(hover: hover) and (pointer: fine) 且 prefers-reduced-motion 未开启时生效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>
);
}
src/components/cursor/magnetic/