Navigation
MagneticMenu
Links lean toward the pointer, the label leaning a little further, and a soft blob glides to whichever link you hover or focus.
Download
- React
- Tailwind CSS v4
- motion
components/ui/magnetic-menu.tsx
"use client";
import { useId, useState, type MouseEvent } from "react";
import { motion, useMotionValue, useReducedMotion, useSpring, useTransform } from "motion/react";
export type MagneticMenuItem = { label: string; href: string };
type MagneticMenuProps = {
items: MagneticMenuItem[];
/** href of the active link at start. Defaults to the first item. */
active?: string;
/** Called on click. Call e.preventDefault() to stop the browser following the link. */
onSelect?: (item: MagneticMenuItem, e: MouseEvent<HTMLAnchorElement>) => void;
orientation?: "horizontal" | "vertical";
/** How far a link follows the pointer, 0 to 1. */
strength?: number;
/** Colour of the hover blob. */
color?: string;
/** Colour of the dot under the active link. */
accent?: string;
/** Name read out by screen readers. */
label?: string;
className?: string;
};
const SPRING = { stiffness: 260, damping: 18, mass: 0.4 };
/* MagneticMenu: links lean toward the pointer, and a soft blob glides to whichever link is under it. */
export function MagneticMenu({
items,
active,
onSelect,
orientation = "horizontal",
strength = 0.3,
color = "rgba(167,139,250,0.18)",
accent = "#a78bfa",
label = "Menu",
className = "text-sm text-white/70",
}: MagneticMenuProps) {
const id = useId();
const [hover, setHover] = useState<string | null>(null);
const [current, setCurrent] = useState(active ?? items[0]?.href);
return (
<nav
aria-label={label}
className={className}
onPointerLeave={() => setHover(null)}
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setHover(null);
}}
>
<ul className={`flex gap-1 ${orientation === "vertical" ? "flex-col items-start" : "flex-wrap items-center justify-center"}`}>
{items.map((item) => (
<MagnetLink
key={item.href}
item={item}
on={item.href === current}
hovered={item.href === hover}
blobId={`${id}-blob`}
strength={strength}
color={color}
accent={accent}
onHover={() => setHover(item.href)}
onPick={(e) => {
setCurrent(item.href);
onSelect?.(item, e);
}}
/>
))}
</ul>
</nav>
);
}
function MagnetLink({
item,
on,
hovered,
blobId,
strength,
color,
accent,
onHover,
onPick,
}: {
item: MagneticMenuItem;
on: boolean;
hovered: boolean;
blobId: string;
strength: number;
color: string;
accent: string;
onHover: () => void;
onPick: (e: MouseEvent<HTMLAnchorElement>) => void;
}) {
const reduce = useReducedMotion();
const x = useMotionValue(0);
const y = useMotionValue(0);
const sx = useSpring(x, SPRING);
const sy = useSpring(y, SPRING);
// The label moves a little further than its pill, so it feels pulled.
const lx = useTransform(sx, (v) => v * 0.5);
const ly = useTransform(sy, (v) => v * 0.5);
return (
<li className="relative">
<motion.a
href={item.href}
aria-current={on ? "page" : undefined}
onClick={onPick}
onFocus={onHover}
onPointerEnter={onHover}
onPointerMove={(e) => {
if (reduce) return;
const r = e.currentTarget.getBoundingClientRect();
x.set((e.clientX - (r.left + r.width / 2)) * strength);
y.set((e.clientY - (r.top + r.height / 2)) * strength);
}}
onPointerLeave={() => {
x.set(0);
y.set(0);
}}
style={{ x: sx, y: sy }}
className={`relative block whitespace-nowrap rounded-full px-4 py-2 outline-none transition-colors focus-visible:ring-2 focus-visible:ring-white/70 ${
on || hovered ? "text-white" : ""
}`}
>
{hovered && (
<motion.span
layoutId={blobId}
aria-hidden
className="absolute inset-0 rounded-full"
style={{ background: color }}
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 380, damping: 30 }}
/>
)}
<motion.span className="relative block" style={{ x: lx, y: ly }}>
{item.label}
</motion.span>
{on && (
<span
aria-hidden
className="absolute bottom-0.5 left-1/2 size-1 -translate-x-1/2 rounded-full"
style={{ background: accent }}
/>
)}
</motion.a>
</li>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/magnetic-menu.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/magnetic-menu-demo.tsx
"use client"; import { MagneticMenu } from "@/components/ui/magnetic-menu"; const ITEMS = [ { label: "Work", href: "#work" }, { label: "Studio", href: "#studio" }, { label: "Journal", href: "#journal" }, { label: "Contact", href: "#contact" }, ]; export default function MagneticMenuExample() { return ( <div className="grid h-full place-items-center bg-[#0b0a14] p-6"> <MagneticMenu items={ITEMS} onSelect={(_, e) => e.preventDefault()} /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| items | { label, href }[] | — | The links. |
| active | string | first href | href of the active link (dot under it). |
| onSelect | (item, e) => void | — | Click handler. Call e.preventDefault() to stay on the page. |
| orientation | "horizontal" | "vertical" | "horizontal" | Row or column. |
| strength | number | 0.3 | How far a link follows the pointer, 0 to 1. |
| color | string | "rgba(167,139,250,0.18)" | Hover blob colour. |
| accent | string | "#a78bfa" | Active dot colour. |
| label | string | "Menu" | Name read out by screen readers. |
| className | string | "text-sm text-white/70" | Text size and colour. |