Magnetic button effect in CSS and React
A button that leans toward the cursor as it approaches, with a brushed-aluminium sheen that tracks the pointer across its face.
Built 4 Aug 2026 · Last verified 4 Aug 2026
The short version
A magnetic button translates toward the pointer while the pointer is near it, then springs back when it leaves. The effect is a pointermove listener that measures the cursor's offset from the button's centre, scales that offset down by a strength factor, and writes it into two CSS custom properties the button's transform reads. Everything visual stays in CSS, so the JavaScript is about fifteen lines.
How it works
- A wrapper element sits around the button with generous padding. That padding is the magnetic field: the area where the button starts reacting before the cursor is actually over it.
- On pointermove, the handler measures the cursor's distance from the centre of the wrapper's box and divides by half the box size, giving a value from -1 to 1 on each axis.
- That normalised offset is multiplied by a strength in pixels (18 feels right for a 48px-tall button) and written to --mag-x and --mag-y on the wrapper.
- The button's transform reads those two variables. transform is itself an animatable property, so a plain CSS transition on the button carries it home when the variables reset: there is no animation loop in JavaScript, and no requestAnimationFrame.
- A second pair of variables, --sheen-x and --sheen-y, positions the aluminium highlight: a steep linear-gradient clipped to the button's face and offset by the same pointer position.
- On pointerleave the handler resets everything to its resting value and the transition does the rest. That is the whole reason to write to custom properties rather than to the transform directly: the resting state lives in CSS, so there is nothing to animate back to in code.
Why bother
A magnetic button is the smallest possible demonstration of a principle I care about: interface elements should feel like they have weight. A button that sits perfectly still until you click it is a rectangle. A button that leans toward you as you approach is an object.
That is not a decorative argument. Fitts's law says the time to hit a target falls as the target gets closer and larger. A button that moves toward the cursor is, briefly, both. The effect is small, roughly eighteen pixels at full strength, but it makes the last few hundred milliseconds of a click feel like the interface met you halfway.
The version here also carries the CreaTech aluminium gradient, with a highlight that tracks the pointer across the button's face. That part is pure decoration, and I am comfortable saying so.
The one decision that matters
There are two ways to build this, and the choice determines everything else.
The obvious way is to hold the offset in React state and let the component re-render on every pointer move. It works, it is three lines shorter, and it is wrong. A pointer moving across a button fires roughly sixty events a second. Sixty renders a second, each reconciling a subtree, for a value that only ever lands on one CSS property.
The other way is to treat the DOM node as the state. The handler measures the
cursor, does two divisions, and writes the result to a custom property with
setProperty. React is not involved. There is no render, no reconciliation and
no dependency array to get wrong, and the component's actual React output never
changes after mount.
That distinction generalises well beyond this button. Any effect whose output is purely visual and purely local, cursor tracking, scroll parallax, drag preview, belongs in custom properties rather than in state. State is for things other parts of the tree need to know about. A highlight position is not one of them.
Why the reset is empty
The part of this component people usually over-build is the return journey. It is tempting to reach for a spring library, or to write an animation loop that eases the offset back to zero.
You do not need one. transform is an animatable property, so the moment the
custom properties go back to their resting values, the CSS transition carries
the button home on its own. The reset function is four setProperty calls
with no easing logic anywhere in it, because the resting state lives in the
stylesheet where it belongs.
The easing curve is worth a word. cubic-bezier(0.22, 1, 0.36, 1) overshoots
slightly before settling, which reads as momentum rather than as a slide. It is
the same curve I use for most UI motion on this site.
The field is the interesting bit
The wrapper around the button has generous padding, and that padding is the whole effect. It is the catchment area: the region where the button starts reacting before the cursor is actually over it.
Measure from the button itself and the effect only fires once you have already arrived, which defeats the point. Measure from a field roughly twice the button's size and the lean starts while you are still approaching. Make the field much larger than that and the button twitches at rest, because a cursor crossing the far corner of a large box still produces a non-zero offset.
Twice the button's dimensions is the value I keep landing on.
What I would not do
I would not put this on a form's submit button. A control that moves as you approach it is a control that is harder to hit precisely, and anyone with a tremor or a trackpad they are fighting will feel that as friction rather than as delight. This belongs on a hero call to action, where the target is large and the stakes are low.
I would also not use it more than once on a page. The effect works because it is surprising, and two of them is a tic.
The source
MIT licensed. Use it in anything, no attribution needed.
Read straight off the file the demo above imports, then highlighted at build time. What you are reading is what is running.
Demo.tsxtsx
import { useRef, type PointerEvent } from "react";
import styles from "./Demo.module.scss";
/** Maximum travel, in pixels, at the edge of the magnetic field. */
const STRENGTH = 18;
/**
* Live media query list. Created once because `matches` stays current on its
* own, and building a new one on every pointermove would be wasteful.
*/
const REDUCED =
typeof window === "undefined"
? null
: window.matchMedia("(prefers-reduced-motion: reduce)");
export default function MagneticButton() {
const field = useRef<HTMLDivElement>(null);
function onMove(e: PointerEvent<HTMLDivElement>) {
const el = field.current;
if (!el || REDUCED?.matches) return;
const r = el.getBoundingClientRect();
// Offset from the centre of the field, normalised to -1 .. 1 per axis.
const nx = (e.clientX - (r.left + r.width / 2)) / (r.width / 2);
const ny = (e.clientY - (r.top + r.height / 2)) / (r.height / 2);
el.style.setProperty("--mag-x", `${nx * STRENGTH}px`);
el.style.setProperty("--mag-y", `${ny * STRENGTH}px`);
// Pointer position across the field, as a percentage, for the sheen.
el.style.setProperty("--sheen-x", `${((e.clientX - r.left) / r.width) * 100}%`);
el.style.setProperty("--sheen-y", `${((e.clientY - r.top) / r.height) * 100}%`);
}
function reset() {
const el = field.current;
if (!el) return;
// Back to the resting values. The transition in CSS does the rest: there
// is no return animation to write, because the rest state lives in CSS.
el.style.setProperty("--mag-x", "0px");
el.style.setProperty("--mag-y", "0px");
el.style.setProperty("--sheen-x", "50%");
el.style.setProperty("--sheen-y", "50%");
}
return (
<div
ref={field}
className={styles.field}
onPointerMove={onMove}
onPointerLeave={reset}
>
<button type="button" className={styles.button}>
<span className={styles.sheen} aria-hidden />
<span className={styles.label}>Pull me</span>
</button>
</div>
);
}Demo.module.scssscss
/* The magnetic field: the padding is the catchment area, so the button starts
leaning before the cursor is actually over it. */
.field {
--mag-x: 0px;
--mag-y: 0px;
--sheen-x: 50%;
--sheen-y: 50%;
display: grid;
place-items: center;
padding: clamp(32px, 7vw, 60px) clamp(40px, 9vw, 88px);
touch-action: manipulation;
}
.button {
position: relative;
isolation: isolate;
overflow: hidden;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 14px 30px;
border: none;
border-radius: var(--r-pill);
cursor: pointer;
background: var(--aluminium);
color: #15151a;
font-family: var(--sans);
font-size: 15px;
font-weight: 600;
letter-spacing: -0.01em;
box-shadow: var(--shadow-1);
/* transform is animatable in its own right, so the spring home is a plain
transition: nothing to drive from JavaScript. */
transform: translate3d(var(--mag-x), var(--mag-y), 0);
transition: transform 0.45s cubic-bezier(0.22, 1, 0.36, 1);
}
/* Brushed highlight tracking the pointer across the button's face. */
.sheen {
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
border-radius: inherit;
background: radial-gradient(
120px 120px at var(--sheen-x) var(--sheen-y),
rgba(255, 255, 255, 0.85),
transparent 70%
);
opacity: 0;
transition: opacity 0.3s ease;
}
.button:hover .sheen,
.button:focus-visible .sheen {
opacity: 1;
}
.label {
position: relative;
z-index: 1;
}
@media (prefers-reduced-motion: reduce) {
/* No magnetic travel at all. The button keeps its hover, focus and sheen,
so the only thing lost is the movement itself. */
.button {
transform: none;
}
}Questions worth answering
Does it work in Safari?
Every current browser. Pointer events, custom properties and transform transitions have all been safe for years; there is nothing experimental in this one.
Does it need JavaScript?
Yes, about fifteen lines of it. Reading the cursor position is not something CSS can do on its own, so a pointermove handler is unavoidable. Everything after that measurement is CSS.
Is it accessible?
Reduced motion. The magnetic translation is disabled entirely. The button keeps its hover and focus states and the sheen becomes a static highlight, so nothing is lost except the movement.
Keyboard. The demo is a real <button>. It is reachable by Tab, activates on Enter and Space, and shows the site focus ring. The magnetic effect is pointer-only by design: there is no cursor to lean toward when you are on a keyboard, and faking one would be noise.
Want this kind of care on a build?
I build fast, production-grade sites for freelance clients. The polish is the same; there is just more of it.