"use client";
import * as React from "react";
export type ProximityFalloff = "linear" | "smooth" | "gaussian";
export interface ProximityTextProps extends Omit<React.HTMLAttributes<HTMLElement>, "children"> {
/** The text to render. */
text?: string;
/** Element to render. */
as?: "span" | "p" | "h1" | "h2" | "h3" | "div";
/** Distance in px at which letters stop responding. */
radius?: number;
/** Weight of letters outside the radius (`wght` axis). */
minWeight?: number;
/** Weight of the letter directly under the pointer. */
maxWeight?: number;
/** Shape of the response curve between the pointer and the radius. */
falloff?: ProximityFalloff;
/** Scale of the letter under the pointer (1 = no scaling). */
scale?: number;
/** `wdth` axis value under the pointer (100 = normal; needs a font with a width axis). */
stretch?: number;
/** Skew of the letter under the pointer, in degrees (0 = upright). */
slant?: number;
/** Blend nearby letters towards `accent`. */
colorize?: boolean;
/** Colour used when `colorize` is on (any CSS colour). */
accent?: string;
/** On touch / no-hover devices: a slow idle wave, or static text. */
touchBehavior?: "wave" | "static";
}
/* ------------------------------ media queries ------------------------------ */
function subscribeMedia(query: string) {
return (onChange: () => void) => {
const mq = window.matchMedia(query);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
};
}
const REDUCED_QUERY = "(prefers-reduced-motion: reduce)";
const NO_HOVER_QUERY = "(hover: none)";
const subscribeReduced = subscribeMedia(REDUCED_QUERY);
const subscribeNoHover = subscribeMedia(NO_HOVER_QUERY);
function useMediaQuery(query: string, subscribe: (cb: () => void) => () => void) {
return React.useSyncExternalStore(
subscribe,
() => window.matchMedia(query).matches,
() => false,
);
}
/* --------------------------------- helpers --------------------------------- */
const FALLOFF: Record<ProximityFalloff, (d: number) => number> = {
linear: (d) => 1 - d,
smooth: (d) => 1 - d * d * (3 - 2 * d),
gaussian: (d) => Math.exp(-d * d * 4.5),
};
function letterStyle(
t: number,
{ minWeight, maxWeight, stretch, scale, slant }: { minWeight: number; maxWeight: number; stretch: number; scale: number; slant: number },
) {
const weight = minWeight + (maxWeight - minWeight) * t;
const axes = [`"wght" ${weight.toFixed(1)}`];
if (stretch !== 100) axes.push(`"wdth" ${(100 + (stretch - 100) * t).toFixed(1)}`);
const s = 1 + (scale - 1) * t;
const k = slant * t;
return {
fontVariationSettings: axes.join(", "),
transform: s !== 1 || k !== 0 ? `scale(${s.toFixed(3)}) skewX(${(-k).toFixed(2)}deg)` : "",
};
}
/**
* Letters that swell towards the pointer. Each frame reads every letter's position, computes a
* falloff from the pointer and writes `font-variation-settings` (plus optional transform / colour)
* straight onto the letter — no React render per frame.
*/
export function ProximityText({
text = "Type that leans in",
as = "span",
radius = 140,
minWeight = 300,
maxWeight = 800,
falloff = "smooth",
scale = 1,
stretch = 100,
slant = 0,
colorize = false,
accent = "#FF7448",
touchBehavior = "wave",
className = "",
style,
...props
}: ProximityTextProps) {
const reduceMotion = useMediaQuery(REDUCED_QUERY, subscribeReduced);
const noHover = useMediaQuery(NO_HOVER_QUERY, subscribeNoHover);
const rootRef = React.useRef<HTMLElement>(null);
const visualRef = React.useRef<HTMLSpanElement>(null);
const Tag = as as React.ElementType;
const mode: "pointer" | "wave" | "static" = reduceMotion
? "static"
: noHover
? touchBehavior === "wave"
? "wave"
: "static"
: "pointer";
React.useEffect(() => {
const root = rootRef.current;
const visual = visualRef.current;
if (!root || !visual) return;
const letters = Array.from(visual.querySelectorAll<HTMLElement>("[data-pt-letter]"));
const options = { minWeight, maxWeight, stretch, scale, slant };
const curve = FALLOFF[falloff] ?? FALLOFF.smooth;
const current = new Float32Array(letters.length);
const write = (i: number, t: number) => {
const el = letters[i];
const { fontVariationSettings, transform } = letterStyle(t, options);
el.style.fontVariationSettings = fontVariationSettings;
el.style.transform = transform;
el.style.color = colorize && t > 0.01 ? `color-mix(in oklab, ${accent} ${(t * 100).toFixed(1)}%, currentColor)` : "";
};
// Rest state (also the whole effect for reduced motion / static touch).
letters.forEach((_, i) => write(i, 0));
if (mode === "static" || !letters.length) return;
let frame = 0;
let inView = false;
let pointer: { x: number; y: number } | null = null;
const startTime = performance.now();
const tick = (now: number) => {
frame = 0;
// Batch every read before any write so each frame costs a single layout.
const centres = letters.map((el) => {
const r = el.getBoundingClientRect();
return [r.left + r.width / 2, r.top + r.height / 2] as const;
});
let moving = false;
for (let i = 0; i < letters.length; i++) {
let target = 0;
if (mode === "wave") {
// A slow travelling wave across the line for devices without hover.
const phase = (now - startTime) / 1400 - i * 0.35;
target = Math.pow((Math.sin(phase) + 1) / 2, 3) * 0.7;
} else if (pointer) {
const d = Math.hypot(centres[i][0] - pointer.x, centres[i][1] - pointer.y) / Math.max(1, radius);
target = d >= 1 ? 0 : Math.max(0, Math.min(1, curve(d)));
}
// Ease towards the target so letters settle rather than snap.
const next = mode === "wave" ? target : current[i] + (target - current[i]) * 0.22;
if (Math.abs(next - current[i]) > 0.002) {
moving = true;
current[i] = next;
write(i, next);
} else if (current[i] !== target) {
current[i] = target;
write(i, target);
}
}
if (moving || mode === "wave") request();
};
const request = () => {
if (!frame && inView && !document.hidden) frame = requestAnimationFrame(tick);
};
const stop = () => {
cancelAnimationFrame(frame);
frame = 0;
};
const onPointerMove = (event: PointerEvent) => {
if (event.pointerType !== "mouse" && event.pointerType !== "pen") return;
if (!inView) return;
// Cheap bounds check first: ignore the pointer while it is far from the text and letters rest.
const box = root.getBoundingClientRect();
const near =
event.clientX > box.left - radius &&
event.clientX < box.right + radius &&
event.clientY > box.top - radius &&
event.clientY < box.bottom + radius;
if (!near) {
if (pointer) {
pointer = null;
request();
}
return;
}
pointer = { x: event.clientX, y: event.clientY };
request();
};
const onPointerOut = (event: PointerEvent) => {
// Leaving the window: relax every letter.
if (event.relatedTarget === null) {
pointer = null;
request();
}
};
const observer = new IntersectionObserver(
(entries) => {
inView = entries.some((entry) => entry.isIntersecting);
if (inView) request();
else stop();
},
{ rootMargin: `${radius}px` },
);
observer.observe(root);
const onVisibility = () => (document.hidden ? stop() : request());
document.addEventListener("visibilitychange", onVisibility);
if (mode === "pointer") {
window.addEventListener("pointermove", onPointerMove, { passive: true });
document.addEventListener("pointerout", onPointerOut);
}
return () => {
stop();
observer.disconnect();
document.removeEventListener("visibilitychange", onVisibility);
window.removeEventListener("pointermove", onPointerMove);
document.removeEventListener("pointerout", onPointerOut);
};
}, [text, mode, radius, minWeight, maxWeight, falloff, scale, stretch, slant, colorize, accent]);
// Keep whitespace as real text so lines can still wrap between words.
const tokens = text.split(/(\s+)/).filter(Boolean);
const rest = letterStyle(0, { minWeight, maxWeight, stretch, scale, slant });
return (
<Tag ref={rootRef} className={className} style={style} {...props}>
<span className="sr-only">{text}</span>
<span ref={visualRef} aria-hidden="true">
{tokens.map((token, t) =>
/^\s+$/.test(token) ? (
" "
) : (
<span key={t} className="inline-block whitespace-nowrap">
{Array.from(token).map((char, c) => (
<span
key={c}
data-pt-letter=""
className="inline-block origin-[50%_70%]"
style={{ fontVariationSettings: rest.fontVariationSettings }}
>
{char}
</span>
))}
</span>
),
)}
</span>
</Tag>
);
}