DesignPass.dev

ui

Free

Magnet

Spring-physics magnetic hover that pulls children toward the cursor with 3D tilt and a light glare that tracks the pointer.

// preview

Hover me

// settings

Pull radius (px)
Pull strength
Tilt amount
Lift

// source

TSJS
TailwindCSS
/*!
 * Magnet — a DesignPass.dev component by Ernest Liu
 * Docs & live playground: https://designpass.dev/components/magnet
 * MIT licensed — keep this notice in copies and adaptations.
 */
"use client";

import React, { useEffect, useRef, type HTMLAttributes, type ReactNode } from "react";

export interface MagnetProps extends HTMLAttributes<HTMLDivElement> {
  children: ReactNode;
  /** Extra distance (px) around the element where the pull begins. */
  padding?: number;
  disabled?: boolean;
  /** Higher = weaker pull (divisor on cursor offset). */
  magnetStrength?: number;
  /** Max tilt in degrees. Set to 0 to disable tilting. */
  tiltStrength?: number;
  /** Show a light sheen that follows the cursor across the surface. */
  glare?: boolean;
  /** Scale applied while the magnet is engaged. */
  lift?: number;
  /** Spring stiffness while tracking the cursor — higher snaps faster. */
  stiffness?: number;
  /** Spring damping while tracking — lower is looser. */
  damping?: number;
  wrapperClassName?: string;
  innerClassName?: string;
}

// Smoothstep gives the pull a soft radial falloff instead of a hard edge.
const smoothstep = (t: number) => {
  const c = Math.min(Math.max(t, 0), 1);
  return c * c * (3 - 2 * c);
};

const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max);

// Loose spring used when the cursor lets go, so the element
// overshoots and wobbles back into place instead of easing home.
const RELEASE_STIFFNESS = 0.055;
const RELEASE_DAMPING = 0.9;

export default function Magnet({
  children,
  padding = 100,
  disabled = false,
  magnetStrength = 2,
  tiltStrength = 12,
  glare = true,
  lift = 1.03,
  stiffness = 0.14,
  damping = 0.72,
  wrapperClassName = "",
  innerClassName = "",
  ...props
}: MagnetProps) {
  const wrapperRef = useRef<HTMLDivElement>(null);
  const innerRef = useRef<HTMLDivElement>(null);
  const glareRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (disabled) return;

    const wrapper = wrapperRef.current;
    const inner = innerRef.current;
    if (!wrapper || !inner) return;

    const coarse = window.matchMedia(
      "(max-width: 639px), (hover: none) and (pointer: coarse)"
    );
    const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
    if (coarse.matches || reducedMotion.matches) return;

    // Spring state lives outside React — no re-render per mousemove.
    const current = { x: 0, y: 0, rx: 0, ry: 0, s: 1, g: 0 };
    const target = { x: 0, y: 0, rx: 0, ry: 0, s: 1, g: 0 };
    const velocity = { x: 0, y: 0, rx: 0, ry: 0, s: 0, g: 0 };
    const glarePos = { x: 50, y: 50 };
    let engaged = false;
    let frame = 0;
    let settled = true;

    const keys = ["x", "y", "rx", "ry", "s", "g"] as const;

    const tick = () => {
      const k = engaged ? stiffness : RELEASE_STIFFNESS;
      const d = engaged ? damping : RELEASE_DAMPING;

      let energy = 0;
      for (const key of keys) {
        velocity[key] = (velocity[key] + (target[key] - current[key]) * k) * d;
        current[key] += velocity[key];
        energy += Math.abs(velocity[key]) + Math.abs(target[key] - current[key]);
      }

      inner.style.transform =
        `translate3d(${current.x}px, ${current.y}px, 0) ` +
        `rotateX(${current.rx}deg) rotateY(${current.ry}deg) scale(${current.s})`;

      const glareEl = glareRef.current;
      if (glareEl) {
        glareEl.style.opacity = String(clamp(current.g, 0, 1));
        glareEl.style.background = `radial-gradient(140% 140% at ${glarePos.x}% ${glarePos.y}%, rgba(255,255,255,0.32), rgba(255,255,255,0.08) 55%, transparent 80%)`;
      }

      if (energy < 0.005) {
        settled = true;
        return;
      }
      frame = requestAnimationFrame(tick);
    };

    const wake = () => {
      if (settled) {
        settled = false;
        frame = requestAnimationFrame(tick);
      }
    };

    const onMouseMove = (e: MouseEvent) => {
      const { left, top, width, height } = wrapper.getBoundingClientRect();
      const centerX = left + width / 2;
      const centerY = top + height / 2;
      const dx = e.clientX - centerX;
      const dy = e.clientY - centerY;

      const reachX = width / 2 + padding;
      const reachY = height / 2 + padding;
      const distance = Math.hypot(dx / reachX, dy / reachY);
      const pull = smoothstep(1 - distance);
      engaged = pull > 0.001;

      target.x = (dx / magnetStrength) * pull;
      target.y = (dy / magnetStrength) * pull;
      // Tilt is normalized against the element itself (not the reach zone)
      // so the surface visibly banks toward the cursor.
      target.ry = clamp(dx / (width / 2), -1, 1) * tiltStrength * pull;
      target.rx = clamp(-dy / (height / 2), -1, 1) * tiltStrength * pull;
      target.s = 1 + (lift - 1) * pull;
      target.g = pull;

      glarePos.x = 50 + clamp(dx / (width / 2), -1, 1) * 50;
      glarePos.y = 50 + clamp(dy / (height / 2), -1, 1) * 50;

      wake();
    };

    window.addEventListener("mousemove", onMouseMove, { passive: true });
    return () => {
      window.removeEventListener("mousemove", onMouseMove);
      cancelAnimationFrame(frame);
      inner.style.transform = "";
      if (glareRef.current) glareRef.current.style.opacity = "0";
    };
  }, [padding, disabled, magnetStrength, tiltStrength, lift, stiffness, damping]);

  return (
    <div
      ref={wrapperRef}
      className={`relative block ${wrapperClassName}`}
      style={{ perspective: "800px" }}
      {...props}
    >
      {/* Tip: give innerClassName the same border radius as your content
          (e.g. rounded-2xl) so the glare clips to the rounded corners. */}
      <div
        ref={innerRef}
        className={`relative size-full will-change-transform [transform-style:preserve-3d] ${innerClassName}`}
      >
        {children}
        {glare && (
          <div
            ref={glareRef}
            aria-hidden="true"
            className="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit] opacity-0 mix-blend-overlay"
          />
        )}
      </div>
    </div>
  );
}

// install

npx shadcn@latest add "https://designpass.dev/r/Magnet-TS-TW.json"

Need the license details? Read the component license.

// updates

Know when new components drop

A short email when something new lands in the library. No noise, unsubscribe anytime.