React text where characters are pulled toward, or pushed away from, the pointer with a smooth falloff. Fully customizable.
<MagnetText
as="h2"
text="Awesome Text"
className="text-7xl font-semibold tracking-tight"
/>Install the following dependencies:
npm install gsap @gsap/reactbun add gsap @gsap/reactdeno add gsap @gsap/reactpnpm add gsap @gsap/reactyarn add gsap @gsap/reactCopy and paste the following code into your project.
"use client";
import { useGSAP } from "@gsap/react";
import { gsap } from "gsap";
import { CustomEase } from "gsap/CustomEase";
import {
type ComponentPropsWithoutRef,
type ElementType,
useMemo,
useRef,
} from "react";
import { cn } from "@/lib/utils";
gsap.registerPlugin(useGSAP, CustomEase);
const LENS_EASE = "magnetLens";
CustomEase.create(LENS_EASE, "0.23, 1, 0.32, 1");
const FINE_POINTER_QUERY = "(hover: hover) and (pointer: fine)";
const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
const WHITESPACE_REGEX = /\s+/;
const AFTER_HYPHEN_REGEX = /(?<=-)/;
interface MagnetTextOptions {
/** Follow tween duration, in seconds. */
duration?: number;
/** Follow tween ease. */
ease?: string;
/** Characters move towards the pointer or away from it. */
mode?: "attract" | "repel";
/** Influence radius around the pointer, in px. */
radius?: number;
/** Duration of the return to rest when the pointer leaves. */
returnDuration?: number;
/** Ease of the return to rest when the pointer leaves. */
returnEase?: string;
/** Max rotation in degrees at full influence (0 = disabled). */
rotate?: number;
/** Extra scale applied at full influence (0 = disabled). */
scale?: number;
/** Max displacement as a fraction of the font size (em). */
strength?: number;
/** Extra font-variation-settings axes kept alongside `wght`, e.g. `"opsz" 96, "wdth" 100`. */
variationSettings?: string;
/**
* Variable-font weight range [rest, full influence], e.g. [500, 690].
* Requires a font with a `wght` axis. Layout stays stable while the weight changes.
*/
weight?: [number, number];
}
type MagnetTextProps<T extends ElementType> = MagnetTextOptions & {
as?: T;
text: string;
wordClassName?: string;
charClassName?: string;
} & Omit<ComponentPropsWithoutRef<T>, "as" | "children">;
// "scale" is an alias of scaleX + scaleY and can't be driven by quickTo.
const SETTER_PROPS = ["x", "y", "scaleX", "scaleY", "rotation"] as const;
type CharSetters = Record<
(typeof SETTER_PROPS)[number] | "k",
gsap.QuickToFunc
>;
/** Per-char influence (0..1), exposed as the `--magnet-k` CSS variable. */
const INFLUENCE_VAR = "--magnet-k";
const INFLUENCE_DECIMALS = 3;
interface Point {
x: number;
y: number;
}
function splitText(text: string) {
return text
.split(WHITESPACE_REGEX)
.filter(Boolean)
.map((word, wordIndex) => ({
id: `w${wordIndex}`,
segments: word.split(AFTER_HYPHEN_REGEX).map((segment, segIndex) => ({
chars: Array.from(segment).map((char, charIndex) => ({
char,
id: `w${wordIndex}s${segIndex}c${charIndex}`,
})),
id: `w${wordIndex}s${segIndex}`,
})),
}));
}
function smoothstep(t: number) {
return t * t * (3 - 2 * t);
}
function MagnetText<T extends ElementType = "span">({
as,
text,
className,
wordClassName,
charClassName,
radius = 160,
strength = 0.16,
mode = "attract",
scale = 0,
rotate = 0,
duration = 0.35,
ease = LENS_EASE,
returnDuration = 0.8,
returnEase = "elastic.out(1, 0.4)",
weight,
variationSettings,
...props
}: MagnetTextProps<T>) {
const containerRef = useRef<HTMLElement>(null);
const liveOptions = useRef({ mode, radius, rotate, scale, strength });
liveOptions.current = { mode, radius, rotate, scale, strength };
const words = useMemo(() => splitText(text), [text]);
const Comp = (as ?? "span") as ElementType;
const weightStyles = useMemo(() => {
if (!weight) {
return null;
}
const [from, to] = weight;
const join = (wght: string) =>
[variationSettings, `"wght" ${wght}`].filter(Boolean).join(", ");
return {
animated: {
fontVariationSettings: join(
`calc(${from} + var(${INFLUENCE_VAR}, 0) * ${to - from})`
),
},
rest: { fontVariationSettings: join(String(from)) },
};
}, [weight, variationSettings]);
useGSAP(
() => {
const container = containerRef.current;
if (!container) {
return;
}
const canHover = window.matchMedia(FINE_POINTER_QUERY).matches;
const reducedMotion = window.matchMedia(REDUCED_MOTION_QUERY).matches;
if (!canHover || reducedMotion) {
return;
}
const chars = Array.from(
container.querySelectorAll<HTMLElement>("[data-magnet-char]")
);
// Influence is tweened on proxies and written to a CSS var, so any style (weight, color…) can use it.
const influences = chars.map(() => ({ k: 0 }));
const writeInfluence = (i: number) => {
const k = Math.max(0, influences[i].k);
chars[i].style.setProperty(
INFLUENCE_VAR,
k.toFixed(INFLUENCE_DECIMALS)
);
};
const writeAllInfluences = () => {
for (const i of chars.keys()) {
writeInfluence(i);
}
};
const createSetters = (): CharSetters[] =>
chars.map((el, i) => ({
...(Object.fromEntries(
SETTER_PROPS.map((prop) => [
prop,
gsap.quickTo(el, prop, { duration, ease }),
])
) as Omit<CharSetters, "k">),
k: gsap.quickTo(influences[i], "k", {
duration,
ease,
onUpdate: () => writeInfluence(i),
}),
}));
const killSetters = (list: CharSetters[]) => {
for (const setter of list) {
for (const quick of Object.values(setter)) {
quick.tween.kill();
}
}
};
// Setters are rebuilt on every enter: the return tween would otherwise fight with them.
let setters: CharSetters[] = [];
let centers: Point[] = [];
let fontSize = 16;
let pointer: Point = { x: 0, y: 0 };
let frame = 0;
let isInside = false;
let returnTweens: gsap.core.Tween[] = [];
const killReturnTweens = () => {
for (const tween of returnTweens) {
tween.kill();
}
returnTweens = [];
};
// Offset-based layout ignores transforms, so centers stay correct while chars are displaced.
const measure = () => {
fontSize = Number.parseFloat(getComputedStyle(container).fontSize);
centers = chars.map((el) => {
let x = el.offsetWidth / 2;
let y = el.offsetHeight / 2;
let node: HTMLElement | null = el;
while (node && node !== container) {
x += node.offsetLeft;
y += node.offsetTop;
node = node.offsetParent as HTMLElement | null;
}
return { x, y };
});
};
const update = () => {
frame = 0;
if (!isInside) {
return;
}
const opts = liveOptions.current;
const force =
fontSize * opts.strength * (opts.mode === "repel" ? -1 : 1);
const rect = container.getBoundingClientRect();
for (const [i, setter] of setters.entries()) {
const dx = pointer.x - (rect.left + centers[i].x);
const dy = pointer.y - (rect.top + centers[i].y);
const dist = Math.hypot(dx, dy) || 1;
const k = smoothstep(Math.max(0, 1 - dist / opts.radius));
setter.x((dx / dist) * k * force);
setter.y((dy / dist) * k * force);
setter.scaleX(1 + k * opts.scale);
setter.scaleY(1 + k * opts.scale);
setter.rotation((dx / dist) * k * opts.rotate);
setter.k(k);
}
};
const schedule = () => {
if (!frame) {
frame = requestAnimationFrame(update);
}
};
const onEnter = () => {
if (isInside) {
return;
}
isInside = true;
killReturnTweens();
setters = createSetters();
measure();
};
const onMove = (e: PointerEvent) => {
onEnter();
pointer = { x: e.clientX, y: e.clientY };
schedule();
};
const onLeave = () => {
isInside = false;
cancelAnimationFrame(frame);
frame = 0;
killSetters(setters);
setters = [];
returnTweens = [
gsap.to(chars, {
duration: returnDuration,
ease: returnEase,
rotation: 0,
scaleX: 1,
scaleY: 1,
x: 0,
y: 0,
}),
gsap.to(influences, {
duration: returnDuration,
ease: returnEase,
k: 0,
onUpdate: writeAllInfluences,
}),
];
};
measure();
document.fonts?.ready.then(measure);
const resizeObserver = new ResizeObserver(measure);
resizeObserver.observe(container);
container.addEventListener("pointerenter", onEnter);
container.addEventListener("pointermove", onMove);
container.addEventListener("pointerleave", onLeave);
return () => {
cancelAnimationFrame(frame);
killSetters(setters);
killReturnTweens();
resizeObserver.disconnect();
container.removeEventListener("pointerenter", onEnter);
container.removeEventListener("pointermove", onMove);
container.removeEventListener("pointerleave", onLeave);
};
},
{
dependencies: [words, duration, ease, returnDuration, returnEase],
revertOnUpdate: true,
scope: containerRef,
}
);
return (
<Comp className={cn("relative", className)} ref={containerRef} {...props}>
<span className="sr-only">{text}</span>
{words.map((word, wordIndex) => (
<span aria-hidden="true" className={wordClassName} key={word.id}>
{word.segments.map((segment) => (
<span className="inline-block whitespace-nowrap" key={segment.id}>
{segment.chars.map(({ char, id }) => (
<span
className={cn(
"relative inline-block will-change-transform",
charClassName
)}
data-magnet-char
key={id}
>
{weightStyles ? (
<>
{/* Invisible rest-weight glyph keeps the width fixed, so neighbours don't shift. */}
<span className="invisible" style={weightStyles.rest}>
{char}
</span>
<span
className="absolute top-0 left-1/2 -translate-x-1/2"
style={weightStyles.animated}
>
{char}
</span>
</>
) : (
char
)}
</span>
))}
</span>
))}
{wordIndex < words.length - 1 ? " " : ""}
</span>
))}
</Comp>
);
}
export type { MagnetTextOptions, MagnetTextProps };
export { MagnetText };import { MagnetText } from "@/components/magnet-text";<MagnetText as="h1" className="text-7xl font-semibold" text="Awesome Text" /><MagnetText mode="repel" radius={220} strength={0.3} text="Keep your distance" /><MagnetText rotate={12} scale={0.25} text="Feel the pull" />The weight grows with the pull. Requires a variable font with a wght axis (e.g. Bricolage Grotesque, Inter, Roboto Flex).
<MagnetText
className="font-serif text-7xl"
text="Awesome Text"
variationSettings='"opsz" 96, "wdth" 100'
weight={[500, 690]}
/>Every character exposes its animated influence (0 at rest, 1 under the pointer) as the --magnet-k CSS variable, so you can drive any style with it:
<MagnetText
charClassName="[opacity:calc(0.35+var(--magnet-k,0)*0.65)]"
text="Light follows the pointer"
/>prefers-reduced-motion is set: the text stays static.aria-hidden, so screen readers read it only once.magnetLens ease, so you can reuse it anywhere: returnEase="magnetLens" gives a soft return without the elastic bounce.weight, each character renders an invisible rest-weight copy that holds its width, so heavier glyphs never push their neighbours.font-variation-settings replaces all axes: pass the other axes you need (opsz, wdth…) through variationSettings.It accepts all the props of the HTML element it renders as, plus the following:
| Prop | Type | Default | Description |
|---|---|---|---|
text | string | - | The text to display. |
as | React.ElementType | "span" | The HTML element to render as. |
radius | number | 160 | Influence radius around the pointer, in px. |
strength | number | 0.16 | Max displacement as a fraction of the font size (em). |
mode | "attract" | "repel" | "attract" | Whether characters move towards or away from the pointer. |
scale | number | 0 | Extra scale at full influence (0.2 = 120%). |
rotate | number | 0 | Max rotation in degrees at full influence. |
duration | number | 0.35 | Duration of the follow tween, in seconds. |
ease | string | "magnetLens" | Ease of the follow tween. magnetLens is registered by the component via CustomEase (cubic-bezier(0.23, 1, 0.32, 1)). |
returnDuration | number | 0.8 | Duration of the return to rest when the pointer leaves. |
returnEase | string | "elastic.out(1, 0.4)" | Ease of the return to rest. |
weight | [number, number] | - | Variable-font wght range: [rest, full influence]. |
variationSettings | string | - | Extra font-variation-settings axes kept alongside wght. |
wordClassName | string | - | Classes applied to each word. |
charClassName | string | - | Classes applied to each character. |