Pressure Test

Experimental variable font interaction responding to cursor proximity.

Overview

An experimental typographic component leveraging variable fonts to create a physical connection between user and text. Dynamically manipulates weight, width, and italic axes based on cursor distance for a tactile feel.


Features

  • Variable font axis control (weight, width, italic)
  • Per-character proximity detection
  • Smooth interpolation for organic movement
  • Flex layout auto-sizing
  • Outline stroke mode

Preview

Live Preview

Installation

Install via the ZenBlocks CLI to automatically handle dependencies:

npx shadcn@latest add https://zenblocks-three.vercel.app/r/pressure-test.json

Method 2: Manual

  1. Install Dependencies

    No external dependencies required.

  2. Copy the Source Code

    Copy the code below into components/zenblocks/pressure-test.tsx.

Click to expand source
"use client";

import React, {
  useEffect,
  useRef,
  useState,
  useMemo,
  useCallback,
} from "react";
import { cn } from "@/lib/utils";

/* -------------------------------------------------------------------------- */
/*                                   TYPES                                    */
/* -------------------------------------------------------------------------- */

interface Vec2 {
  x: number;
  y: number;
}

interface PressureTestProps {
  text?: string;
  fontFamily?: string;
  fontUrl?: string;
  width?: boolean;
  weight?: boolean;
  italic?: boolean;
  alpha?: boolean;
  flex?: boolean;
  stroke?: boolean;
  scale?: boolean;
  strokeColor?: string;
  strokeWidth?: number;
  className?: string;
  minFontSize?: number;
}

/* -------------------------------------------------------------------------- */
/*                                  HELPERS                                   */
/* -------------------------------------------------------------------------- */

const dist = (a: Vec2, b: Vec2): 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
): number => {
  const val = maxVal - Math.abs((maxVal * distance) / maxDist);
  return Math.max(minVal, val + minVal);
};

function debounce<T extends (...args: never[]) => void>(
  fn: T,
  delay: number
): (...args: Parameters<T>) => void {
  let timeoutId: ReturnType<typeof setTimeout> | undefined;

  return (...args: Parameters<T>) => {
    if (timeoutId) clearTimeout(timeoutId);
    timeoutId = setTimeout(() => fn(...args), delay);
  };
}

/* -------------------------------------------------------------------------- */
/*                               COMPONENT                                    */
/* -------------------------------------------------------------------------- */

export function PressureTest({
  text = "ZENBLOCKS",
  fontFamily = "Compressa VF",
  fontUrl = "https://res.cloudinary.com/dr6lvwubh/raw/upload/v1529908256/CompressaPRO-GX.woff2",
  width = true,
  weight = true,
  italic = true,
  alpha = false,
  flex = true,
  stroke = false,
  scale = false,
  strokeColor,
  strokeWidth = 2,
  className = "",
  minFontSize = 24,
  ...props
}: PressureTestProps & React.HTMLAttributes<HTMLHeadingElement>) {
  const containerRef = useRef<HTMLDivElement>(null);
  const titleRef = useRef<HTMLHeadingElement>(null);
  const spansRef = useRef<Array<HTMLSpanElement | null>>([]);

  const mouseRef = useRef<Vec2>({ x: 0, y: 0 });
  const cursorRef = useRef<Vec2>({ x: 0, y: 0 });

  const [fontSize, setFontSize] = useState(minFontSize);
  const [scaleY, setScaleY] = useState(1);
  const [lineHeight, setLineHeight] = useState(1);
  const [mounted, setMounted] = useState(false);

  const chars = useMemo(() => text.split(""), [text]);

  /* ------------------------------ MOUNT ----------------------------------- */

  useEffect(() => {
    setMounted(true);
  }, []);

  /* ------------------------------ POINTER -------------------------------- */

  useEffect(() => {
    if (!mounted) return;

    const onMouseMove = (e: MouseEvent) => {
      cursorRef.current.x = e.clientX;
      cursorRef.current.y = e.clientY;
    };

    const onTouchMove = (e: TouchEvent) => {
      const t = e.touches[0];
      cursorRef.current.x = t.clientX;
      cursorRef.current.y = t.clientY;
    };

    window.addEventListener("mousemove", onMouseMove);
    window.addEventListener("touchmove", onTouchMove, { passive: true });

    if (containerRef.current) {
      const rect = containerRef.current.getBoundingClientRect();
      mouseRef.current = {
        x: rect.left + rect.width / 2,
        y: rect.top + rect.height / 2,
      };
      cursorRef.current = { ...mouseRef.current };
    }

    return () => {
      window.removeEventListener("mousemove", onMouseMove);
      window.removeEventListener("touchmove", onTouchMove);
    };
  }, [mounted]);

  /* ------------------------------ RESIZE ---------------------------------- */

  const setSize = useCallback(() => {
    if (!containerRef.current || !titleRef.current) return;

    const { width: cw, height: ch } =
      containerRef.current.getBoundingClientRect();

    let nextFontSize = cw / (chars.length / 2);
    nextFontSize = Math.max(nextFontSize, minFontSize);

    setFontSize(nextFontSize);
    setScaleY(1);
    setLineHeight(1);

    requestAnimationFrame(() => {
      if (!titleRef.current) return;
      const textRect = titleRef.current.getBoundingClientRect();

      if (scale && textRect.height > 0) {
        const yRatio = ch / textRect.height;
        setScaleY(yRatio);
        setLineHeight(yRatio);
      }
    });
  }, [chars.length, minFontSize, scale]);

  useEffect(() => {
    if (!mounted) return;

    const onResize = debounce(setSize, 100);
    onResize();
    window.addEventListener("resize", onResize);
    return () => window.removeEventListener("resize", onResize);
  }, [mounted, setSize]);

  /* ------------------------------ ANIMATION ------------------------------- */

  useEffect(() => {
    if (!mounted) return;

    let rafId = 0;

    const animate = () => {
      mouseRef.current.x += (cursorRef.current.x - mouseRef.current.x) / 15;
      mouseRef.current.y += (cursorRef.current.y - mouseRef.current.y) / 15;

      if (titleRef.current) {
        const titleRect = titleRef.current.getBoundingClientRect();
        const maxDist = titleRect.width / 2;

        spansRef.current.forEach((span) => {
          if (!span) return;

          const rect = span.getBoundingClientRect();
          const center: Vec2 = {
            x: rect.x + rect.width / 2,
            y: rect.y + rect.height / 2,
          };

          const d = dist(mouseRef.current, center);

          const wdth = width ? Math.floor(getAttr(d, maxDist, 5, 200)) : 100;
          const wght = weight ? Math.floor(getAttr(d, maxDist, 100, 900)) : 400;
          const ital = italic ? getAttr(d, maxDist, 0, 1).toFixed(2) : "0";
          const a = alpha ? getAttr(d, maxDist, 0, 1).toFixed(2) : "1";

          span.style.fontVariationSettings = `'wght' ${wght}, 'wdth' ${wdth}, 'ital' ${ital}`;
          if (alpha) span.style.opacity = a;
        });
      }

      rafId = requestAnimationFrame(animate);
    };

    animate();
    return () => cancelAnimationFrame(rafId);
  }, [mounted, width, weight, italic, alpha]);

  /* ------------------------------ STYLES ---------------------------------- */

  const styleElement = useMemo(() => {
    if (!mounted) return null;

    return (
      <style>{`
        @font-face {
          font-family: '\${fontFamily}';
          src: url('\${fontUrl}');
          font-style: normal;
        }

        .pressure-test-title span {
          display: inline-block;
          transform-origin: center center;
        }

        .stroke span {
          position: relative;
          color: currentColor;
        }

        .stroke span::after {
          content: attr(data-char);
          position: absolute;
          inset: 0;
          color: transparent;
          z-index: -1;
          -webkit-text-stroke-width: \${strokeWidth}px;
          -webkit-text-stroke-color: \${strokeColor || "currentColor"};
        }
      `}</style>
    );
  }, [mounted, fontFamily, fontUrl, strokeColor, strokeWidth]);

  /* ------------------------------ RENDER ---------------------------------- */

  // 🔥 HARD GATE — NOTHING RENDERS ON SERVER
  if (!mounted) {
    return <div className="relative w-full h-full" aria-hidden="true" />;
  }

  return (
    <div
      ref={containerRef}
      className="relative w-full h-full bg-transparent flex items-center justify-center"
    >
      {styleElement}

      <h1
        ref={titleRef}
        className={cn(
          "pressure-test-title",
          flex && "flex justify-between w-full",
          stroke && "stroke",
          "uppercase text-center",
          className
        )}
        style={{
          fontFamily,
          fontSize,
          lineHeight,
          transform: `scale(1, \${scaleY})`,
          transformOrigin: "center top",
          margin: 0,
          fontWeight: 100,
          whiteSpace: "nowrap",
          width: "100%",
        }}
        {...props}
      >
        {chars.map((char: string, i: number) => (
          <span
            key={i}
            ref={(el) => {
              spansRef.current[i] = el;
            }}
            data-char={char}
          >
            {char}
          </span>
        ))}
      </h1>
    </div>
  );
};

Usage

import { PressureTest } from "@/components/zenblocks/pressure-test";

<PressureTest 
  text="ZENBLOCKS" 
  className="text-4xl md:text-6xl font-black text-orange-500 hover:text-orange-400 transition-colors" 
/>

Props

PropTypeDefaultDescription
textstring"ZENBLOCKS"The text to display interactively
classNamestring""Tailwind classes for styling (colors, margin, etc.)
flexbooleantrueScale text to fit container width
strokebooleanfalseEnable outline stroke mode
widthbooleantrueEnable variable font width axis
weightbooleantrueEnable variable font weight axis
italicbooleantrueEnable variable font italic axis
alphabooleanfalseEnable opacity distance effect
scalebooleanfalseEnable vertical scaling for tall fonts
minFontSizenumber24Minimum font size floor calculation

Accessibility

  • Renders as semantic <h1> by default
  • Returns to legible weight/width when not interacted
  • Variable font transitions don't interrupt text announcement
  • High contrast maintained in both themes

Customization

Font Family

Designed for "Compressa VF" but works with any variable font. Import the font in your global CSS.

Intensity

Adjust min and max values in the getAttr helper to control distortion extremes.

Outline Mode

<PressureTest text="OUTLINE" stroke={true} />

Motion Behavior

  • Input: Tracks mousemove distance to each character center
  • Reaction: Linearly interpolates font-variation-settings
  • Performance: Uses requestAnimationFrame with direct DOM manipulation (bypasses React state)

Performance Notes

  • Frame-by-frame updates via RAF for 60fps
  • Direct span ref manipulation (no React re-renders)
  • Character positions calculated once on mount
  • Touch events supported for mobile

Examples

Custom Text

<PressureTest 
  text="HOVER ME" 
  className="text-blue-500 font-display text-8xl"
/>

Stroke Mode

<PressureTest 
  text="OUTLINE" 
  stroke={true} 
  className="text-transparent stroke-white"
  strokeColor="#ffffff"
  strokeWidth={2}
/>

Notes

  • Requires a variable font file (WOFF2) with wght, wdth, and ital axes
  • Best viewed with large text sizes
  • Effect is most dramatic with high-contrast fonts
  • Component is client-side only