Press
A variable-font pressure headline — the line behaves as a press bed under the cursor: glyph weight swells with a gaussian falloff while nearby letters rise and letter-spacing opens around the pressure point. Idle, the weight axis breathes almost imperceptibly. Pure rAF over font-variation-settings; real text in the DOM, static mid-weight under reduced motion.
cssfree
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/use-reduced-motion";
import { useVisibilityPause } from "@/registry/default/hooks/use-visibility-pause/use-visibility-pause";
export interface PressProps extends Omit<React.ComponentPropsWithoutRef<"span">, "children"> {
/** The headline text. Server-renders as plain text; the effect attaches after hydration. */
children: string;
/** Resting font weight for untouched glyphs. @default 320 */
minWeight?: number;
/** Font weight a glyph reaches directly under the cursor. @default 900 */
maxWeight?: number;
/** Radius in px of the pressure field around the cursor. @default 160 */
radius?: number;
/** How far, in px, glyphs near the cursor rise off the baseline. @default 6 */
lift?: number;
/** How far, in em, letter-spacing opens around the pressure point. @default 0.06 */
spread?: number;
/**
* Font stack. Must expose a variable `wght` axis for the swell to render;
* the default system stack is variable on every modern platform.
* @default variable system-ui stack
*/
fontFamily?: string;
/** Freeze the pressure field (and the idle breathing). @default false */
paused?: boolean;
}
const DEFAULT_FONT =
"system-ui, -apple-system, 'Segoe UI Variable Display', 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif";
/** True on touch-first devices, where cursor pressure is meaningless. SSR-safe: false on the server. */
function useCoarsePointer(): boolean {
return React.useSyncExternalStore(
subscribeCoarse,
() => window.matchMedia("(pointer: coarse)").matches,
() => false
);
}
function subscribeCoarse(callback: () => void): () => void {
const mql = window.matchMedia("(pointer: coarse)");
mql.addEventListener("change", callback);
return () => mql.removeEventListener("change", callback);
}
/**
* Press — a variable-font pressure headline. The whole line behaves as a
* press bed under the cursor: glyph weight swells with a gaussian proximity
* falloff while nearby letters rise off the baseline and letter-spacing opens
* around the pressure point, so the line visibly deforms as a field rather
* than one glyph at a time. Idle, the weight axis breathes almost
* imperceptibly so the headline reads as alive before first hover.
*
* Styles are written imperatively per frame (rAF lerp — no CSS transitions on
* `font-variation-settings`, which are janky) and the loop pauses offscreen
* and on hidden tabs. Server-side it renders the intact string, splitting
* into per-glyph spans only after mount, so hydration always matches and
* crawlers see real text. Coarse pointers and reduced motion get a static
* mid-weight treatment.
*/
export function Press({
children,
minWeight = 320,
maxWeight = 900,
radius = 160,
lift = 6,
spread = 0.06,
fontFamily = DEFAULT_FONT,
paused = false,
className,
style,
...props
}: PressProps) {
const reducedMotion = useReducedMotion();
const coarse = useCoarsePointer();
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => setMounted(true), []);
const isStatic = reducedMotion || coarse;
const midWeight = (minWeight + maxWeight) / 2;
// Split into words (trailing spaces attached) so line-wrapping matches the
// plain server-rendered text, then into characters within each word.
const wordData = React.useMemo(() => {
let offset = 0;
return children.split(/(?<=\s)/).map((word) => {
const chars = Array.from(word);
const start = offset;
offset += chars.length;
return { chars, start };
});
}, [children]);
const totalChars = React.useMemo(
() => wordData.reduce((sum, w) => sum + w.chars.length, 0),
[wordData]
);
const charRefs = React.useRef<(HTMLSpanElement | null)[]>([]);
const influences = React.useRef<number[]>([]);
const pointer = React.useRef({ x: 0, y: 0, has: false });
React.useEffect(() => {
if (isStatic || !mounted) return;
const onMove = (event: PointerEvent) => {
if (event.pointerType === "touch") return;
pointer.current.x = event.clientX;
pointer.current.y = event.clientY;
pointer.current.has = true;
};
const onLeave = () => {
pointer.current.has = false;
};
window.addEventListener("pointermove", onMove, { passive: true });
document.documentElement.addEventListener("pointerleave", onLeave);
return () => {
window.removeEventListener("pointermove", onMove);
document.documentElement.removeEventListener("pointerleave", onLeave);
};
}, [isStatic, mounted]);
// Reset imperative state whenever the text or endpoints change.
React.useEffect(() => {
charRefs.current.length = totalChars;
influences.current = new Array<number>(totalChars).fill(0);
for (const el of charRefs.current) {
if (!el) continue;
el.style.fontVariationSettings = `'wght' ${minWeight}`;
el.style.transform = "translateY(0px)";
el.style.letterSpacing = "0em";
}
}, [totalChars, minWeight, maxWeight]);
const wrapperRef = useVisibilityPause<HTMLSpanElement>(
(elapsed) => {
const spans = charRefs.current;
const p = pointer.current;
const sigma = radius / 2;
// Read pass first (rects), write pass second — one forced layout per frame.
const targets = new Array<number>(spans.length).fill(0);
if (p.has) {
for (let i = 0; i < spans.length; i++) {
const el = spans[i];
if (!el) continue;
const rect = el.getBoundingClientRect();
const dx = rect.left + rect.width / 2 - p.x;
const dy = rect.top + rect.height / 2 - p.y;
targets[i] = Math.exp(-(dx * dx + dy * dy) / (2 * sigma * sigma));
}
}
const weightSpan = maxWeight - minWeight;
for (let i = 0; i < spans.length; i++) {
const el = spans[i];
if (!el) continue;
const cur = influences.current[i] ?? 0;
const next = cur + ((targets[i] ?? 0) - cur) * 0.16;
influences.current[i] = next;
// Idle breathing: a barely-perceptible traveling swell of the weight
// axis, fading out wherever real cursor pressure takes over.
const breath = 0.05 * (0.5 + 0.5 * Math.sin(elapsed * 0.9 + i * 0.42)) * (1 - next);
const t = Math.min(1, next + breath);
el.style.fontVariationSettings = `'wght' ${(minWeight + weightSpan * t).toFixed(1)}`;
el.style.transform = `translateY(${(-lift * next).toFixed(2)}px)`;
el.style.letterSpacing = next > 0.005 ? `${(spread * next).toFixed(4)}em` : "0em";
}
},
{ paused: paused || isStatic || !mounted }
);
const baseStyle: React.CSSProperties = {
fontFamily,
fontVariationSettings: `'wght' ${isStatic ? midWeight : minWeight}`,
...style,
};
// Server render + reduced motion / coarse pointer: the intact string, no
// per-glyph spans — identical markup server and client, real text for
// crawlers, static mid-weight when the effect can't run.
if (!mounted || isStatic) {
return (
<span
ref={wrapperRef}
data-crucible="press"
className={cn("inline-block", className)}
style={baseStyle}
{...props}
>
{children}
</span>
);
}
return (
<span
ref={wrapperRef}
data-crucible="press"
aria-label={children}
className={cn("inline-block", className)}
style={baseStyle}
{...props}
>
{wordData.map((word, wi) => (
<span key={wi} aria-hidden className="inline-block whitespace-pre">
{word.chars.map((ch, ci) => {
const index = word.start + ci;
return (
<span
key={ci}
ref={(el) => {
charRefs.current[index] = el;
}}
className="inline-block whitespace-pre will-change-transform"
style={{ fontVariationSettings: `'wght' ${minWeight}` }}
>
{ch}
</span>
);
})}
</span>
))}
</span>
);
}
Installation
CLI
npx shadcn@latest add @crucible/pressProps
| Prop | Type | Default | Description |
|---|---|---|---|
| children | string | The headline text. Server-renders as plain text; the effect attaches after hydration. | |
| minWeight | number | 320 | Resting font weight for untouched glyphs. |
| maxWeight | number | 900 | Font weight a glyph reaches directly under the cursor. |
| radius | number | 160 | Radius in px of the pressure field around the cursor. |
| lift | number | 6 | How far, in px, glyphs near the cursor rise off the baseline. |
| spread | number | 0.06 | How far, in em, letter-spacing opens around the pressure point. |
| fontFamily | string | variable system-ui stack | Font stack. Must expose a variable wght axis for the swell to render; the default system stack is variable on every modern platform. |
| paused | boolean | false | Freeze the pressure field (and the idle breathing). |
Also accepts all props of Omit<React.ComponentPropsWithoutRef<"span">, "children"> — they pass through to the underlying element. | |||
Honors prefers-reduced-motion with a designed static fallback, and passes className through.