Web InnoventixFreeCode

Magnetic Hover Effect

Original · free

A button that magnetically nudges toward the cursor.

byWeb InnoventixReact + Tailwind
hovermagnetic
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/hover-magnetic.json
hover-magnetic.tsx
"use client";

import { useCallback, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent, RefObject } from "react";
import { useReducedMotion } from "motion/react";

type Offset = { x: number; y: number };

const ZERO: Offset = { x: 0, y: 0 };

function useMagnetic(strength: number, radius: number) {
  const ref = useRef<HTMLElement | null>(null);
  const reduce = useReducedMotion();
  const [offset, setOffset] = useState<Offset>(ZERO);
  const [active, setActive] = useState(false);

  const onPointerMove = useCallback(
    (event: ReactPointerEvent<HTMLElement>) => {
      if (reduce) return;
      const el = ref.current;
      if (!el) return;
      const rect = el.getBoundingClientRect();
      const cx = rect.left + rect.width / 2;
      const cy = rect.top + rect.height / 2;
      const dx = event.clientX - cx;
      const dy = event.clientY - cy;
      const dist = Math.hypot(dx, dy);
      const falloff = Math.max(0, 1 - dist / radius);
      setOffset({ x: dx * strength * falloff, y: dy * strength * falloff });
    },
    [reduce, radius, strength],
  );

  const onPointerEnter = useCallback(() => setActive(true), []);

  const reset = useCallback(() => {
    setActive(false);
    setOffset(ZERO);
  }, []);

  return { ref, offset, active, onPointerMove, onPointerEnter, reset, reduce };
}

export default function HoverMagnetic() {
  const primary = useMagnetic(0.4, 160);
  const label = useMagnetic(0.28, 120);
  const ghost = useMagnetic(0.35, 150);
  const icon = useMagnetic(0.55, 130);

  return (
    <section className="relative w-full overflow-hidden bg-white px-6 py-24 text-slate-900 sm:px-10 dark:bg-slate-950 dark:text-slate-100">
      <style>{`
        @keyframes hovmag-sheen {
          0% { transform: translateX(-120%) skewX(-18deg); }
          100% { transform: translateX(220%) skewX(-18deg); }
        }
        @keyframes hovmag-pulse {
          0%, 100% { opacity: 0.35; transform: scale(1); }
          50% { opacity: 0.7; transform: scale(1.08); }
        }
        @media (prefers-reduced-motion: reduce) {
          .hovmag-sheen, .hovmag-pulse { animation: none !important; }
        }
      `}</style>

      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 [background:radial-gradient(60rem_60rem_at_50%_-10%,theme(colors.indigo.500/0.12),transparent)]"
      />

      <div className="relative mx-auto max-w-3xl">
        <div className="mb-14 text-center">
          <span className="inline-flex items-center gap-2 rounded-full border border-indigo-200 bg-indigo-50 px-3 py-1 text-xs font-medium tracking-wide text-indigo-700 uppercase dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-300">
            <span className="hovmag-pulse h-1.5 w-1.5 rounded-full bg-indigo-500" />
            Magnetic hover
          </span>
          <h2 className="mt-5 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
            Buttons that lean into your cursor
          </h2>
          <p className="mx-auto mt-3 max-w-xl text-base text-slate-600 dark:text-slate-400">
            Move your pointer near any element below. It nudges toward you with a
            soft spring and snaps home when you leave. Keyboard focus and reduced
            motion are fully respected.
          </p>
        </div>

        <div className="flex flex-col items-center gap-12">
          {/* Primary magnetic CTA */}
          <button
            ref={primary.ref as RefObject<HTMLButtonElement>}
            type="button"
            onPointerMove={primary.onPointerMove}
            onPointerEnter={primary.onPointerEnter}
            onPointerLeave={primary.reset}
            onBlur={primary.reset}
            style={{
              transform: `translate3d(${primary.offset.x}px, ${primary.offset.y}px, 0)`,
              transition: primary.reduce
                ? "none"
                : primary.active
                  ? "transform 120ms cubic-bezier(0.33,1,0.68,1)"
                  : "transform 480ms cubic-bezier(0.22,1,0.36,1)",
            }}
            className="group relative inline-flex items-center gap-3 overflow-hidden rounded-2xl bg-indigo-600 px-8 py-4 text-base font-semibold text-white shadow-lg shadow-indigo-600/25 outline-none transition-colors hover:bg-indigo-500 focus-visible:ring-4 focus-visible:ring-indigo-500/40 dark:bg-indigo-500 dark:shadow-indigo-500/20 dark:hover:bg-indigo-400"
          >
            <span className="pointer-events-none absolute inset-0 overflow-hidden rounded-2xl">
              <span className="hovmag-sheen absolute inset-y-0 left-0 w-1/3 bg-white/25 opacity-0 group-hover:opacity-100 group-hover:[animation:hovmag-sheen_900ms_ease-in-out]" />
            </span>
            <span
              className="pointer-events-none inline-flex"
              style={{
                transform: `translate3d(${primary.offset.x * 0.35}px, ${primary.offset.y * 0.35}px, 0)`,
                transition: primary.reduce
                  ? "none"
                  : "transform 480ms cubic-bezier(0.22,1,0.36,1)",
              }}
            >
              Start free trial
            </span>
            <svg
              viewBox="0 0 24 24"
              className="pointer-events-none h-5 w-5 transition-transform group-hover:translate-x-0.5"
              fill="none"
              stroke="currentColor"
              strokeWidth={2}
              strokeLinecap="round"
              strokeLinejoin="round"
              aria-hidden="true"
            >
              <path d="M5 12h14" />
              <path d="M13 6l6 6-6 6" />
            </svg>
          </button>

          {/* Secondary row */}
          <div className="flex flex-wrap items-center justify-center gap-6">
            <a
              ref={ghost.ref as RefObject<HTMLAnchorElement>}
              href="#hovmag-docs"
              onPointerMove={ghost.onPointerMove}
              onPointerEnter={ghost.onPointerEnter}
              onPointerLeave={ghost.reset}
              onBlur={ghost.reset}
              style={{
                transform: `translate3d(${ghost.offset.x}px, ${ghost.offset.y}px, 0)`,
                transition: ghost.reduce
                  ? "none"
                  : ghost.active
                    ? "transform 120ms cubic-bezier(0.33,1,0.68,1)"
                    : "transform 480ms cubic-bezier(0.22,1,0.36,1)",
              }}
              className="inline-flex items-center gap-2 rounded-2xl border border-slate-300 bg-white px-6 py-3.5 text-sm font-semibold text-slate-800 outline-none transition-colors hover:border-slate-400 hover:bg-slate-50 focus-visible:ring-4 focus-visible:ring-slate-400/30 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:border-slate-600 dark:hover:bg-slate-800"
            >
              Read the docs
            </a>

            {/* Magnetic icon button */}
            <button
              ref={icon.ref as RefObject<HTMLButtonElement>}
              type="button"
              aria-label="Add to favourites"
              onPointerMove={icon.onPointerMove}
              onPointerEnter={icon.onPointerEnter}
              onPointerLeave={icon.reset}
              onBlur={icon.reset}
              style={{
                transform: `translate3d(${icon.offset.x}px, ${icon.offset.y}px, 0)`,
                transition: icon.reduce
                  ? "none"
                  : icon.active
                    ? "transform 110ms cubic-bezier(0.33,1,0.68,1)"
                    : "transform 460ms cubic-bezier(0.22,1,0.36,1)",
              }}
              className="group inline-flex h-14 w-14 items-center justify-center rounded-full border border-rose-200 bg-rose-50 text-rose-600 outline-none transition-colors hover:bg-rose-100 focus-visible:ring-4 focus-visible:ring-rose-400/40 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-300 dark:hover:bg-rose-500/20"
            >
              <svg
                viewBox="0 0 24 24"
                className="h-6 w-6 transition-transform duration-300 group-hover:scale-110"
                fill="currentColor"
                aria-hidden="true"
              >
                <path d="M12 21s-6.7-4.35-9.33-8.24C.9 10.02 1.6 6.4 4.6 5.28 6.7 4.5 9 5.3 10.2 7.1c.3.44.6.44.9 0C12.3 5.3 14.6 4.5 16.7 5.28c3 1.12 3.7 4.74 1.93 7.48C18.7 16.65 12 21 12 21z" />
              </svg>
            </button>
          </div>

          {/* Magnetic pill with independent label pull */}
          <div
            ref={label.ref as RefObject<HTMLDivElement>}
            onPointerMove={label.onPointerMove}
            onPointerEnter={label.onPointerEnter}
            onPointerLeave={label.reset}
            style={{
              transform: `translate3d(${label.offset.x}px, ${label.offset.y}px, 0)`,
              transition: label.reduce
                ? "none"
                : label.active
                  ? "transform 130ms cubic-bezier(0.33,1,0.68,1)"
                  : "transform 500ms cubic-bezier(0.22,1,0.36,1)",
            }}
            className="flex items-center gap-4 rounded-full border border-emerald-200 bg-emerald-50 px-6 py-3 dark:border-emerald-500/30 dark:bg-emerald-500/10"
          >
            <span className="relative flex h-2.5 w-2.5">
              <span className="hovmag-pulse absolute inline-flex h-full w-full rounded-full bg-emerald-400" />
              <span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-emerald-500" />
            </span>
            <span
              className="text-sm font-medium text-emerald-800 dark:text-emerald-200"
              style={{
                transform: `translate3d(${label.offset.x * 0.4}px, 0, 0)`,
                transition: label.reduce
                  ? "none"
                  : "transform 500ms cubic-bezier(0.22,1,0.36,1)",
              }}
            >
              All systems operational
            </span>
          </div>
        </div>

        <p className="mt-14 text-center text-xs text-slate-500 dark:text-slate-500">
          Pull strength falls off with distance. Touch and reduced-motion users get
          a clean, static button.
        </p>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →