Magnetic
A ring that snaps onto a target's bounding box and pulls the target itself toward the pointer. The morph is scaleX/scaleY on an SVG rect, never width and height — that choice was settled by measurement in Phase 0.
Live props — bound to the real component
Install
npx shadcn@latest add @pointerui/magnetic# Copy three folders out of the repo
src/lib/engine/ -> lib/pointerui/engine/
src/registry/provider/ -> components/pointerui/provider/
src/registry/variants/magnetic/ -> components/pointerui/magnetic/
# Then fix the two aliases the CLI would have rewritten
# @/lib/engine -> @/lib/pointerui/engine
# ../../provider -> @/components/pointerui/providerimport { createEngine } from "./lib/pointerui/engine";
// The engine has no React imports at all, so it runs anywhere.
const engine = createEngine();
const el = document.createElement("div");
document.body.append(el);
const layer = engine.registerLayer(el, { duration: 0.15, ease: "power3" });
const stop = engine.onTick((state) => {
layer.setX(state.x);
layer.setY(state.y);
});
// Magnetic's own layer setup is the same call with its own options.Usage
import { CursorProvider } from "@/components/pointerui/provider";
import { Magnetic } from "@/components/pointerui/magnetic";
export default function Page() {
return (
<CursorProvider>
<Magnetic />
<button data-cursor="magnetic">Hover me</button>
</CursorProvider>
);
}Props
Generated from magnetic/types.ts and the component's own defaults, so this table cannot drift from the code you are about to copy.
| Prop | Type | Default | Description |
|---|---|---|---|
| size | number | 36 | Resting ring diameter in px, before it morphs to anything. |
| padding | number | 14 | Extra space between the ring and the target's bounds, per side. |
| ringWidth | number | 1.5 | Ring stroke width in px. Stays this width at any scale. |
| ringAlpha | number | 0.55 | Ring stroke alpha, 0–1. |
| color | string | "var(--cursor-color, currentColor)" | Any CSS colour. Defaults to the |
| radius | number | 12 | Corner radius in px once morphed onto a target. |
| strength | number | 0.35 | How hard a target is pulled toward the pointer, 0–1. Overridden per element by |
| maxPull | number | 0.25 | Pull ceiling as a fraction of the target's own size. Without a clamp, a small target near the pointer flies away from its own layout. |
| lag | number | 0.35 | Follow duration in seconds while tracking the pointer or a target centre. |
| ease | string | "power3" | GSAP ease for the follow tween. Any ease string GSAP understands works — This is the shape of the lag, where |
Data attributes
Mark any element with one of these and this variant responds when the pointer enters it. Values it does not understand are ignored.
- data-cursor="magnetic"
- Ring morphs onto the element's box and the element is pulled toward the pointer.
- data-cursor="grow"
- Scales the resting ring without morphing.
- data-cursor="hide"
- Fades the ring out.
Notes & caveats
- Pull is clamped to a fraction of the target's own size. Without a ceiling, a small target near the pointer flies out of its own layout.
- Cached target rects are invalidated on scroll and resize, and by a
ResizeObserverinborder-boxmode — content-box misses padding-only changes. - The stroke stays 1.5px at any scale because the scale is applied inside the SVG, where
vector-effect: non-scaling-strokeworks. Transform the wrapper instead and the stroke inflates with it.