Cursor Grid
PointerA field of cells that light up under the cursor and fade where it has been, with a slow idle sweep when nothing is touched.
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, from the library itself: scroll, hover, navigate away and back. The demo is never gated.
Adjust · props are custom properties, set inline, so you edit the running instance
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
every browser.
One command adds the integration, the base tokens and every component. Then import what you use.
npx astro add moonarcpnpm astro add moonarcbunx astro add moonarc---
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>Owns the file, no dependency. The registry item also installs the shared runtime and the base tokens.
npx shadcn@latest add https://moonarc.dev/r/magnetic.jsonThe CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.
The whole component. Self-contained styles in a cascade layer so your classes always win. If you paste it, also copy runtime.ts.
---
/**
* 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>| 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. |
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.
ClientRouterBound through the shared runtime; listeners aborted before the swap.
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.
A soft radial light follows the cursor over a card and fades in only while hovered.
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.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown