Skip to content
38

Magnetic Button

Button that pulls toward the cursor and springs back on leave. Adjustable strength, asChild to wrap a link, respects reduced-motion.

Example

Installation

npx shadcn@latest add https://hirael.com/r/magnetic-button.json

Usage

import { MagneticButton } from "@/components/magnetic-button"

Paths assume the default @/ alias from your components.json. The example above shows the parts composed.

API

<MagneticButton />

+ native element props
PropTypeDefault
strength

Pull strength as a fraction of the cursor's distance from center.

number0.4
asChild

Render the child element instead of a button (e.g. a link).

booleanfalse
size"default" | "xs" | "sm" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg" | nullnone
variant"link" | "default" | "destructive" | "outline" | "secondary" | "ghost" | nullnone

Component source

'use client';

import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { type HTMLMotionProps, motion, useReducedMotion, useSpring } from 'motion/react';

import { cn } from '@/lib/utils';
import { type Button, buttonVariants } from '@/registry/hirael/bases/radix/ui/button';

const MotionSlot = motion.create(Slot);

const SPRING = { stiffness: 200, damping: 15, mass: 0.1 };

export interface MagneticButtonProps
  extends HTMLMotionProps<'button'>, Pick<React.ComponentProps<typeof Button>, 'variant' | 'size'> {
  /** Pull strength as a fraction of the cursor's distance from center. */
  strength?: number;
  /** Render the child element instead of a button (e.g. a link). */
  asChild?: boolean;
}

const MagneticButton = ({
  className,
  strength = 0.4,
  variant,
  size,
  asChild = false,
  onPointerMove,
  onPointerLeave,
  ...props
}: MagneticButtonProps) => {
  const reduced = useReducedMotion();
  const x = useSpring(0, SPRING);
  const y = useSpring(0, SPRING);

  const handlePointerMove = (event: React.PointerEvent<HTMLButtonElement>) => {
    onPointerMove?.(event);
    if (event.defaultPrevented || reduced || event.pointerType !== 'mouse') return;
    const rect = event.currentTarget.getBoundingClientRect();
    x.set((event.clientX - (rect.left + rect.width / 2)) * strength);
    y.set((event.clientY - (rect.top + rect.height / 2)) * strength);
  };

  const handlePointerLeave = (event: React.PointerEvent<HTMLButtonElement>) => {
    onPointerLeave?.(event);
    x.set(0);
    y.set(0);
  };

  const Comp = asChild ? MotionSlot : motion.button;

  return (
    <Comp
      data-slot="magnetic-button"
      onPointerMove={handlePointerMove}
      onPointerLeave={handlePointerLeave}
      style={{ x, y }}
      className={cn(
        (!asChild || variant || size) && buttonVariants({ variant, size }),
        // Motion drives the transform every frame; transition-all would make it lag behind the spring.
        'transition-[color,background-color,border-color,box-shadow]',
        className,
      )}
      {...props}
    />
  );
};

export { MagneticButton };

Dependencies

shadcn registry

button

npm

@radix-ui/react-slotmotion