Magnetic wrap
A wrapper that pulls one control toward the pointer within a radius, then eases back.
Installs gsap from npm, under GSAP's own licence. No GSAP code ships in this registry.
npx shadcn@latest add https://ui.goodtabs.co/r/magnetic-wrap.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/magnetic-wrap
A round action inside its field. The dial marks the radius, the dashes where the pull peaks.
Icon row
Five icon buttons, each with its own small field, so they lean as the pointer passes.
Usage
import { MagneticWrap } from "@/components/ui/magnetic-wrap"
<MagneticWrap radius={120}>
<Button>Start building</Button>
</MagneticWrap>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| radius | number | 120 | How far from the centre the pull reaches, in px. |
| strength | number | 0.5 | How much of the pointer's offset the control follows near its centre, from 0 to 1. |
Source
magnetic-wrap/magnetic-wrap.tsx
"use client";
import * as React from "react";
import { useGSAP } from "@gsap/react";
import gsap from "gsap";
import { cn } from "@/lib/utils";
gsap.registerPlugin(useGSAP);
const LIVE = "(hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference)";
type MagneticWrapProps = React.ComponentProps<"div"> & {
/** How far from the centre the pull reaches, in px. */
radius?: number;
/** How much of the pointer's offset the control follows near its centre, from 0 to 1. */
strength?: number;
};
/**
* Wraps one control and pulls it toward the pointer within a radius. The outer box never moves, so
* the measurement cannot chase itself, and the child is left untouched. Off on touch and under
* reduced motion, where this is two plain boxes.
*/
function MagneticWrap({ radius = 120, strength = 0.5, className, children, ...props }: MagneticWrapProps) {
const root = React.useRef<HTMLDivElement>(null);
const moving = React.useRef<HTMLDivElement>(null);
useGSAP(
() => {
const media = gsap.matchMedia();
media.add(LIVE, () => {
const toX = gsap.quickTo(moving.current, "x", { duration: 0.5, ease: "power3.out" });
const toY = gsap.quickTo(moving.current, "y", { duration: 0.5, ease: "power3.out" });
const follow = (event: PointerEvent) => {
const box = root.current?.getBoundingClientRect();
if (!box) return;
const dx = event.clientX - (box.left + box.width / 2);
const dy = event.clientY - (box.top + box.height / 2);
const t = Math.hypot(dx, dy) / radius;
// Follows near the centre and fades to nothing at the edge, so crossing the radius never jumps.
const pull = t < 1 ? strength * (1 - t * t) ** 2 : 0;
toX(dx * pull);
toY(dy * pull);
};
const rest = () => {
toX(0);
toY(0);
};
window.addEventListener("pointermove", follow, { passive: true });
document.documentElement.addEventListener("pointerleave", rest);
return () => {
window.removeEventListener("pointermove", follow);
document.documentElement.removeEventListener("pointerleave", rest);
};
});
return () => media.revert();
},
{ scope: root, dependencies: [radius, strength], revertOnUpdate: true },
);
return (
<div ref={root} data-slot="magnetic-wrap" className={cn("inline-flex", className)} {...props}>
<div ref={moving} className="inline-flex">
{children}
</div>
</div>
);
}
export { MagneticWrap };
For AI agents
Hand your agent this page, the markdown at /components/magnetic-wrap/prompt.md, or the Copy AI prompt button above.
When to use
- The one primary action in a hero or a closing section
- A short row of round icon buttons on a marketing page
- Anywhere a control should feel inviting before it is pressed
When not to use
- App chrome, forms and toolbars people use all day: a moving target is slower to hit
- Rows of links or text, where neighbours would drift apart
- More than a few on one page, because each adds a window listener
Agent notes
- GSAP installs from npm under GSAP's own licence. No GSAP code ships in this registry
- Wrap exactly one control. The wrapper moves an inner box, so the child's own transforms keep working
- It listens on the window so it can react outside its own box, and only on a fine pointer with motion allowed
- Keyboard focus never pulls. On touch and under reduced motion the control sits still
The maths
- Pull is s × d × (1 - t²)², with t = d / r, so it is 0 at the centre and at the edge and crossing the radius never jumps.
- It peaks at d = r / √5, which is 63px for a 140px radius.
- The peak move is 0.286 × s × r, so strength 0.5 at 140px moves the control 20px.