Skip to main content
← Back to Motion Recipes
intermediateInteraction

Magnetic Button

A button that gently follows the cursor using gsap.quickTo, without touching Angular state.

standalone componentviewChildDestroyRefno signal writes on pointermovegsap.quickToeasingclamping
04

Demo

How it works

On pointermove, the handler measures the cursor's offset from the button's center and calls two gsap.quickTo() setters — one for x, one for y — with a fraction of that offset. Unlike gsap.to(), quickTo() returns a reusable function optimized to be called many times per second: it reuses the same tween instead of creating a new one on every call.

On pointerleave, both setters are called with 0, so the button eases back to its resting position with the same duration and easing.

The Angular way

  • The pointer handlers are plain addEventListener calls attached once in afterNextRender(), not Angular (pointermove) template bindings — that keeps every mouse move from going through Angular's binding/read path at all.
  • No signal is written on every pointer event. The button's position is owned entirely by GSAP; Angular never re-renders because of it.
  • viewChild.required('magnet') gets the button element; setup runs once, in afterNextRender(), guarded by isPlatformBrowser() since this project's SSR setup still runs afterRender hooks while prerendering.
  • Cleanup removes both listeners and calls gsap.killTweensOf(button) from DestroyRef.onDestroy().

Source code

import { isPlatformBrowser } from '@angular/common';
import {
  ChangeDetectionStrategy,
  Component,
  DestroyRef,
  ElementRef,
  PLATFORM_ID,
  afterNextRender,
  inject,
  viewChild,
} from '@angular/core';

@Component({
  selector: 'app-magnetic-button-demo',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <button
      #magnet
      type="button"
      class="inline-flex items-center justify-center rounded-full bg-primary px-10 py-5 text-base font-semibold text-primary-foreground shadow-lg transition-shadow hover:shadow-xl focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
    >
      Hover me
    </button>
  `,
})
export default class MagneticButtonDemo {
  private readonly destroyRef = inject(DestroyRef);
  private readonly platformId = inject(PLATFORM_ID);
  private readonly buttonRef =
    viewChild.required<ElementRef<HTMLButtonElement>>('magnet');

  private cleanup: (() => void) | null = null;

  constructor() {
    afterNextRender(() => this.setup());
    this.destroyRef.onDestroy(() => this.cleanup?.());
  }

  private async setup() {
    if (!isPlatformBrowser(this.platformId)) return;
    if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;

    const { gsap } = await import('gsap');
    const button = this.buttonRef().nativeElement;

    const setX = gsap.quickTo(button, 'x', { duration: 0.5, ease: 'power3' });
    const setY = gsap.quickTo(button, 'y', { duration: 0.5, ease: 'power3' });

    const onMove = (event: PointerEvent) => {
      const rect = button.getBoundingClientRect();
      const relX = event.clientX - (rect.left + rect.width / 2);
      const relY = event.clientY - (rect.top + rect.height / 2);
      setX(relX * 0.35);
      setY(relY * 0.35);
    };

    const onLeave = () => {
      setX(0);
      setY(0);
    };

    button.addEventListener('pointermove', onMove);
    button.addEventListener('pointerleave', onLeave);

    this.cleanup = () => {
      button.removeEventListener('pointermove', onMove);
      button.removeEventListener('pointerleave', onLeave);
      gsap.killTweensOf(button);
    };
  }
}

Implementation recipe

  1. Create the standalone component with OnPush.
  2. Add the static markup: a single real <button>, no wrapper element required.
  3. Query the button with viewChild.required<ElementRef>('magnet').
  4. Lazy-load GSAP with await import('gsap') inside afterNextRender(), and bail out early if the user prefers reduced motion.
  5. Build the animation: gsap.quickTo(button, 'x', { duration: 0.5, ease: 'power3' }), one setter per axis.
  6. Add cleanup: remove both listeners and kill tweens of the button in destroyRef.onDestroy().
  7. Add reduced motion: skip attaching the listeners entirely instead of animating with a zero duration.
  8. Test keyboard/accessibility: tab to the button, confirm the focus ring is visible, and confirm Enter/Space still activate it — the magnetic effect only listens for pointer events.

Accessibility notes

  • stays a real, keyboard-focusable button
  • visible focus ring
  • prefers-reduced-motion disables the follow effect

Performance notes

  • gsap.quickTo instead of a new tween per pointermove event
  • no Angular change detection triggered by pointer movement

Common pitfalls

  • Calling gsap.to() directly inside the pointermove handler — it works, but creates and tears down a tween on every event. quickTo() exists specifically to avoid that cost.
  • Writing the pointer offset into a component signal to drive the template — this forces change detection on every mouse move for no visual benefit, since GSAP is already updating the DOM directly.
  • Leaving the effect active for users who prefer reduced motion — skip attaching the listeners rather than reducing the animation duration to near-zero.