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-buttonUsage
Usagetsx
<MagneticButton />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| href | string | — |
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