Skip to content
Corsair UI
Browse the docs

MotionNo. 77 of 109

Magnetic

Pulls its content toward the mouse while hovered and springs it back on leave.

Installation

pnpm dlx shadcn@latest add @corsair-ui/magnetic

No setup needed: the CLI finds @corsair-ui in the shadcn registry directory. The files land in your project as source: edit them like your own code. More in Installation.

Also adds
utils
npm
clsxtailwind-merge

Examples

Strengths

Mouse and pen only; the hit area never moves.

API

PropTypeDefault
strength

How far the content follows the pointer, as a fraction of its offset.

number0.3

Accessibility

  • Only mouse and pen move it; the hit area and keyboard focus stay where they are.
  • With prefers-reduced-motion it holds still and shows the finished state.

Compatibility

Tailwind CSS
3.4 and 4, both checked in CI
React
19
Rendering
Client component ("use client")
Motion
Respects prefers-reduced-motion

Source

1 file, installed as source you own
magnetic.tsxView on GitHub
"use client";

import { useEffect, useRef, type ComponentProps } from "react";

import { cn } from "@/registry/default/lib/utils";

const FOLLOW = "transform 200ms cubic-bezier(0.22, 1, 0.36, 1)";
// A slight overshoot on the way back, like a spring settling.
const RELEASE = "transform 600ms cubic-bezier(0.34, 1.56, 0.64, 1)";

function prefersReducedMotion() {
  return window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
}

interface MagneticProps extends ComponentProps<"span"> {
  /** How far the content follows the pointer, as a fraction of its distance from the center. */
  strength?: number;
}

/**
 * Pulls its content toward the mouse while hovered and springs it back on
 * leave. The outer element never moves, so the hit area stays put. Touch,
 * keyboard and `prefers-reduced-motion` get the content unchanged.
 *
 * @example
 * <Magnetic><Button>Get started</Button></Magnetic>
 */
function Magnetic({
  strength = 0.3,
  className,
  children,
  onPointerMove,
  onPointerLeave,
  ...props
}: MagneticProps) {
  const content = useRef<HTMLSpanElement>(null);
  const frame = useRef(0);

  useEffect(() => () => cancelAnimationFrame(frame.current), []);

  const moveTo = (x: number, y: number, transition: string) => {
    const node = content.current;
    if (!node) return;
    node.style.transition = transition;
    node.style.transform = x || y ? `translate3d(${x}px, ${y}px, 0)` : "";
  };

  return (
    <span
      data-slot="magnetic"
      className={cn("inline-block", className)}
      onPointerMove={(event) => {
        onPointerMove?.(event);
        if (event.pointerType === "touch" || prefersReducedMotion()) return;
        const target = event.currentTarget;
        const { clientX, clientY } = event;
        cancelAnimationFrame(frame.current);
        frame.current = requestAnimationFrame(() => {
          const bounds = target.getBoundingClientRect();
          moveTo(
            (clientX - bounds.left - bounds.width / 2) * strength,
            (clientY - bounds.top - bounds.height / 2) * strength,
            FOLLOW
          );
        });
      }}
      onPointerLeave={(event) => {
        onPointerLeave?.(event);
        cancelAnimationFrame(frame.current);
        moveTo(0, 0, RELEASE);
      }}
      {...props}
    >
      <span ref={content} data-slot="magnetic-content" className="inline-block">
        {children}
      </span>
    </span>
  );
}

export { Magnetic, type MagneticProps };