Skip to content
08 / 10Interaction7 min read

Magnet

Every text button and pill on this site leans toward the cursor as it approaches. The arithmetic is three lines. The thing that makes it read as weight rather than jitter is the fourth, and it's one GSAP call.

Used on this site

Every text button and pill on this site. The 'Start a project' pill in the home page hero is the nearest one, and the 'Go and look' button below this paragraph is another. Approach either slowly and watch it lean before you arrive.

Paused
Move slowly across the three buttons, or drag a finger through them. Drag either factor to zero and that axis goes dead; drag both to 1 and the button pins itself to the cursor, which is the moment you can see there was never any physics in it.

A magnetic button is three lines of arithmetic, and everything that makes it feel right is in the fourth.

The maths is the cursor's offset from the element's centre, scaled down, written to x and y. Anyone can produce that in a minute. In the minute after that, they discover it judders. What separates a button that leans from one that stutters isn't the sum, it's what you do with the answer sixty times a second.

The wrong answer is a gsap.to() per mousemove. I know because that was version one. Each call starts a fresh tween that believes the element is at rest while the previous tween is still mid-flight moving it, the two fight over the same properties in the same frame, and you get a button with a tremor. gsap.quickTo exists for exactly this. It builds one tween once and hands back a setter that rewrites the destination of the tween already running.

Three lines, and a fourth

The shipped pull is eleven lines inside TextButton, and only the last three do any arithmetic. Everything above them is bookkeeping about when the setters exist.

src/components/ui/TextButton.tsx — onMove
    qyRef.current?.tween.kill();
    qxRef.current = null;
    qyRef.current = null;
    gsap.to(elRef.current, { x: 0, y: 0, duration: 0.5, ease: EASE.micro });
  }
};

/** THE RECT WAS MEASURED ON AN ALREADY-MOVED ELEMENT.
 *
 *  `getBoundingClientRect()` reports where the button IS, transform included —
 *  and the button is mid-lean by the time the second pointermove arrives. So
The whole of the pull. Two quickTo setters, one rect, two multiplies.

The setters get built lazily on the first move rather than up front in an effect, because a button nobody ever hovers shouldn't carry two live tweens for the lifetime of the page, and there are a lot of these buttons. The killTweensOf a line above them is the re-engage case. A pointer that leaves and comes straight back arrives while the half-second return tween is still walking the element home, and without that kill, the fresh setters and the surviving return tween write to x and y in the same tick for the whole half second, with the element going wherever the last writer put it.

0.3 across, 0.4 down

The two factors are different numbers on purpose. Each one scales an offset from the centre measured in pixels, and a text button is many times wider than it is tall, so the horizontal offset available is large and the vertical one is tiny. Give both axes 0.3 and the vertical response is a proportional share of almost nothing. The button slides left and right convincingly and feels nailed to its baseline. Taking the vertical factor to 0.4 buys back roughly a third more travel on the axis that had none to spare.

ConstantDefault
horizontal factor0.3fraction of the cursor's x-offset from centre. At 1 the button is welded to the pointer and stops reading as attraction
vertical factor0.4the same for y, deliberately larger. A wide, short target has far less vertical offset to scale
quickTo duration0.3the lag between cursor and element. Under about 0.15 it tracks so closely it looks rigid; past 0.5 it feels sprung
quickTo easepower3.outfront-loaded, so the element commits immediately and settles slowly
return0.5 / <code>EASE.micro</code>the trip home on release. EASE.micro is power2.out, gentler than the follow, so the let-go does not read as a second gesture

The release is the other half

Every demonstration of this technique shows the pull. Almost none of them show the let-go, and the let-go is where the bug lives. The order below is the entire point: kill the tween each setter owns, drop the two references so the next move rebuilds the pair from scratch, and only then start the journey back to zero.

src/components/ui/TextButton.tsx — onLeave
    return;
  }
  enterLine();
  if (arRef.current) arRef.current.style.transform = arrow === "↗" ? "translate(5px,-5px)" : "translateX(6px)";
};

const onLeave = () => {
  restRect.current = null;
  if (reduced) {
    if (elRef.current) elRef.current.style.opacity = "1";
    return;
  }
  leaveLine();
  if (arRef.current) arRef.current.style.transform = "translate(0,0)";
  if (magnetic && elRef.current) {
    // release: drop the follow setters so they can't fight the return tween
Under reduced motion the handler never reaches the magnet at all. It puts the opacity back and returns.

Two implementations, and a demo that holds three

PillButton runs the identical handler with two softer constants, 0.25 and 0.3, because a pill is padded on all four sides and closer to square, so the axes need less correcting against each other. It's also magnetic by default, where TextButton opts in, which is why the hero CTA pulls without anything at the call site asking. Its release reaches the same place by a different road: killHoverTweens kills every tween on the element's x and y rather than each setter's own tween. It has to be the shared helper there, because the pill's hover also owns a growing fill circle, a label colour and a rotating arrow that all need to die together on a rapid enter-leave.

src/components/ui/PillButton.tsx — onMove

const onEnter = (e: React.MouseEvent) => {
  // measured now, while the element is still where the layout put it
  restRect.current = elRef.current?.getBoundingClientRect() ?? null;
  enterAt(e.clientX, e.clientY);
};
const onLeave = (e: React.MouseEvent) => {
  restRect.current = null;
  leaveAt(e.clientX, e.clientY);
};
The same handler, two numbers softer.

The demo above differs again, because it drives three elements from a single pointermove on the window and so has no enter or leave events to hang state on. It keeps a setter pair per element in a Map and treats presence in the map as the engaged state, engaging only within 60 px horizontally and 40 px vertically of an element's box. Without that bound, the whole strip lurches at a cursor that's nowhere near any of it, and three buttons leaning at once reads as a fault rather than an affordance.

src/components/lab/demos/MagnetDemo.tsx
// one pair of setters per element, created once
const setters = new Map<HTMLElement, [gsap.QuickToFunc, gsap.QuickToFunc]>();

const engage = (el: HTMLElement) => {
  if (setters.has(el)) return setters.get(el)!;
  gsap.killTweensOf(el, "x,y");
  const pair: [gsap.QuickToFunc, gsap.QuickToFunc] = [
    gsap.quickTo(el, "x", { duration: 0.3, ease: "power3.out" }),
    gsap.quickTo(el, "y", { duration: 0.3, ease: "power3.out" }),
  ];
  setters.set(el, pair);
  return pair;
};
One pair of setters per element, created once. The map is the state machine.

A magnet that only answers a mouse

Under prefers-reduced-motion the pull isn't softened, it's gone. onMove returns on its first line, and the whole hover becomes the element dropping to 0.7 opacity and back. That's a real affordance rather than a consolation: the visitor still learns which thing is interactive, and nothing has moved. The pill keeps its growing fill under the same preference, because the fill is its affordance. What it drops is the pull and the arrow's 45° rotation.

Keyboard focus gets answered from the other side. onFocus checks :focus-visible and then calls the very same onEnter the mouse calls, so tabbing draws the underline exactly as hovering does, and blur retracts it. The pill, having no cursor to grow its fill from, grows it from its own centre instead. There's no magnetic pull on focus, because there's no pointer to lean toward. But a magnetic button that only answers a mouse is an inaccessible button with a nice hover.

P.S. This one is running on the site right now.

GSAP · Interaction ·
← Back to the lab
Creative Web Designer & Developer25.2048°N · 55.2708°E

AlexanderSmith

Locating the studio25.2048°N · 55.2708°E