Anatomy of the Magnetic Button
How a button's content leans toward the cursor: a sensor, a scaled delta, and one underdamped spring driving two elements at different weights.
↓Scroll to step through it
The pull
The button doesn't track the cursor directly. A sensor computes a scaled offset, hands it to a spring, and the spring drives both the shell and its label at different weights.
MagneticButton wraps its <button> in an invisible div. Pointer events are
bound there, not on the button itself, so range can extend the pull past
the visible edges without changing hit-testing on the button's own box:
<div
data-slot="magnetic-button-sensor"
onPointerMove={handlePointerMove}
onPointerLeave={reset}
className="inline-flex"
style={{ padding: range }}
>range defaults to 0, so the sensor is exactly the button's box until you
opt into a wider catchment.
On every pointermove, the handler reads the button's center and turns the
cursor's distance from it into a target, scaled by strength:
That target is a raw motion value: it jumps instantly, with no easing. What
renders is useSpring(x, SPRING), an underdamped spring
(stiffness: 170, damping: 12, mass: 0.1) chasing it. Underdamped means it
doesn't ease flatly into place: it laps past the target and settles with a
touch of elasticity, so the pull feels elastic instead of merely delayed.
Three tracks, same timeline: the cursor moves, the dashed target follows it
instantly at × strength, and the solid shell lags a beat behind before
overshooting into place. On pointerleave, both motion values reset to 0
and the same spring pulls the shell back home.
The parallax layer
The label is its own motion.span, nested
inside the motion.button, riding a second useTransform off the same
spring value:
Because the label is a DOM child of the button, its own × 0.4 transform
stacks on top of whatever the shell is already doing. The button moves by
the full spring value, and the label inside it drifts an extra 40% in the
same direction. Two elements, one spring, two different weights.
Set staticLabel and labelFactor becomes 0. The label gets no transform
of its own and rides the shell, staying centered no matter how far
the button leans.
A single useSpring per axis drives everything downstream through
useTransform. There's no separate animation to keep in sync when the
pointer changes direction mid-flight: the spring already knows its own
velocity, so a reversal blends instead of snapping. And because x / y are
motion values applied via style={{ x: springX, y: springY }}, Framer writes
directly to transform on the compositor thread, with no React re-render per
pointer move, no layout, and no paint.
The sensor has exactly two handlers: onPointerMove and onPointerLeave.
There's no RequestAnimationFrame loop, no ResizeObserver, nothing running
while the cursor is elsewhere. The spring itself only does work while it's
displaced from its target. Once it settles at 0, it's inert until the
next pointermove.
The result
Every mechanism assembled. Move the cursor near each button to see the sensor, the spring, and the parallax together.
One spring value is read twice at different weights. The shell chases the
cursor, the label chases it a little harder, and pointerleave lets the same
spring pull everything back to zero.
- Cursor
- clientX inside the sensor
- Raw target
- (x − cx) × strength, no easing
- Spring shell
- 170 / 12 / 0.1: lags, then overshoots
const rect = el.getBoundingClientRect();const cx = rect.left + rect.width / 2;const cy = rect.top + rect.height / 2;x.set((e.clientX - cx) * strength);y.set((e.clientY - cy) * strength);Anatomy of the Magnetic Button
How a button's content leans toward the cursor: a sensor, a scaled delta, and one underdamped spring driving two elements at different weights.
- Cursor
- clientX inside the sensor
- Raw target
- (x − cx) × strength, no easing
- Spring shell
- 170 / 12 / 0.1: lags, then overshoots
const rect = el.getBoundingClientRect();const cx = rect.left + rect.width / 2;const cy = rect.top + rect.height / 2;x.set((e.clientX - cx) * strength);y.set((e.clientY - cy) * strength);The pull
The button doesn't track the cursor directly. A sensor computes a scaled offset, hands it to a spring, and the spring drives both the shell and its label at different weights.
MagneticButton wraps its <button> in an invisible div. Pointer events are
bound there, not on the button itself, so range can extend the pull past
the visible edges without changing hit-testing on the button's own box:
<div
data-slot="magnetic-button-sensor"
onPointerMove={handlePointerMove}
onPointerLeave={reset}
className="inline-flex"
style={{ padding: range }}
>range defaults to 0, so the sensor is exactly the button's box until you
opt into a wider catchment.
On every pointermove, the handler reads the button's center and turns the
cursor's distance from it into a target, scaled by strength:
That target is a raw motion value: it jumps instantly, with no easing. What
renders is useSpring(x, SPRING), an underdamped spring
(stiffness: 170, damping: 12, mass: 0.1) chasing it. Underdamped means it
doesn't ease flatly into place: it laps past the target and settles with a
touch of elasticity, so the pull feels elastic instead of merely delayed.
Three tracks, same timeline: the cursor moves, the dashed target follows it
instantly at × strength, and the solid shell lags a beat behind before
overshooting into place. On pointerleave, both motion values reset to 0
and the same spring pulls the shell back home.
shell only
label only
together
- Shell
- the whole button, full spring offset
- Label
- nested inside; adds its own ×0.4 (0 if staticLabel)
const labelFactor = staticLabel ? 0 : 0.4;const labelX = useTransform(springX, (v) => v * labelFactor);const labelY = useTransform(springY, (v) => v * labelFactor);The parallax layer
The label is its own motion.span, nested
inside the motion.button, riding a second useTransform off the same
spring value:
Because the label is a DOM child of the button, its own × 0.4 transform
stacks on top of whatever the shell is already doing. The button moves by
the full spring value, and the label inside it drifts an extra 40% in the
same direction. Two elements, one spring, two different weights.
Set staticLabel and labelFactor becomes 0. The label gets no transform
of its own and rides the shell, staying centered no matter how far
the button leans.
A single useSpring per axis drives everything downstream through
useTransform. There's no separate animation to keep in sync when the
pointer changes direction mid-flight: the spring already knows its own
velocity, so a reversal blends instead of snapping. And because x / y are
motion values applied via style={{ x: springX, y: springY }}, Framer writes
directly to transform on the compositor thread, with no React re-render per
pointer move, no layout, and no paint.
The sensor has exactly two handlers: onPointerMove and onPointerLeave.
There's no RequestAnimationFrame loop, no ResizeObserver, nothing running
while the cursor is elsewhere. The spring itself only does work while it's
displaced from its target. Once it settles at 0, it's inert until the
next pointermove.
The result
Every mechanism assembled. Move the cursor near each button to see the sensor, the spring, and the parallax together.
One spring value is read twice at different weights. The shell chases the
cursor, the label chases it a little harder, and pointerleave lets the same
spring pull everything back to zero.
Accessibility
useReducedMotion() is checked before anything moves. When it's on, the
pointer handler returns immediately and the style props for both the shell
and the label are undefined, so the button renders with no transform at all:
const reduceMotion = useReducedMotion();
// …
const handlePointerMove = (e) => {
if (reduceMotion) return;
// …
};
style={reduceMotion ? undefined : { x: springX, y: springY }}Press feedback doesn't ride the spring either. active:scale-[0.97] is a
plain CSS transition on the button class, so it fires the instant you click
regardless of motion preference. Focus gets the standard ring:
focus-visible:ring-2 focus-visible:ring-ring, offset by 2px.
Motion Score
x / yMagnetic pull: spring-driven translate toward the pointerscalePress dip to 0.97 on active