Skip to content

Components / Pointer

Magnetic

A button or icon is pulled toward the cursor while it is nearby and springs back when it leaves. An invisible field around the child lets clicks through to its neighbours; one pointermove listener on the document measures the pointer against it, and the pull is CSS translate on the active preset's spring. No pull under reduced motion, read on every move.

Live demo

Get started

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

619 B raw

391 B gzip.

Uses the shared runtime (1.9 kB raw, once per site). With those included: 2.3 kB raw.

CSS 4.5 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Baseline · widely available

every browser.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import Magnetic from '@moonarc/core/Magnetic';
---
<Magnetic strength={0.4} radius={48}>
  <a href="/setup/" class="rounded-full bg-fg px-5 py-3 text-bg">Get started</a>
</Magnetic>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the shared runtime and the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/magnetic.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win. If you paste it, also copy runtime.ts.

Magnetic.astro
---
/**
 * Magnetic — the child is pulled toward the cursor inside a radius and
 * springs back when the cursor leaves.
 *
 * The wrapper is the field (its padding is the radius) but lets every
 * pointer event through, so it never covers a neighbour's click; one
 * pointermove listener on the document measures the pointer against the
 * field and writes the pull as two custom properties that the child's
 * translate reads. Following is near-instant; returning uses the active
 * preset's spring. Reduced motion is read on every move: no pull while it
 * is on, the pull again as soon as it is off.
 */
import type { HTMLAttributes, HTMLTag } from 'astro/types';

interface Props extends HTMLAttributes<'span'> {
  /** Element to render. */
  as?: HTMLTag;
  /** Pull as a fraction of the cursor's offset from the centre, 0–1. */
  strength?: number;
  /** Field radius around the child in px. */
  radius?: number;
}

const { as: Tag = 'span', strength = 0.4, radius = 40, class: className, style, ...rest } = Astro.props;
const inline = [`--ma-mg-strength:${strength}`, `--ma-mg-radius:${radius}px`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---

<Tag class:list={['ma-magnetic', className]} data-ma-magnetic style={inline} {...rest}>
  <slot />
</Tag>

<style is:global>
  @layer components {
    /* the field reaches over its neighbours, so it must not catch their pointer: only the child does */
    :where(.ma-magnetic) {
      display: inline-block;
      padding: var(--ma-mg-radius, 40px);
      margin: calc(var(--ma-mg-radius, 40px) * -1);
      pointer-events: none;
    }
    :where(.ma-magnetic > *) {
      display: inline-block;
      pointer-events: auto;
      translate: var(--ma-mx, 0px) var(--ma-my, 0px);
      transition: translate var(--ma-duration) var(--ma-ease);
    }
    :where(.ma-magnetic[data-moving] > *) {
      transition-duration: 120ms;
      transition-timing-function: linear;
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-magnetic > *) {
        translate: 0 0;
        transition: none;
      }
    }
  }
</style>

<script>
  import { onMount, prefersReducedMotion } from '../lib/runtime';

  onMount<HTMLElement>('[data-ma-magnetic]', (el, { signal }) => {
    const strength = Number(el.style.getPropertyValue('--ma-mg-strength') || 0.4);
    const pull = (x: number, y: number) => {
      el.style.setProperty('--ma-mx', `${x}px`);
      el.style.setProperty('--ma-my', `${y}px`);
    };
    const leave = () => {
      delete el.dataset.moving;
      pull(0, 0);
    };
    // the field lets the pointer through (it lies over the neighbours), so it is measured, not hit: the document hears the
    // moves and the wrapper's box is the field, whatever box its parent has
    document.addEventListener(
      'pointermove',
      (e) => {
        const r = el.getBoundingClientRect();
        const x = e.clientX - r.left;
        const y = e.clientY - r.top;
        // outside the field, or reduced motion switched on since the bind: spring back once
        if (x < 0 || y < 0 || x > r.width || y > r.height || prefersReducedMotion()) return 'moving' in el.dataset && leave();
        pull((x - r.width / 2) * strength, (y - r.height / 2) * strength);
        el.dataset.moving = '';
      },
      { signal, passive: true },
    );
    // the pointer left the window, a pen left its range or a touch ended: spring back
    document.addEventListener('pointerout', (e) => e.relatedTarget || leave(), { signal });
  });
</script>

Props

PropTypeDefaultDescription
asHTMLTag'span'Element to render.
strengthnumber0.4Pull as a fraction of the cursor's offset from the centre. Over 0.6 the button escapes the cursor.
radiusnumber40Field radius around the child in px; the pull starts when the cursor enters it.

Reduced motion

No pull: the child stays put in CSS and the script checks the setting on every move, so switching reduced motion on or off (the system or the page's own switch) applies at once.

With ClientRouter

Bound through the shared runtime; listeners aborted before the swap.

Why it is built this way

Replaces: Magnet (React Bits) · magnetic button (GSAP tutorials). See the migration table.

A field of cells that light up under the cursor and fade where it has been, with a slow idle sweep when nothing is touched.

every browserpointeralways

Spotlight

Pointer

A soft radial light follows the cursor over a card and fades in only while hovered.

every browserpointerhover

Tilt

Pointer

A card leans toward the cursor in 3D, lifts slightly, catches a glare, and settles back on the active preset's spring when the cursor leaves.

every browserpointerhover

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown