Each letter leans toward the cursor and springs home, like a magnetic button for every character.
Pull me
Customize
Usage
import { MagneticLetters } from "@/components/motionkit/magnetic-letters";
<MagneticLetters as="h2" text="Pull me" />Motion
The exact curves this component runs, plotted with Motion's own maths.
Lean
Pointer moves nearby
Works in the App Router as is. Requires Tailwind CSS v4.
npm install motionSave as components/motionkit/magnetic-letters.tsx.
"use client";
import { useEffect, useRef, type ElementType } from "react";
import { motion, useMotionValue, useReducedMotion, useSpring, useTransform, type MotionValue } from "motion/react";
type MagneticLettersProps = {
text: string;
/** Pixels from the pointer within which letters are pulled. */
radius?: number;
/** How far letters lean toward the pointer, 0 to 1. */
strength?: number;
/** Extra scale for a letter right under the pointer, e.g. 0.2 for 20% larger. */
lift?: number;
/** Element to render, e.g. "h2". */
as?: ElementType;
className?: string;
};
export function MagneticLetters({
text,
radius = 140,
strength = 0.35,
lift = 0.15,
as: Tag = "p",
className = "",
}: MagneticLettersProps) {
const ref = useRef<HTMLElement>(null);
const reduceMotion = useReducedMotion();
// the pointer, relative to the text box; far away means no pull
const pointerX = useMotionValue(Infinity);
const pointerY = useMotionValue(Infinity);
// listening on the parent lets the pull begin as the pointer approaches, not only once it is on a letter
useEffect(() => {
const element = ref.current;
const area = element?.parentElement;
if (!element || !area || reduceMotion) return;
function onMove(event: PointerEvent) {
const rect = element!.getBoundingClientRect();
pointerX.set(event.clientX - rect.left);
pointerY.set(event.clientY - rect.top);
}
function onLeave() {
pointerX.set(Infinity);
pointerY.set(Infinity);
}
area.addEventListener("pointermove", onMove);
area.addEventListener("pointerleave", onLeave);
return () => {
area.removeEventListener("pointermove", onMove);
area.removeEventListener("pointerleave", onLeave);
};
}, [reduceMotion, pointerX, pointerY]);
if (reduceMotion) return <Tag className={className}>{text}</Tag>;
return (
// positioned, so each letter's offsetLeft/Top is measured from this box
<Tag ref={ref} className={`relative ${className}`}>
<span className="sr-only">{text}</span>
<span aria-hidden>
{text.split(" ").map((word, w, words) => (
<span key={w}>
<span className="inline-block whitespace-nowrap">
{Array.from(word).map((char, c) => (
<Letter key={c} char={char} pointerX={pointerX} pointerY={pointerY} radius={radius} strength={strength} lift={lift} />
))}
</span>
{w < words.length - 1 && " "}
</span>
))}
</span>
</Tag>
);
}
type LetterProps = {
char: string;
pointerX: MotionValue<number>;
pointerY: MotionValue<number>;
radius: number;
strength: number;
lift: number;
};
function Letter({ char, pointerX, pointerY, radius, strength, lift }: LetterProps) {
const ref = useRef<HTMLSpanElement>(null);
// offsetLeft/Top ignore transforms, so a letter's resting centre never chases its own movement
const pull = useTransform<number, { x: number; y: number; t: number }>([pointerX, pointerY], ([px, py]) => {
const letter = ref.current;
if (!letter || !Number.isFinite(px)) return { x: 0, y: 0, t: 0 };
const dx = px - (letter.offsetLeft + letter.offsetWidth / 2);
const dy = py - (letter.offsetTop + letter.offsetHeight / 2);
const t = Math.max(0, 1 - Math.hypot(dx, dy) / radius);
return { x: dx * strength * t, y: dy * strength * t, t };
});
const spring = { stiffness: 220, damping: 18, mass: 0.5 };
const x = useSpring(useTransform(pull, (p) => p.x), spring);
const y = useSpring(useTransform(pull, (p) => p.y), spring);
const scale = useSpring(useTransform(pull, (p) => 1 + p.t * lift), spring);
return (
<motion.span ref={ref} className="inline-block" style={{ x, y, scale }}>
{char}
</motion.span>
);
}
| Prop | Type | Default | Description |
|---|---|---|---|
| text | string | — | The text. |
| radius | number | 140 | Pixels from the pointer within which letters are pulled. |
| strength | number | 0.35 | How far letters lean, 0 to 1. |
| lift | number | 0.15 | Extra scale under the pointer. |
| as | ElementType | "p" | Element to render. |
Magnetic Button
A button that leans toward the cursor with a springy, physical pull.
Text Scramble
Text that decodes itself character by character, terminal style.
Come closer
Variable Proximity
Letters near the cursor swell to a heavier weight and ease back as it moves on, using a variable font.