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 is 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. 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, and in the minute after that they discover it judders. What separates a button that leans from one that stutters is not the sum — it is what you do with the answer sixty times a second.

The wrong answer is a gsap.to() per mousemove. 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 two 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
const onMove = (e: React.MouseEvent) => {
  if (!magnetic || reduced || !elRef.current) return;
  if (!qxRef.current || !qyRef.current) {
    gsap.killTweensOf(elRef.current, "x,y"); // stop an in-flight return before re-engaging
    qxRef.current = gsap.quickTo(elRef.current, "x", { duration: 0.3, ease: "power3.out" });
    qyRef.current = gsap.quickTo(elRef.current, "y", { duration: 0.3, ease: "power3.out" });
  }
  const b = elRef.current.getBoundingClientRect();
  qxRef.current((e.clientX - b.left - b.width / 2) * 0.3);
  qyRef.current((e.clientY - b.top - b.height / 2) * 0.4);
};
The whole of the pull. Two quickTo setters, one rect, two multiplies.

The setters are built lazily on the first move rather than up front in an effect, because a button nobody ever hovers should not carry two live tweens for the lifetime of the page, and there are a great many 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 the return still has to run, with the element going wherever the last of the two wrote.

0.3 across, 0.4 down

The two factors are not the same number, and that is deliberate. What each one scales is an offset from the centre measured in pixels, so on a text button — many times wider than it is tall — the horizontal offset available is large and the vertical offset 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, which 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
const onLeave = () => {
  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
    qxRef.current?.tween.kill();
    qyRef.current?.tween.kill();
    qxRef.current = null;
    qyRef.current = null;
    gsap.to(elRef.current, { x: 0, y: 0, duration: 0.5, ease: EASE.micro });
  }
};
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 therefore closer to square, so the axes need less correcting against each other. It is also magnetic by default, where TextButton opts in, which is why the hero CTA pulls without anything at the call site asking it to. 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, and it has to be a shared helper there, because the pill’s hover also owns a growing fill circle, a label colour and a rotating arrow that must all die together on a rapid enter-leave.

src/components/ui/PillButton.tsx — onMove
const onMove = (e: React.MouseEvent) => {
  if (!magnetic || reduced || !elRef.current) return;
  if (!qxRef.current || !qyRef.current) {
    gsap.killTweensOf(elRef.current, "x,y"); // stop an in-flight return before re-engaging
    qxRef.current = gsap.quickTo(elRef.current, "x", { duration: 0.3, ease: "power3.out" });
    qyRef.current = gsap.quickTo(elRef.current, "y", { duration: 0.3, ease: "power3.out" });
  }
  const b = elRef.current.getBoundingClientRect();
  qxRef.current((e.clientX - b.left - b.width / 2) * 0.25);
  qyRef.current((e.clientY - b.top - b.height / 2) * 0.3);
};
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 that map as the engaged state — engaging only within 60 px horizontally and 40 px vertically of an element’s box, because without a bound the whole strip lurches at a cursor that is 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 — the whole point
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 is not softened, it is gone: onMove returns on its first line, and the whole hover is replaced by dropping the element to 0.7 opacity and back. That is 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 is 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 is no magnetic pull on focus, because there is no pointer to lean toward — but a magnetic button that answers only a mouse is an inaccessible button with a nice hover.

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

AlexanderSmith

Locating the studio25.2048°N · 55.2708°E