# Magnetic (Moonarc)

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.

- Import: `import Magnetic from '@moonarc/core/Magnetic'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/magnetic.json`
- Tier B · category pointer · trigger pointer, hover
- Readout: `<Magnetic strength={0.4}>`
- Browser support: widely (every browser)
- Measured cost: 619 B raw JS · 391 B gzip · + runtime (with dependencies 2.3 kB raw; CSS 4.5 kB raw)
- Page: https://moonarc.dev/components/magnetic/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `as` | `HTMLTag` | `'span'` | Element to render. |
| `strength` | `number` | `0.4` | Pull as a fraction of the cursor's offset from the centre. Over 0.6 the button escapes the cursor. |
| `radius` | `number` | `40` | Field radius around the child in px; the pull starts when the cursor enters it. |

## Usage

```astro
<Magnetic strength={0.4} radius={48}>
  <a href="/setup/" class="rounded-full bg-fg px-5 py-3 text-bg">Get started</a>
</Magnetic>
```

## 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.

## Craft

- The field is padding with a negative margin, so the layout is unchanged and the cursor is caught before it touches the button. It has pointer-events: none (the child takes them back), so a neighbour inside the field keeps its clicks and its hover. The field is measured instead of hit: one passive pointermove listener on the document reads the wrapper's box, one rect read per move, and writes only while the pointer is inside.
- Following: 120 ms linear, because direct manipulation must not lag. Returning: the preset spring, because release is where the character lives.
- The pull is a fraction of the offset, not a fixed distance, so it is strongest at the edge of the field and the button always sits under the cursor on the way out.

## Replaces

- Magnet (React Bits)
- magnetic button (GSAP tutorials)

## Source

```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>

```
