Build a Variable Font Proximity Effect in React
Change variable font weight based on cursor distance from each character, creating a fluid typographic interaction effect.
Variable fonts unlocked something that was never really possible before: continuous, smooth changes to type weight in the browser. Variable Proximity turns that capability into an interaction. Your cursor becomes the force that shapes the letterforms. Move close and they grow heavy; step back and they thin out.
What we are building
VariableProximity maps each character's font variation settings to the cursor's proximity. You define a from and to font variation state, and the component interpolates between them based on distance. It supports linear, exponential, and gaussian falloff curves.
Setting up
This component uses Motion for the letter spans and a custom animation frame loop for the cursor tracking.
import { forwardRef, useMemo, useRef, useEffect } from 'react';
import { motion } from 'motion/react';Building the component
Two helper hooks do the cursor tracking work. useMousePositionRef stores the cursor position relative to the container in a ref (not state, to avoid re-renders on every mouse move).
function useMousePositionRef(containerRef: MutableRefObject<HTMLElement | null>) {
const positionRef = useRef({ x: 0, y: 0 });
useEffect(() => {
const updatePosition = (x: number, y: number) => {
if (containerRef?.current) {
const rect = containerRef.current.getBoundingClientRect();
positionRef.current = { x: x - rect.left, y: y - rect.top };
}
};
const handleMouseMove = (ev: MouseEvent) => updatePosition(ev.clientX, ev.clientY);
window.addEventListener('mousemove', handleMouseMove);
return () => window.removeEventListener('mousemove', handleMouseMove);
}, [containerRef]);
return positionRef;
}useAnimationFrame runs a callback every frame, which reads the current mouse position and updates each letter's fontVariationSettings.
The parsed settings are computed once from the from and to strings and stored in a useMemo. This avoids re-parsing on every animation frame.
const parsedSettings = useMemo(() => {
const parseSettings = (settingsStr: string) =>
new Map(
settingsStr.split(',').map(s => s.trim()).map(s => {
const [name, value] = s.split(' ');
return [name.replace(/['"]/g, ''), parseFloat(value)];
})
);
const fromSettings = parseSettings(fromFontVariationSettings);
const toSettings = parseSettings(toFontVariationSettings);
return Array.from(fromSettings.entries()).map(([axis, fromValue]) => ({
axis, fromValue, toValue: toSettings.get(axis) ?? fromValue,
}));
}, [fromFontVariationSettings, toFontVariationSettings]);The falloff function converts a distance to a 0-1 interpolation factor. Gaussian falloff feels the most natural, while linear is the most predictable.
const calculateFalloff = (distance: number) => {
const norm = Math.min(Math.max(1 - distance / radius, 0), 1);
switch (falloff) {
case 'exponential': return norm ** 2;
case 'gaussian': return Math.exp(-((distance / (radius / 2)) ** 2) / 2);
default: return norm;
}
};Each animation frame, we check whether the cursor has actually moved. If not, we skip the update entirely to avoid unnecessary style recalculations.
useAnimationFrame(() => {
const { x, y } = mousePositionRef.current;
if (lastPositionRef.current.x === x && lastPositionRef.current.y === y) return;
lastPositionRef.current = { x, y };
letterRefs.current.forEach((letterRef, index) => {
if (!letterRef) return;
const rect = letterRef.getBoundingClientRect();
const letterCenterX = rect.left + rect.width / 2 - containerRect.left;
const letterCenterY = rect.top + rect.height / 2 - containerRect.top;
const distance = calculateDistance(x, y, letterCenterX, letterCenterY);
if (distance >= radius) {
letterRef.style.fontVariationSettings = fromFontVariationSettings;
return;
}
const falloffValue = calculateFalloff(distance);
const newSettings = parsedSettings.map(({ axis, fromValue, toValue }) => {
const interpolatedValue = fromValue + (toValue - fromValue) * falloffValue;
return `'${axis}' ${interpolatedValue}`;
}).join(', ');
letterRef.style.fontVariationSettings = newSettings;
});
});How to use it
<VariableProximity
label="Hover over me"
fromFontVariationSettings="'wght' 100, 'wdth' 75"
toFontVariationSettings="'wght' 900, 'wdth' 125"
containerRef={containerRef}
radius={150}
falloff="gaussian"
/>The containerRef must point to the scrollable container or viewport element, so mouse coordinates are calculated correctly.
Key takeaways
- Storing cursor position in a ref instead of state means zero re-renders on mouse move. All the work happens in the animation frame callback, directly mutating DOM styles.
- Skipping the update when position has not changed is a simple optimization that prevents redundant style recalculations on every frame even when the cursor is still.
- The
fromandtofont variation settings accept any valid CSSfont-variation-settingsstring, so this component works with any variable font axis, not just weight.