Create a Text Pressure Effect in React
Make text respond to cursor proximity by adjusting variable font weight and scale, creating an interactive pressure simulation.
What if text felt physical? Like it had weight, and you could press on it. The text pressure effect turns cursor position into a force that distorts letterforms, making your UI feel tactile in a way that flat design rarely achieves.
The final result
TextPressure tracks mouse position relative to each character and adjusts variable font axes (weight, width, italic) based on proximity. Characters closest to the cursor become heavier and wider; move away and they snap back to their resting state.
Setting up
No animation library is needed here. This is all React state, requestAnimationFrame, and the browser's variable font API.
import { useEffect, useRef, useState, useMemo, useCallback } from 'react';The component also injects a @font-face rule so the variable font loads automatically.
Building the component
Two utility functions do the math. dist calculates Euclidean distance between two points, and getAttr maps a distance value to a range using a linear falloff.
const dist = (a: { x: number; y: number }, b: { x: number; y: number }) => {
const dx = b.x - a.x;
const dy = b.y - a.y;
return Math.sqrt(dx * dx + dy * dy);
};
const getAttr = (distance: number, maxDist: number, minVal: number, maxVal: number) => {
const val = maxVal - Math.abs((maxVal * distance) / maxDist);
return Math.max(minVal, val + minVal);
};We track two separate cursor positions: the actual position (cursorRef) and a smoothed position (mouseRef) that lerps toward the cursor each frame. This creates a subtle lag that makes the effect feel physical.
useEffect(() => {
let rafId: number;
const animate = () => {
mouseRef.current.x += (cursorRef.current.x - mouseRef.current.x) / 15;
mouseRef.current.y += (cursorRef.current.y - mouseRef.current.y) / 15;
spansRef.current.forEach(span => {
if (!span) return;
const rect = span.getBoundingClientRect();
const charCenter = { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
const d = dist(mouseRef.current, charCenter);
const maxDist = titleRef.current!.getBoundingClientRect().width / 2;
const wdth = width ? Math.floor(getAttr(d, maxDist, 5, 200)) : 100;
const wght = weight ? Math.floor(getAttr(d, maxDist, 100, 900)) : 400;
const italVal = italic ? getAttr(d, maxDist, 0, 1).toFixed(2) : '0';
span.style.fontVariationSettings = `'wght' ${wght}, 'wdth' ${wdth}, 'ital' ${italVal}`;
});
rafId = requestAnimationFrame(animate);
};
animate();
return () => cancelAnimationFrame(rafId);
}, [width, weight, italic, alpha]);The font size is calculated to fill the container width, and rescales on window resize using a debounced handler.
let newFontSize = containerW / (chars.length / 2);
newFontSize = Math.max(newFontSize, minFontSize);Each character gets its own span with a ref stored in an array, so the animation loop can read each character's bounding rect every frame.
How to use it
<TextPressure
text="COMPRESS"
fontUrl="https://res.cloudinary.com/dr6lvwubh/raw/upload/v1529908256/CompressaPRO-GX.woff2"
fontFamily="Compressa VF"
width={true}
weight={true}
italic={true}
/>This component requires a variable font that supports the wdth and wght axes. The default uses Compressa, but any variable font with those axes will work.
Key takeaways
- The lerped mouse position is what separates this from a basic hover effect. That smooth lag is the "pressure" feeling.
fontVariationSettingsis only updated when the value actually changes, preventing unnecessary style recalculations every frame.- Running the animation in a
requestAnimationFrameloop rather than amousemoveevent handler keeps updates consistent and avoids firing more often than the screen refreshes.