Fazekit

Code

"use client";
import * as React from "react";
import { motion, useMotionValue, useReducedMotion, useSpring } from "motion/react";
import { cn } from "@/lib/utils";

export type MagneticButtonVariant = "solid" | "outline" | "ghost";
export type MagneticButtonSize = "sm" | "md" | "lg" | "icon";

export interface MagneticButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  /** Visual style of the shell. */
  variant?: MagneticButtonVariant;
  size?: MagneticButtonSize;
  /** How far the shell follows the cursor (0–1 of the cursor offset). */
  strength?: number;
  /** How far the inner label follows the cursor. Higher than `strength` gives a sense of depth. */
  labelStrength?: number;
  /** Extra invisible hit area (px) around the button that already attracts it. */
  reach?: number;
  /** Spring used for the pull and the release. */
  spring?: { stiffness: number; damping: number; mass?: number };
  /** Class for the outer (layout) wrapper. */
  wrapperClassName?: string;
}

const variants: Record<MagneticButtonVariant, string> = {
  solid:
    "bg-primary text-primary-foreground shadow-lg shadow-primary/25 hover:shadow-xl hover:shadow-primary/30",
  outline:
    "border border-foreground/15 bg-background/60 text-foreground backdrop-blur hover:border-foreground/30 hover:bg-accent",
  ghost: "text-foreground hover:bg-accent",
};

const sizes: Record<MagneticButtonSize, string> = {
  sm: "h-9 px-4 text-sm",
  md: "h-11 px-6 text-sm",
  lg: "h-14 px-8 text-base",
  icon: "size-14",
};

export function MagneticButton({
  variant = "solid",
  size = "md",
  strength = 0.3,
  labelStrength = 0.4,
  reach = 28,
  spring = { stiffness: 220, damping: 16, mass: 0.6 },
  className,
  wrapperClassName,
  children,
  disabled,
  ...props
}: MagneticButtonProps) {
  const reduce = useReducedMotion();
  const areaRef = React.useRef<HTMLSpanElement>(null);

  const x = useMotionValue(0);
  const y = useMotionValue(0);
  const lx = useMotionValue(0);
  const ly = useMotionValue(0);
  const sx = useSpring(x, spring);
  const sy = useSpring(y, spring);
  const slx = useSpring(lx, spring);
  const sly = useSpring(ly, spring);

  const onPointerMove = (e: React.PointerEvent<HTMLSpanElement>) => {
    if (reduce || disabled || e.pointerType !== "mouse" || !areaRef.current) return;
    const r = areaRef.current.getBoundingClientRect();
    const dx = e.clientX - (r.left + r.width / 2);
    const dy = e.clientY - (r.top + r.height / 2);
    x.set(dx * strength);
    y.set(dy * strength);
    // The label drifts a little further than the shell, but never leaves the button.
    const maxX = r.width / 2 - reach;
    const maxY = r.height / 2 - reach;
    lx.set(Math.max(-maxX * 0.3, Math.min(maxX * 0.3, dx * labelStrength * 0.5)));
    ly.set(Math.max(-maxY * 0.3, Math.min(maxY * 0.3, dy * labelStrength * 0.5)));
  };

  const reset = () => {
    x.set(0);
    y.set(0);
    lx.set(0);
    ly.set(0);
  };

  return (
    <span
      ref={areaRef}
      onPointerMove={onPointerMove}
      onPointerLeave={reset}
      className={cn("relative inline-flex", wrapperClassName)}
      style={{ padding: reach, margin: -reach }}
    >
      <motion.span style={{ x: sx, y: sy }} className="inline-flex">
        <button
          type="button"
          disabled={disabled}
          className={cn(
            "relative inline-flex select-none items-center justify-center overflow-hidden rounded-full font-medium transition-[background-color,border-color,box-shadow] duration-300 active:scale-[0.97]",
            "outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
            "disabled:pointer-events-none disabled:opacity-50",
            variants[variant],
            sizes[size],
            className,
          )}
          {...props}
        >
          <motion.span style={{ x: slx, y: sly }} className="relative inline-flex items-center gap-2">
            {children}
          </motion.span>
        </button>
      </motion.span>
    </span>
  );
}

More in Buttons

View all →