DesignPass.dev

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.

// updates

Know when new components drop

A short email when something new lands in the library. No noise, unsubscribe anytime.