TextPressure
// preview
Text
Pressure radius (px)
Weight axis
Width axis
Italic axis
Fill container
// source
TSJS
TailwindCSS
/*!
* TextPressure, a DesignPass.dev component by Ernest Liu (ernestliu.com)
* Docs & live playground: https://designpass.dev/components/text-pressure
* MIT licensed, keep this notice in copies and adaptations.
*/
"use client";
import React, { useEffect, useRef, type CSSProperties } from "react";
export interface TextPressureProps {
text: string;
/** A variable font family name; loaded from fontUrl if provided. */
fontFamily?: string;
/** A Google Fonts (or similar) stylesheet URL, or a direct .woff2/.ttf
* file. Set to "" if the family is already loaded on the page. */
fontUrl?: string;
/** Respond on the weight (wght) axis. */
weight?: boolean;
/** Respond on the width (wdth) axis. */
width?: boolean;
/** Respond on the italic (ital) axis. */
italic?: boolean;
minWeight?: number;
maxWeight?: number;
minWidth?: number;
maxWidth?: number;
/** Pressure radius (px) around the cursor. */
radius?: number;
/** Stretch the characters to fill the container width. */
spread?: boolean;
/** Auto-size the font so the line fills the container. */
autoFit?: boolean;
minFontSize?: number;
className?: string;
style?: CSSProperties;
}
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
// Roboto Flex (Apache 2.0, via Google Fonts) ships wght + wdth axes in one
// family, so the pressure effect works out of the box with no font hunting
// or licensing surprises.
const DEFAULT_FONT_FAMILY = "Roboto Flex";
const DEFAULT_FONT_URL =
"https://fonts.googleapis.com/css2?family=Roboto+Flex:opsz,wght,wdth@8..144,100..1000,25..151&display=swap";
/**
* Variable-font text where each glyph swells toward the cursor: weight,
* width, and italic axes ramp up with proximity and relax on a smooth
* trailing ease. The rAF loop only runs while the pointer is near and
* parks itself once every glyph has settled. Zero dependencies.
*/
export default function TextPressure({
text,
fontFamily = DEFAULT_FONT_FAMILY,
fontUrl = DEFAULT_FONT_URL,
weight = true,
width = true,
italic = false,
minWeight = 320,
maxWeight = 900,
minWidth = 75,
maxWidth = 151,
radius = 140,
spread = true,
autoFit = true,
minFontSize = 24,
className = "",
style,
}: TextPressureProps) {
const containerRef = useRef<HTMLDivElement>(null);
const lineRef = useRef<HTMLSpanElement>(null);
const chars = [...text];
useEffect(() => {
const container = containerRef.current;
const line = lineRef.current;
if (!container || !line) return;
let disposed = false;
// Size the line to fill the container: measure at the current size,
// then scale proportionally.
const fit = () => {
if (!autoFit) return;
line.style.fontSize = "100px";
const lineWidth = line.scrollWidth;
if (lineWidth > 0) {
const next = Math.max((100 * container.clientWidth) / lineWidth, minFontSize);
line.style.fontSize = `${next}px`;
}
};
// Load the variable font, either as a direct file (FontFace API) or a
// stylesheet like Google Fonts (a <link>, since that URL serves CSS,
// not a font binary). Skipped when the family is already available.
if (fontUrl) {
const alreadyLoaded = [...document.fonts].some(
(loaded) => loaded.family === fontFamily,
);
const isDirectFontFile = /\.(woff2?|ttf|otf)(\?.*)?$/i.test(fontUrl);
if (!alreadyLoaded && isDirectFontFile) {
const face = new FontFace(fontFamily, `url(${fontUrl})`);
document.fonts.add(face);
face
.load()
.then(() => {
if (!disposed) fit();
})
.catch(() => {});
} else if (!alreadyLoaded) {
let link = document.querySelector<HTMLLinkElement>(`link[data-text-pressure-font="${fontUrl}"]`);
if (!link) {
link = document.createElement("link");
link.rel = "stylesheet";
link.href = fontUrl;
link.dataset.textPressureFont = fontUrl;
document.head.appendChild(link);
}
document.fonts.ready.then(() => {
if (!disposed) fit();
});
}
}
fit();
const resizeObserver = new ResizeObserver(fit);
resizeObserver.observe(container);
const glyphs = Array.from(line.querySelectorAll<HTMLElement>("[data-pressure-char]"));
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (glyphs.length === 0 || reducedMotion) {
return () => {
disposed = true;
resizeObserver.disconnect();
};
}
const cursor = { x: 0, y: 0 };
const target = { x: 0, y: 0 };
const strengths = new Float32Array(glyphs.length);
let active = false;
let frame = 0;
let settled = true;
const tick = () => {
cursor.x = lerp(cursor.x, target.x, 0.14);
cursor.y = lerp(cursor.y, target.y, 0.14);
let energy = 0;
for (let i = 0; i < glyphs.length; i++) {
const glyph = glyphs[i];
// Glyph centers shift as neighbors swell, so measure every frame.
const rect = glyph.getBoundingClientRect();
const dx = cursor.x - (rect.left + rect.width / 2);
const dy = cursor.y - (rect.top + rect.height / 2);
const distance = Math.hypot(dx, dy);
const pressure = active ? Math.max(0, 1 - distance / radius) : 0;
strengths[i] = lerp(strengths[i], pressure, 0.22);
energy += Math.abs(pressure - strengths[i]);
const settings: string[] = [];
if (weight) settings.push(`"wght" ${Math.round(lerp(minWeight, maxWeight, strengths[i]))}`);
if (width) settings.push(`"wdth" ${Math.round(lerp(minWidth, maxWidth, strengths[i]))}`);
if (italic) settings.push(`"ital" ${strengths[i].toFixed(2)}`);
glyph.style.fontVariationSettings = settings.join(", ");
}
energy += Math.abs(target.x - cursor.x) + Math.abs(target.y - cursor.y);
// Park the loop whenever nothing is moving; pointer events wake it.
if (energy < 0.01) {
settled = true;
return;
}
frame = requestAnimationFrame(tick);
};
const wake = () => {
if (settled) {
settled = false;
frame = requestAnimationFrame(tick);
}
};
const onPointerMove = (event: PointerEvent) => {
target.x = event.clientX;
target.y = event.clientY;
wake();
};
const onPointerEnter = (event: PointerEvent) => {
cursor.x = event.clientX;
cursor.y = event.clientY;
target.x = event.clientX;
target.y = event.clientY;
active = true;
wake();
};
const onPointerLeave = () => {
active = false;
wake();
};
container.addEventListener("pointerenter", onPointerEnter);
container.addEventListener("pointermove", onPointerMove, { passive: true });
container.addEventListener("pointerleave", onPointerLeave);
return () => {
disposed = true;
resizeObserver.disconnect();
container.removeEventListener("pointerenter", onPointerEnter);
container.removeEventListener("pointermove", onPointerMove);
container.removeEventListener("pointerleave", onPointerLeave);
cancelAnimationFrame(frame);
};
}, [
text,
fontFamily,
fontUrl,
weight,
width,
italic,
minWeight,
maxWeight,
minWidth,
maxWidth,
radius,
autoFit,
minFontSize,
]);
return (
<div ref={containerRef} className={`relative w-full ${className}`} style={style}>
<span
ref={lineRef}
aria-label={text}
role="text"
className={`flex whitespace-nowrap leading-none ${spread ? "justify-between" : "justify-center"}`}
style={{ fontFamily }}
>
{chars.map((char, index) => (
<span key={index} data-pressure-char aria-hidden="true" className="inline-block">
{char === " " ? "\u00A0" : char}
</span>
))}
</span>
</div>
);
}
// install
Install the TextPressure component from DesignPass into this project by running:
npx shadcn@latest add "https://designpass.dev/r/TextPressure-TS-TW.json"
If the project has no components.json yet, run `npx shadcn@latest init` first.
Then show me a minimal usage example.Need the license details? Read the component license.