@tonybonet/magnetic
v0.4.0
Published
Pointer-tracking magnetic UI elements for React with spring physics and a shared orchestrator.
Downloads
644
Maintainers
Readme
@tonybonet/magnetic
Pointer-tracking magnetic UI elements with a shared orchestrator. One listener. One rAF loop. Zero compromises.
Pepper your page with magnetic buttons, CTAs, and tiles — each one subtly pulls toward the cursor. The shared orchestrator means one pointermove listener, one requestAnimationFrame loop, and IntersectionObserver gating regardless of how many elements you mount. No per-element listeners. No frame-budget tax.
Install
npm install @tonybonet/magnetic
# or
pnpm add @tonybonet/magnetic
# or
yarn add @tonybonet/magneticPeer dependencies: react (≥19.0.0) and motion (≥12.0.0).
Quickstart
Component (JSX)
import { Magnetic } from "@tonybonet/magnetic/component";
export function CTA() {
return (
<Magnetic options={{ maxOffset: 20 }}>
<button>Hover me</button>
</Magnetic>
);
}<Magnetic> owns its own <LazyMotion> boundary. If your app already wraps with LazyMotion at the root, Motion deduplicates — the wider feature set wins.
Headless hook
import { m } from "motion/react";
import { useMagnetic } from "@tonybonet/magnetic";
function Tile() {
const magnetic = useMagnetic({ maxOffset: 20 });
return (
<m.div ref={magnetic.ref} style={magnetic.style}>
Tile content
</m.div>
);
}Presets
import { Magnetic } from "@tonybonet/magnetic/component";
import { TACTILE, FLUID, PLAYFUL, SUBTLE, ATTRACTION } from "@tonybonet/magnetic/presets";
// Use a preset directly
<Magnetic options={TACTILE}>
<button>Snappy button</button>
</Magnetic>;
// Compose with .with()
<Magnetic options={FLUID.with({ maxOffset: 60, trigger: "hover" })}>
<div>Customized card</div>
</Magnetic>;| Preset | Feel | maxDist | maxOff | Best for |
|---|---|---|---|---|
| TACTILE | ⚡ Snappy, responsive | 200 | 16 | Buttons & controls |
| FLUID | 🌊 Luxurious, heavy | 400 | 48 | Cards & premium UI |
| PLAYFUL | 🎈 Bouncy, energetic | 280 | 32 | Badges & playful UI |
| SUBTLE | 👁️ Barely-there | 120 | 6 | Text links & inline |
| ATTRACTION | 🧲 Aggressive reach | 500 | 80 | Hero CTAs & focal |
Each preset is a full MagnetOptions object with a .with() method for composition:
// Start from a preset, override anything
TACTILE.with({ maxOffset: 24 })
FLUID.with({ maxDistance: 500, snap: false })
ATTRACTION.with({ trigger: "hover" })
// Or compose from defaults
MAGNET_DEFAULTS.with({ maxOffset: 20, smooth: false })Exports
| Subpath | What |
|---|---|
| @tonybonet/magnetic | useMagnetic hook + UseMagneticResult type |
| @tonybonet/magnetic/component | Magnetic JSX component + MagneticProps type |
| @tonybonet/magnetic/presets | TACTILE, FLUID, PLAYFUL, SUBTLE, ATTRACTION, MAGNET_DEFAULTS + MagnetPreset type |
Why a Shared Orchestrator?
The naive approach: every magnetic element attaches its own pointermove listener. 50 buttons = 50 listeners + 50 reflows per frame. Budget blown.
Magnetic uses a singleton orchestrator that shares:
- One
window.pointermovelistener (passive) - One
requestAnimationFrameloop (frame-coalesced) - One
IntersectionObserver(gates off-screen elements out of the per-frame cost) - One
ResizeObserver(invalidates cached bounding rects on layout change)
┌─────────────────────────────────────────────┐
│ MagnetOrchestrator │
│ ┌───────────┐ ┌──────┐ ┌───────────────┐ │
│ │pointermove│ │ rAF │ │IntersectionObs│ │
│ └─────┬─────┘ └──┬───┘ └───────┬───────┘ │
│ └───────────┼──────────────┘ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ Active Elements │ │
│ │ (on-screen only) │ │
│ └─────────────────────┘ │
└─────────────────────────────────────────────┘Window blur and document.visibilitychange reset all active elements to avoid stuck offsets when the user tabs away.
API
useMagnetic(options?)
Primary hook. Returns { ref, x, y, style, enabled, isComposing, reset }.
| Field | Type | Description |
|---|---|---|
| ref | (node: T \| null) => void | Attach to the element that should track the pointer |
| x | MotionValue<number> | Spring-animated X offset |
| y | MotionValue<number> | Spring-animated Y offset |
| style | { x, y } | Pass directly to a motion component's style prop |
| enabled | boolean | Whether the magnetic effect is active |
| isComposing | boolean | Whether the pointer is within maxDistance |
| reset | () => void | Reset to origin (stable identity across renders) |
<Magnetic options? style? magneticKey? ...divProps>
JSX convenience wrapper. Renders a motion.div with magnetic behavior.
| Prop | Type | Description |
|---|---|---|
| options | MagnetOptions | Configuration (see below) |
| style | MotionStyle | Additional motion styles to apply |
| magneticKey | Key | Override the derived structural key for forced remount |
| ...rest | HTMLMotionProps<"div"> | All standard motion.div props |
MagnetOptions
| Option | Type | Default | Category | Description |
|---|---|---|---|---|
| enabled | boolean | true | Structural | Turns the effect on/off (triggers remount) |
| maxDistance | number | 320 | Structural | Radius from center where pull begins |
| maxOffset | number | 64 | Structural | Maximum translation toward the pointer |
| originX | number | 0.5 | Structural | Horizontal anchor (0 = left, 1 = right) |
| originY | number | 0.5 | Structural | Vertical anchor (0 = top, 1 = bottom) |
| elastic | boolean | true | Structural | Use sine-curve mapping for pull strength |
| smooth | boolean | true | Visual | true = SMOOTH preset, false = TACTILE |
| snap | boolean | true | Visual | true = bounce back, false = linear return |
| smoothSpring | SpringOptions | — | Visual | Override the hover spring |
| snapSpring | SpringOptions | — | Visual | Override the return spring |
| trigger | "distance" \| "hover" \| "click" | "distance" | Structural | Activation mode |
Structural options trigger a remount when changed. Visual options update live.
Accessibility
useMagnetic calls useReducedMotion() from Motion and disables itself when the user has prefers-reduced-motion: reduce set. No configuration needed.
Compatibility
| Package | Range |
|---|---|
| react | ^19.0.0 |
| motion | ^12.0.0 |
License
MIT
