Dirck Mulder
Text Animations||3 min read

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.

tsx
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.

tsx
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.

tsx
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.

tsx
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

tsx
<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.
  • fontVariationSettings is only updated when the value actually changes, preventing unnecessary style recalculations every frame.
  • Running the animation in a requestAnimationFrame loop rather than a mousemove event handler keeps updates consistent and avoids firing more often than the screen refreshes.