# Magnetic Button

> Pulled toward the cursor, with a label that leads.

Category: Buttons. Docs: https://neonkit.heyaadi.com/components/magnetic-button

## Installation

```bash
npx shadcn@latest add https://neonkit.heyaadi.com/r/magnetic-button.json
```

With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/magnetic-button`.

npm dependencies: none. Also needs `lib/utils.ts` (`cn`) and `lib/neon.ts` (`neonVars`), which the CLI adds.

## Usage

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

<MagneticButton variant="solid" size="lg">Start a conversation</MagneticButton>
```

## Examples

### Contact call to action

Bring the pointer near: the button leans toward it on a spring, the label leads a little further, and the glow warms up.

```tsx
import { ArrowRight } from "@/components/ui/icons";
import { MagneticButton } from "@/components/ui/magnetic-button";

export default function MagneticButtonDemo() {
  return (
    <div className="flex flex-col items-center gap-7 text-center">
      <div className="space-y-2">
        <h3 className="text-balance text-2xl font-medium tracking-[-0.02em] text-white/90">Have something in mind?</h3>
        <p className="text-[13px] text-white/55">Taking on two projects for July through September.</p>
      </div>
      <div className="flex flex-wrap items-center justify-center gap-3">
        <MagneticButton size="lg" variant="solid">
          Start a conversation <ArrowRight />
        </MagneticButton>
        <MagneticButton size="lg" variant="ghost">
          See recent work
        </MagneticButton>
      </div>
    </div>
  );
}
```

### Icon links

Round icon buttons rendered as links with asChild.

```tsx
import { Mail, MessagesSquare, Rss } from "@/components/ui/icons";
import { MagneticButton } from "@/components/ui/magnetic-button";

const links = [
  { label: "Email the studio", icon: Mail, href: "mailto:hello@example.com" },
  { label: "Join the community chat", icon: MessagesSquare, href: "#community" },
  { label: "Subscribe to the RSS feed", icon: Rss, href: "/changelog" },
];

export default function MagneticButtonIcons() {
  return (
    <nav aria-label="Elsewhere" className="flex items-center gap-4">
      {links.map(({ label, icon: Icon, href }) => (
        <MagneticButton key={label} asChild size="icon" strength={0.35} radius={40}>
          <a href={href} aria-label={label}>
            <Icon />
          </a>
        </MagneticButton>
      ))}
    </nav>
  );
}
```

### Strength and reach

radius is how far beyond the edge the pull starts; strength is how much of the pointer's offset the button follows.

```tsx
import { MagneticButton } from "@/components/ui/magnetic-button";

export default function MagneticButtonStrength() {
  return (
    <div className="flex flex-wrap items-center justify-center gap-12">
      <MagneticButton strength={0.1} radius={32} labelStrength={0.15}>
        Subtle
      </MagneticButton>
      <MagneticButton>Default</MagneticButton>
      <MagneticButton strength={0.4} radius={96} labelStrength={0.5}>
        Strong
      </MagneticButton>
    </div>
  );
}
```

### As a link

```tsx
import { ArrowUpRight } from "@/components/ui/icons";
import { MagneticButton } from "@/components/ui/magnetic-button";

export default function MagneticButtonAsLink() {
  return (
    <MagneticButton asChild color="lime">
      <a href="https://github.com/AadiXC0DE/NeonKit" target="_blank" rel="noreferrer">
        Star on GitHub <ArrowUpRight />
      </a>
    </MagneticButton>
  );
}
```

## API reference

### MagneticButton

Also accepts every native button attribute. The ref points at the button (or the asChild element).

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `radius` | `number` | `56` | Pixels beyond the button's edge where the pull begins. It eases in from zero at that distance. |
| `strength` | `number` | `0.2` | Share of the pointer's offset from center the button follows, capped at about a third of its height. |
| `labelStrength` | `number` | `0.3` | How much further the label travels than the button, as a share of the button's offset. |
| `variant` | `"outline" \| "solid" \| "ghost"` | `"outline"` | The same faces as Button. |
| `size` | `"sm" \| "md" \| "lg" \| "icon"` | `"md"` | 32, 40 or 48px tall like Button; icon is a 40px circle. |
| `color` | `NeonColor` | `inherited` | Palette name or hex. Falls back to --neon-accent, then cyan. |
| `asChild` | `boolean` | `false` | Render the child element (a link, say) as the magnet and merge props onto it. |
| `disabled` | `boolean` | `false` | Dims the button, blocks clicks and switches the pull off. |

## Accessibility

- The pull only runs for a mouse or pen on devices that can hover. Touch gets a plain button.
- Under prefers-reduced-motion the button stays put.
- The offset uses the CSS translate property, so it never fights the press scale or your own transforms.
- Motion runs on a small spring in requestAnimationFrame with direct style writes; React does not re-render while it moves.

## Source

`components/ui/magnetic-button.tsx`

```tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { neonVars, type NeonColor } from "@/lib/neon";

type RenderProp = { asChild?: boolean; render?: React.ReactElement; nativeButton?: boolean; children?: React.ReactNode };

/**
 * Base UI spells asChild as `render={<Link />}` plus `nativeButton`, and the shadcn CLI rewrites
 * asChild into that form in base-* projects. Folds it back into asChild and children.
 */
function fromRenderProp<P extends RenderProp>({ render, nativeButton, ...props }: P): Omit<P, "render" | "nativeButton"> {
  void nativeButton; // Only meaningful to Base UI.
  if (!render) return props;
  const children = props.children === undefined ? render : React.cloneElement(render, undefined, props.children);
  return { ...props, asChild: true, children };
}

// Server Components can hand children over as lazy references, which only resolve
// on the client. Unwrapping them here keeps server and client HTML the same.
const reactUse = (React as unknown as Record<string, unknown>)[" use ".trim()] as
  | (<T>(usable: PromiseLike<T>) => T)
  | undefined;

function unwrapLazy(node: React.ReactNode): React.ReactNode {
  const lazy = node as { $$typeof?: symbol; _payload?: { then?: unknown } } | null;
  if (
    reactUse &&
    lazy &&
    typeof lazy === "object" &&
    lazy.$$typeof === Symbol.for("react.lazy") &&
    typeof lazy._payload?.then === "function"
  ) {
    return reactUse(lazy._payload as PromiseLike<React.ReactNode>);
  }
  return node;
}

const variants = {
  outline:
    "border border-[rgb(var(--neon)/0.7)] bg-[rgb(var(--neon)/0.06)] text-[rgb(var(--neon))] shadow-[0_0_12px_rgb(var(--neon)/CALC(0.25*var(--neon-glow,1))),inset_0_0_12px_rgb(var(--neon)/CALC(0.12*var(--neon-glow,1)))] [text-shadow:0_0_10px_rgb(var(--neon)/CALC(0.6*var(--neon-glow,1)))] hover:border-[rgb(var(--neon))] hover:bg-[rgb(var(--neon)/0.12)] hover:shadow-[0_0_24px_rgb(var(--neon)/CALC(0.5*var(--neon-glow,1))),inset_0_0_16px_rgb(var(--neon)/CALC(0.2*var(--neon-glow,1)))]",
  solid:
    "bg-[rgb(var(--neon))] text-[#05060a] shadow-[0_0_0_1px_rgb(var(--neon)),0_0_18px_rgb(var(--neon)/CALC(0.45*var(--neon-glow,1))),inset_0_1px_0_rgb(255_255_255/0.45)] hover:shadow-[0_0_0_1px_rgb(var(--neon)),0_0_32px_rgb(var(--neon)/CALC(0.7*var(--neon-glow,1))),inset_0_1px_0_rgb(255_255_255/0.45)] hover:brightness-110",
  ghost: "text-white/70 hover:bg-[rgb(var(--neon)/0.1)] hover:text-[rgb(var(--neon))]",
} as const;

const sizes = {
  sm: "h-8 gap-1.5 rounded-md px-3 text-xs [&_svg]:size-4",
  md: "h-10 gap-2 rounded-lg px-4 text-sm [&_svg]:size-4",
  lg: "h-12 gap-2.5 rounded-xl px-6 text-base [&_svg]:size-[18px]",
  // A lone glyph carries the button, so it gets more of the circle.
  icon: "size-10 rounded-full [&_svg]:size-[18px]",
} as const;

// Slightly under-damped: it follows briskly and overshoots a touch when let go.
const STIFFNESS = 320;
const DAMPING = 22;

type AnyProps = Record<string, unknown>;
type PossibleRef<T> = React.Ref<T> | undefined;

function setRef<T>(ref: PossibleRef<T>, value: T | null) {
  if (typeof ref === "function") ref(value);
  else if (ref) (ref as React.MutableRefObject<T | null>).current = value;
}

// React 19 reads `ref` from props; React 18 keeps it on the element.
function getElementRef(element: React.ReactElement) {
  if (Number.parseInt(React.version, 10) >= 19) {
    return (element.props as { ref?: React.Ref<HTMLElement> }).ref;
  }
  return (element as unknown as { ref?: React.Ref<HTMLElement> }).ref;
}

/** Our props under the child's: handlers both run (child first), classes and styles merge. */
function mergeProps(ours: AnyProps, theirs: AnyProps): AnyProps {
  const merged: AnyProps = { ...ours, ...theirs };
  for (const key of Object.keys(ours)) {
    const a = ours[key];
    const b = theirs[key];
    if (/^on[A-Z]/.test(key) && typeof a === "function" && typeof b === "function") {
      merged[key] = (...args: unknown[]) => {
        (b as (...args: unknown[]) => void)(...args);
        (a as (...args: unknown[]) => void)(...args);
      };
    } else if (key === "className") {
      merged[key] = cn(a as string, b as string);
    } else if (key === "style") {
      merged[key] = { ...(a as object), ...(b as object) };
    }
  }
  return merged;
}

export interface MagneticButtonProps
  extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "color"> {
  /** Palette name or any hex color. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
  variant?: keyof typeof variants;
  /** `icon` is a round 40px button for a single glyph. */
  size?: keyof typeof sizes;
  /** How far (px) beyond the button's edge the pull reaches. */
  radius?: number;
  /** Share of the pointer's offset from center the button follows, 0 to 1. Capped near a third of its height. */
  strength?: number;
  /** How much further the label travels than the button, as a share of the button's offset. */
  labelStrength?: number;
  /** Render the child element (e.g. a link) as the magnet. */
  asChild?: boolean;
  /** Base UI-style asChild: `render={<Link href="/" />}`. */
  render?: React.ReactElement;
  /** Accepted for Base UI compatibility; no effect. */
  nativeButton?: boolean;
}

const MagneticButton = React.forwardRef<HTMLButtonElement, MagneticButtonProps>(
  (raw, ref) => {
    const {
      className,
      style,
      color,
      variant = "outline",
      size = "md",
      radius = 56,
      strength = 0.2,
      labelStrength = 0.3,
      asChild = false,
      disabled,
      children,
      ...props
    } = fromRenderProp(raw);
    const rootRef = React.useRef<HTMLElement | null>(null);
    const labelRef = React.useRef<HTMLSpanElement>(null);
    const resolved = asChild ? unwrapLazy(children) : children;
    const child = asChild && React.isValidElement<AnyProps>(resolved) ? resolved : null;
    const childRef = child ? getElementRef(child) : undefined;
    const composedRef = React.useCallback(
      (node: HTMLButtonElement | null) => {
        rootRef.current = node;
        setRef(ref, node);
        setRef(childRef as PossibleRef<HTMLButtonElement>, node);
      },
      [ref, childRef],
    );

    React.useEffect(() => {
      const root = rootRef.current;
      if (!root || disabled || typeof window === "undefined" || !window.matchMedia) return;
      const finePointer = window.matchMedia("(hover: hover) and (pointer: fine)");
      const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
      if (!finePointer.matches || reducedMotion.matches) return;

      const pos = { x: 0, y: 0 };
      const vel = { x: 0, y: 0 };
      const target = { x: 0, y: 0, pull: 0 };
      let pull = 0;
      let frame = 0;
      let last = 0;

      const label = labelRef.current;
      const write = () => {
        root.style.translate = `${pos.x.toFixed(2)}px ${pos.y.toFixed(2)}px`;
        root.style.setProperty("--nk-magnetic-button-pull", pull.toFixed(3));
        if (label) {
          label.style.translate = `${(pos.x * labelStrength).toFixed(2)}px ${(pos.y * labelStrength).toFixed(2)}px`;
        }
      };

      const step = (now: number) => {
        const dt = Math.min((now - (last || now)) / 1000, 1 / 30);
        last = now;
        // Fixed sub-steps keep the spring stable when a frame runs long.
        const steps = Math.max(1, Math.ceil(dt / (1 / 240)));
        const h = dt / steps;
        for (let i = 0; i < steps; i++) {
          for (const axis of ["x", "y"] as const) {
            const force = -STIFFNESS * (pos[axis] - target[axis]) - DAMPING * vel[axis];
            vel[axis] += force * h;
            pos[axis] += vel[axis] * h;
          }
        }
        pull += (target.pull - pull) * Math.min(1, dt * 12);
        write();
        const resting =
          Math.abs(pos.x - target.x) < 0.01 &&
          Math.abs(pos.y - target.y) < 0.01 &&
          Math.abs(vel.x) < 0.01 &&
          Math.abs(vel.y) < 0.01 &&
          Math.abs(pull - target.pull) < 0.002;
        if (resting) {
          pos.x = target.x;
          pos.y = target.y;
          pull = target.pull;
          write();
          frame = 0;
          last = 0;
          return;
        }
        frame = requestAnimationFrame(step);
      };

      const kick = () => {
        if (!frame) frame = requestAnimationFrame(step);
      };

      const release = () => {
        target.x = 0;
        target.y = 0;
        target.pull = 0;
        kick();
      };

      const onMove = (event: PointerEvent) => {
        if (event.pointerType === "touch") return;
        const rect = root.getBoundingClientRect();
        // The rect includes the current offset; take it out to find where the button rests.
        const cx = rect.left + rect.width / 2 - pos.x;
        const cy = rect.top + rect.height / 2 - pos.y;
        const dx = event.clientX - cx;
        const dy = event.clientY - cy;
        const outsideX = Math.max(Math.abs(dx) - rect.width / 2, 0);
        const outsideY = Math.max(Math.abs(dy) - rect.height / 2, 0);
        const distance = Math.hypot(outsideX, outsideY);
        if (distance >= radius) {
          if (target.pull !== 0) release();
          return;
        }
        const t = 1 - distance / radius;
        const falloff = t * t * (3 - 2 * t);
        const cap = rect.height * 0.32;
        target.x = Math.max(-cap, Math.min(cap, dx * strength)) * falloff;
        target.y = Math.max(-cap, Math.min(cap, dy * strength)) * falloff;
        target.pull = falloff;
        kick();
      };

      const onOut = (event: PointerEvent) => {
        if (!event.relatedTarget) release();
      };

      window.addEventListener("pointermove", onMove, { passive: true });
      window.addEventListener("pointerout", onOut);
      window.addEventListener("blur", release);
      return () => {
        window.removeEventListener("pointermove", onMove);
        window.removeEventListener("pointerout", onOut);
        window.removeEventListener("blur", release);
        cancelAnimationFrame(frame);
        root.style.translate = "";
        root.style.removeProperty("--nk-magnetic-button-pull");
        if (label) label.style.translate = "";
      };
    }, [disabled, radius, strength, labelStrength]);

    const decorate = (label: React.ReactNode) => (
      <>
        <span
          aria-hidden
          className="pointer-events-none absolute -inset-px rounded-[inherit] opacity-[var(--nk-magnetic-button-pull,0)] shadow-[0_0_26px_rgb(var(--neon)/CALC(0.45*var(--neon-glow,1)))]"
        />
        <span
          ref={labelRef}
          className="relative inline-flex items-center gap-[inherit] will-change-[translate] [&_svg]:pointer-events-none [&_svg]:shrink-0"
        >
          {label}
        </span>
      </>
    );

    const rootProps: AnyProps = {
      style: neonVars(color, style),
      className: cn(
        "[--neon:var(--neon-accent,34_211_238)]",
        "relative inline-flex shrink-0 cursor-pointer select-none items-center justify-center whitespace-nowrap font-medium outline-none will-change-[translate]",
        "transition-[background-color,border-color,color,box-shadow,filter,transform,scale] duration-200 ease-out active:scale-[0.97]",
        "focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)] focus-visible:ring-offset-2 focus-visible:ring-offset-[#05060a]",
        "disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50",
        sizes[size],
        variants[variant],
        className,
      ),
      ...props,
    };

    if (child) {
      return React.cloneElement(
        child,
        {
          ...mergeProps(rootProps, child.props),
          ...(disabled && { "aria-disabled": true, tabIndex: -1 }),
          ref: composedRef,
        } as AnyProps,
        decorate(child.props.children as React.ReactNode),
      );
    }

    return (
      <button ref={composedRef} disabled={disabled} {...(rootProps as React.ButtonHTMLAttributes<HTMLButtonElement>)}>
        {decorate(children)}
      </button>
    );
  },
);
MagneticButton.displayName = "MagneticButton";

export { MagneticButton };
```
