bottega

primitives

Magnetic Button

A button that magnetically attracts toward the cursor on hover using a spring animation. Snaps back on pointer leave. Fully accessible: keyboard operable, focus-visible ring, spreads all button props. Pass an `href` to render as a link (`<a>`) instead of a `<button>`, keeping the same magnet effect.


Install

1. Register the namespace (once per project):

json
// components.json — register the @bottega namespace once
{
  "registries": {
    "@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
  }
}

2. Add the component:

bash
npx shadcn add @bottega/magnetic-button

Usage

Usagetsx
<MagneticButton />

Props

PropTypeDefaultDescription
hrefstring

Source

magnetic-button.tsxtsx
"use client";

import { useEffect, useRef, useState } from "react";
import { motion, useMotionValue, useSpring, useReducedMotion } from "motion/react";
import type { HTMLMotionProps } from "motion/react";

const SPRING = { stiffness: 300, damping: 20, mass: 0.5 };
const PULL_STRENGTH = 0.35;

export type MagneticButtonProps =
  | ({ href?: undefined } & Omit<HTMLMotionProps<"button">, "style">)
  | ({ href: string } & Omit<HTMLMotionProps<"a">, "style">);

export function MagneticButton(props: MagneticButtonProps) {
  // Mount-gate + null-safe reduce check (contract): useReducedMotion() is null
  // on SSR and the first client render, so server and first paint are identical
  // (springs at rest, no whileTap) — no hydration mismatch.
  const [mounted, setMounted] = useState(false);
  useEffect(() => {
    setMounted(true);
  }, []);
  const prefersReducedMotion = useReducedMotion();
  const reduce = prefersReducedMotion === null || prefersReducedMotion;
  const animate = mounted && !reduce;
  const ref = useRef<HTMLElement>(null);

  const rawX = useMotionValue(0);
  const rawY = useMotionValue(0);
  const x = useSpring(rawX, SPRING);
  const y = useSpring(rawY, SPRING);

  function handlePointerMove(e: React.PointerEvent<HTMLElement>) {
    if (!animate || !ref.current) return;
    const rect = ref.current.getBoundingClientRect();
    const cx = rect.left + rect.width / 2;
    const cy = rect.top + rect.height / 2;
    rawX.set((e.clientX - cx) * PULL_STRENGTH);
    rawY.set((e.clientY - cy) * PULL_STRENGTH);
  }

  function handlePointerLeave() {
    rawX.set(0);
    rawY.set(0);
  }

  const className = [
    "inline-flex items-center justify-center",
    "px-6 py-3 text-sm font-medium",
    "bg-primary text-primary-foreground",
    "rounded-[var(--radius)]",
    "cursor-pointer select-none",
    "transition-shadow duration-[var(--duration)]",
    "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
    props.className,
  ]
    .filter(Boolean)
    .join(" ");

  const style = animate ? { x, y } : undefined;
  const whileTap = animate ? { scale: 0.95 } : undefined;

  if (props.href !== undefined) {
    const { children, className: _className, ...rest } = props;
    return (
      <motion.a
        ref={ref as React.Ref<HTMLAnchorElement>}
        style={style}
        onPointerMove={handlePointerMove}
        onPointerLeave={handlePointerLeave}
        whileTap={whileTap}
        className={className}
        {...rest}
      >
        {children}
      </motion.a>
    );
  }

  const { children, className: _className, href: _href, ...rest } = props;
  return (
    <motion.button
      ref={ref as React.Ref<HTMLButtonElement>}
      style={style}
      onPointerMove={handlePointerMove}
      onPointerLeave={handlePointerLeave}
      whileTap={whileTap}
      className={className}
      {...rest}
    >
      {children}
    </motion.button>
  );
}

Dependencies

  • motion
  • @bottega/tokens