Components Motion Primitives MagneticButton

MagneticButton

Button that drifts toward the cursor.

Live demo

01

Default

strength 0.3 Β· radius 100 Β· damping 0.1

Strong & wide

strength 0.6 Β· radius 180

Subtle & quick

strength 0.15 Β· damping 0.05

Implementation

02
MagneticButton.svelte
<script>
  import MagneticButton from '$lib/components/MagneticButton.svelte';
</script>
​
<MagneticButton strength={0.3} radius={120}>
  <button type="button">Hover me</button>
</MagneticButton>

MagneticButton wraps any focusable element and pulls it toward the cursor when the pointer enters its radius. The translation is interpolated each frame using a critically-damped easing (the damping prop), so motion stays smooth even when the cursor changes direction sharply. Touch and reduced-motion users see a static control β€” the wrapper short-circuits before binding listeners.

Logic explainer

03

What Does It Do? (Plain English)

MagneticButton is a wrapper that makes its child element subtly chase the cursor β€” as you move the mouse near a wrapped button or link, the element drifts a few pixels in that direction, like an iron filing tugged towards a magnet. It is a tactile "this is interactive" cue, especially common on premium product pages where every CTA needs a moment of personality.

The wrapper itself has no semantics. The interactive element you put inside (a <button>, an <a>, whatever) keeps its native role, focus ring, ARIA labels, and keyboard handling. MagneticButton only exists to translate the inner element by a few pixels in response to cursor proximity. On a touchscreen or with reduced motion enabled, the wrapper goes inert and the button behaves like an ordinary button.

How It Works (Pseudo-Code)

state:
  x, y          = (0, 0)        // current translation in pixels
  prefersReducedMotion          // capability flag
  coarsePointer                 // capability flag (touch device)

on mount:
  read matchMedia('(prefers-reduced-motion: reduce)')
  read matchMedia('(pointer: coarse)')
  subscribe to both for live OS-level changes
  return cleanup that unsubscribes on destroy

on mousemove(event) over wrapper:
  if prefersReducedMotion or coarsePointer: return       // inert path
  rect       = wrapper.getBoundingClientRect()
  centerX    = rect.left + rect.width / 2
  centerY    = rect.top  + rect.height / 2
  dx         = event.clientX βˆ’ centerX
  dy         = event.clientY βˆ’ centerY
  distance   = sqrt(dxΒ² + dyΒ²)
  if distance < radius:
    factor = (1 βˆ’ distance / radius) Γ— strength    // closer = stronger pull
    x = dx Γ— factor
    y = dy Γ— factor
  else:
    x = 0; y = 0                                   // outside influence

on mouseleave:
  x = 0; y = 0                                     // CSS transition eases home

render:
  outer wrapper:
    padding  = radius           // expands hit area to capture pre-hover
    margin   = -radius          // cancels visual padding so layout stays put
  inner content:
    transform: translate(x, y)
    transition: transform damping s cubic-bezier(.23, 1, .32, 1)

The outer wrapper's padding: radius; margin: -radius; trick is the small but critical detail that lets the magnetic pull begin before the cursor reaches the visible button. Without it, you would only see motion once the pointer was already on the element β€” which defeats the point.

The Core Concept: Linear Pull With A Cosine-Shaped Glide-Home

The displacement formula is deliberately simple:

factor = (1 βˆ’ distance / radius) Γ— strength
x = (cursorX βˆ’ centerX) Γ— factor
y = (cursorY βˆ’ centerY) Γ— factor

Plotting factor against distance is a straight line from strength (when the cursor is on top of the centre) down to 0 (at the edge of the influence radius):

strength │●
         β”‚ ●
         β”‚  ●
         β”‚   ●
       0 β”‚    ●─────────── distance (px)
         0    radius

A linear pull is the right call here because the element ought to track the cursor more or less proportionally β€” anything fancier (an exponential, a smoothstep) makes the motion feel laggy at close range. The motion feels eased not because the pull curve is curved, but because the return-to-rest is curved: the inner element carries a CSS transition: transform <damping>s cubic-bezier(0.23, 1, 0.32, 1). That cubic-bezier is one of the standard "ease-out-expo"-flavoured curves β€” quick at the start, slow at the end β€” which produces the silky settle when the cursor leaves.

So the recipe is: hard-realtime linear math while the cursor is inside the radius, soft cubic-bezier easing whenever the position changes (which the CSS transition handles for free, frame after frame). The damping prop is the duration of that CSS transition, not a physics damping coefficient β€” a slight misnomer kept for friendliness.

The radius prop controls reach (how far away the magnet starts pulling); the strength prop controls how much of that pull lands as visible motion. Default strength = 0.3 means at the centre the element moves 30% of the cursor's offset towards itself. Going above ~0.5 makes the element feel like it is being chased too hard and looks gummy.

CSS Animation Strategy

JavaScript writes inline style:transform and style:transition per pointermove; CSS handles the visible motion via will-change: transform to keep the inner element on its own GPU layer.

.magnetic-wrapper {
  display: inline-block;
  padding: var(--radius);   /* enlarged hit area */
  margin: calc(var(--radius) * -1);  /* layout-neutral */
  cursor: pointer;
}

.magnetic-content {
  display: block;
  will-change: transform;
}

Reduced motion is handled at the JavaScript layer rather than via @media: when prefers-reduced-motion: reduce is true, the pointermove handler returns immediately so x and y never leave (0, 0). The element is still in the DOM, fully accessible, fully clickable β€” it just doesn't drift.

Coarse-pointer devices get the same kill switch. Tap-and-hold could theoretically fire pointermove, but the result on most touch hardware is jittery and the magnetic wobble looks like a bug. Better to render a clean, static button.

Performance

Per cursor frame:

  • one getBoundingClientRect() (cheap; no forced layout if no other code is reading layout that frame)
  • four arithmetic ops (subtract, sqrt, divide, multiply)
  • two $state writes (x, y)
  • two inline-style updates that the compositor folds into the existing layer

Steady state when the cursor is outside the wrapper or absent: zero. There is no requestAnimationFrame loop, no observer, no timer.

The initial mount cost is two matchMedia subscriptions, both released by a returned cleanup closure on destroy.

State Flow Diagram

              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
              β”‚  mount                 β”‚
              β”‚  read motion / pointer β”‚
              β”‚  subscribe to changes  β”‚
              β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                          β”‚ cursor enters padded hit area
                          β–Ό
              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
              β”‚  ACTIVE                β”‚  ← every mousemove β†’ recompute (x, y)
              β”‚  x, y track cursor     β”‚
              β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                          β”‚ cursor leaves
                          β–Ό
              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
              β”‚  REST                  β”‚  ← (x, y) = (0, 0)
              β”‚  CSS transition eases  β”‚
              β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

  Reduced-motion: reduce  ──►  pointermove returns early. ACTIVE never entered.
  Coarse pointer (touch)  ──►  pointermove returns early. Static button.
  OS preference flips     ──►  matchMedia listeners flip the gates live.

Props Reference

Prop Type Default Description
children Snippet required The interactive element to wrap (button, link, etc). The wrapper does not own focus or click.
strength number 0.3 Multiplier on the linear pull. 0.3 means the element travels 30% of the cursor's offset toward itself when at centre. Values >0.5 feel sticky.
radius number 100 Influence radius in pixels. The pull starts at this distance and ramps to maximum at the centre. The wrapper's hit-area padding is set to this value so the pull begins before the cursor visually reaches the element.
damping number 0.1 Duration in seconds of the CSS transition that runs whenever x / y change. Higher = silkier glide back to rest, lower = snappier tracking.

Edge Cases

Situation Behaviour
prefers-reduced-motion: reduce set at OS level Pointermove handler bails out; element stays at (0, 0). Listener stays subscribed so the user can flip the preference and the magnet wakes up live.
Touch device (pointer: coarse) Same kill switch. The button works as a normal tap target.
OS preference flips at runtime The two matchMedia listeners update prefersReducedMotion / coarsePointer reactively, so the magnet flips on/off without a page reload.
Multiple stacked wrappers Each wrapper is independent; pointermove fires on whichever element is currently under the cursor (or its ancestor chain). The padding / negative-margin trick keeps layout neutral so wrappers can sit inside flex/grid containers without disturbing alignment.
Very small wrapped element with default radius=100 The padded hit-area can extend well beyond the element. This is intentional β€” it's how the pull starts before you reach the button β€” but be aware that the wrapper covers ~200Γ—200 px of pointer-event surface around any 24Γ—24 px icon button. Stagger them or shrink radius.
Disabled wrapped button The wrapper still moves β€” the magnet is wrapper-side. If the visual feedback feels wrong on a disabled state, set strength={0} or omit MagneticButton when the inner button is disabled.
Cursor enters and immediately leaves Single pointermove fires (sets x,y), then mouseleave fires (resets to 0,0). The CSS transition handles both transitions smoothly without any "snap".

Dependencies

  • Svelte 5 β€” $state, $props, Snippet. Reactivity is the whole story; no manual subscription bookkeeping needed.
  • onMount / cleanup β€” to subscribe / unsubscribe from matchMedia listeners safely.
  • Zero external libraries β€” no animation library, no spring physics library. The cubic-bezier cubic-bezier(0.23, 1, 0.32, 1) is built into CSS.

File Structure

src/lib/components/MagneticButton.svelte      # implementation
src/lib/components/MagneticButton.md          # this explainer
src/routes/magneticbutton/+page.svelte        # demo page

API

04
PropTypeDefaultDescription
strengthnumber0.3Fraction of cursor-to-centre distance to travel. Higher = stronger pull.
radiusnumber100Trigger radius in pixels around the wrapped element.
dampingnumber0.10–1. Lower values smooth the motion across more frames.