Letters near the cursor swell to a heavier weight and ease back as it moves on, using a variable font.
Come closer
Customize
Usage
import { VariableProximity } from "@/components/motionkit/variable-proximity";
// needs a variable font, such as Geist or Inter
<VariableProximity
as="h1"
text="Come closer"
/>Motion
The exact curves this component runs, plotted with Motion's own maths.
Weight swell
Pointer moves nearby
Works in the App Router as is. Requires Tailwind CSS v4.
npm install motionSave as components/motionkit/variable-proximity.tsx.
"use client";
import { useEffect, useRef, type ElementType } from "react";
import { useReducedMotion } from "motion/react";
type VariableProximityProps = {
text: string;
/** Pixels from the pointer within which letters respond. */
radius?: number;
/** Weight at rest. Needs a variable font, such as Geist or Inter. */
fromWeight?: number;
/** Weight right under the pointer. */
toWeight?: number;
/** "smooth" eases off gently; "linear" falls away evenly. */
falloff?: "smooth" | "linear";
/** Element to render, e.g. "h1". */
as?: ElementType;
className?: string;
};
export function VariableProximity({
text,
radius = 120,
fromWeight = 300,
toWeight = 900,
falloff = "smooth",
as: Tag = "p",
className = "",
}: VariableProximityProps) {
const ref = useRef<HTMLElement>(null);
const reduceMotion = useReducedMotion();
useEffect(() => {
const element = ref.current;
if (!element || reduceMotion) return;
const letters = [...element.querySelectorAll<HTMLSpanElement>("[data-letter]")];
const weights = letters.map(() => fromWeight);
let pointer: { x: number; y: number } | null = null;
let frame = 0;
// each frame, every letter eases a fraction of the way to its target weight, so the
// swell follows the pointer smoothly and settles back when it leaves; the loop stops once still
function tick() {
let moving = false;
letters.forEach((letter, i) => {
let target = fromWeight;
if (pointer) {
const rect = letter.getBoundingClientRect();
const distance = Math.hypot(pointer.x - (rect.left + rect.width / 2), pointer.y - (rect.top + rect.height / 2));
const t = Math.max(0, 1 - distance / radius);
const eased = falloff === "smooth" ? t * t * (3 - 2 * t) : t;
target = fromWeight + (toWeight - fromWeight) * eased;
}
weights[i] += (target - weights[i]) * 0.25;
if (Math.abs(target - weights[i]) > 0.5) moving = true;
else weights[i] = target;
letter.style.fontVariationSettings = `"wght" ${weights[i].toFixed(1)}`;
});
frame = moving || pointer ? requestAnimationFrame(tick) : 0;
}
function onMove(event: PointerEvent) {
pointer = { x: event.clientX, y: event.clientY };
if (!frame) frame = requestAnimationFrame(tick);
}
function onLeave() {
pointer = null;
if (!frame) frame = requestAnimationFrame(tick);
}
// listen on the parent too, so the swell starts as the pointer approaches, not only once it is on a letter
const area = element.parentElement ?? element;
area.addEventListener("pointermove", onMove);
area.addEventListener("pointerleave", onLeave);
return () => {
cancelAnimationFrame(frame);
area.removeEventListener("pointermove", onMove);
area.removeEventListener("pointerleave", onLeave);
letters.forEach((letter) => (letter.style.fontVariationSettings = ""));
};
}, [radius, fromWeight, toWeight, falloff, reduceMotion, text]);
return (
<Tag ref={ref} className={className} style={{ fontVariationSettings: `"wght" ${fromWeight}` }}>
{/* screen readers read the text once, not letter by letter */}
<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) => (
<span key={c} data-letter className="inline-block" style={{ fontVariationSettings: `"wght" ${fromWeight}` }}>
{char}
</span>
))}
</span>
{w < words.length - 1 && " "}
</span>
))}
</span>
</Tag>
);
}
| Prop | Type | Default | Description |
|---|---|---|---|
| text | string | — | The text. |
| radius | number | 120 | Pixels from the pointer within which letters respond. |
| fromWeight | number | 300 | Weight at rest. |
| toWeight | number | 900 | Weight under the pointer. |
| falloff | "smooth" | "linear" | "smooth" | How the swell fades with distance. |
| as | ElementType | "p" | Element to render. Needs a variable font, such as Geist or Inter. |
Text Scramble
Text that decodes itself character by character, terminal style.
Pull me
Magnetic Letters
Each letter leans toward the cursor and springs home, like a magnetic button for every character.
Shuffle Text
On hover or focus, the letters swap places a few times and slide home. Made for links and buttons.