Text Effects

Mesh Text

Text drawn to a canvas texture on a WebGL2 grid mesh that the pointer drags and warps with spring-back and chromatic split.

Install

npx shadcn@latest add @paragon/mesh-text

mesh-text.tsx

"use client";

import * as React from "react";
import { useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export interface MeshTextProps extends React.ComponentProps<"div"> {
  /** The text drawn to the canvas texture and warped on the mesh. */
  text?: string;
  /** Displacement strength — how far the mesh drags under the pointer. */
  force?: number;
  /** Pointer influence radius, as a fraction of the surface (0.05–0.6). */
  radius?: number;
  /** Chromatic-aberration split at the warp edges (0 = off). */
  colorSplit?: number;
  /** Text fill color. Defaults to the foreground token. */
  color?: string;
  /** Font size in px (drawn to the texture). */
  fontSize?: number;
  /** Font weight for the drawn text. */
  fontWeight?: number;
  /** Render static text (no WebGL) — for reduced motion or fallback. */
  static?: boolean;
}

const VERT = `#version 300 es
in vec2 a_pos;      // clip-space base position (-1..1)
in vec2 a_uv;       // texture coord (0..1)
uniform vec2 u_pointer;   // pointer in clip space
uniform float u_radius;   // influence radius (clip units)
uniform float u_force;    // displacement strength
uniform float u_active;   // 0..1 pointer engaged
out vec2 v_uv;
out float v_disp;
void main() {
  vec2 pos = a_pos;
  vec2 d = a_pos - u_pointer;
  float dist = length(d);
  float fall = smoothstep(u_radius, 0.0, dist) * u_active;
  // Pull vertices toward the pointer, with a slight swirl for organic warp.
  vec2 dir = dist > 0.0001 ? d / dist : vec2(0.0);
  pos -= dir * fall * u_force;
  v_disp = fall;
  v_uv = a_uv;
  gl_Position = vec4(pos, 0.0, 1.0);
}`;

const FRAG = `#version 300 es
precision highp float;
in vec2 v_uv;
in float v_disp;
uniform sampler2D u_tex;
uniform float u_split;   // chromatic split amount
out vec4 outColor;
void main() {
  float s = u_split * v_disp;
  vec2 off = vec2(s, 0.0);
  // Sample coverage (alpha) per channel with a small offset for RGB fringing.
  float a_r = texture(u_tex, v_uv + off).a;
  float a_g = texture(u_tex, v_uv).a;
  float a_b = texture(u_tex, v_uv - off).a;
  vec3 col = texture(u_tex, v_uv).rgb;
  float a = max(a_g, max(a_r, a_b));
  // Flat color * center coverage; fringe the R/B edges only where split active.
  vec3 outc = col * a_g;
  outc.r = mix(outc.r, col.r * a_r, step(0.001, s));
  outc.b = mix(outc.b, col.b * a_b, step(0.001, s));
  outColor = vec4(outc, a);
}`;

function compile(gl: WebGL2RenderingContext, type: number, src: string) {
  const sh = gl.createShader(type)!;
  gl.shaderSource(sh, src);
  gl.compileShader(sh);
  if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
    const log = gl.getShaderInfoLog(sh);
    gl.deleteShader(sh);
    throw new Error(log ?? "shader compile failed");
  }
  return sh;
}

/**
 * MeshText — the text is painted to an offscreen 2D canvas, uploaded as a
 * WebGL2 texture, and mapped onto a finely subdivided grid mesh. The pointer
 * drags the nearby vertices with a smooth falloff and the mesh springs back to
 * rest when released, so the type stretches and ripples like a warped sheet.
 * An optional chromatic split fringes the warped edges into RGB.
 *
 * Pure WebGL2 — no dependencies. The texture is redrawn (and DPR-corrected) on
 * resize; the animation pauses when scrolled offscreen and every GL resource,
 * rAF, and listener is released on unmount. Falls back to plain, legible text
 * under `prefers-reduced-motion`, on coarse pointers, or if WebGL2 is
 * unavailable. The real text is always in the DOM (sr-only) for accessibility.
 */
export function MeshText({
  text = "Warp me",
  force = 0.22,
  radius = 0.32,
  colorSplit = 0.01,
  color,
  fontSize = 120,
  fontWeight = 700,
  static: isStatic = false,
  className,
  style,
  ...props
}: MeshTextProps) {
  const reducedMotion = useReducedMotion() ?? false;
  const wrapRef = React.useRef<HTMLDivElement>(null);
  const canvasRef = React.useRef<HTMLCanvasElement>(null);
  const [failed, setFailed] = React.useState(false);
  const [fine, setFine] = React.useState(false);

  React.useEffect(() => {
    if (typeof window === "undefined" || !window.matchMedia) return;
    const mql = window.matchMedia("(hover: hover) and (pointer: fine)");
    const sync = () => setFine(mql.matches);
    sync();
    mql.addEventListener("change", sync);
    return () => mql.removeEventListener("change", sync);
  }, []);

  const disabled = isStatic || reducedMotion || failed || !fine;

  React.useEffect(() => {
    if (disabled) return;
    const wrap = wrapRef.current;
    const canvas = canvasRef.current;
    if (!wrap || !canvas) return;

    const gl = canvas.getContext("webgl2", {
      alpha: true,
      premultipliedAlpha: false,
      antialias: true,
    });
    if (!gl) {
      setFailed(true);
      return;
    }

    let program: WebGLProgram | null = null;
    let vao: WebGLVertexArrayObject | null = null;
    let posBuf: WebGLBuffer | null = null;
    let uvBuf: WebGLBuffer | null = null;
    let idxBuf: WebGLBuffer | null = null;
    let tex: WebGLTexture | null = null;
    let raf = 0;
    let running = true;
    let disposed = false;

    // --- Build program ---
    try {
      const vs = compile(gl, gl.VERTEX_SHADER, VERT);
      const fs = compile(gl, gl.FRAGMENT_SHADER, FRAG);
      program = gl.createProgram()!;
      gl.attachShader(program, vs);
      gl.attachShader(program, fs);
      gl.linkProgram(program);
      gl.deleteShader(vs);
      gl.deleteShader(fs);
      if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
        throw new Error(gl.getProgramInfoLog(program) ?? "link failed");
      }
    } catch {
      setFailed(true);
      return;
    }

    gl.useProgram(program);

    // --- Mesh grid ---
    const COLS = 40;
    const ROWS = 24;
    const positions: number[] = [];
    const uvs: number[] = [];
    const indices: number[] = [];
    for (let y = 0; y <= ROWS; y++) {
      for (let x = 0; x <= COLS; x++) {
        const u = x / COLS;
        const v = y / ROWS;
        positions.push(u * 2 - 1, (1 - v) * 2 - 1);
        uvs.push(u, v);
      }
    }
    const idx = (x: number, y: number) => y * (COLS + 1) + x;
    for (let y = 0; y < ROWS; y++) {
      for (let x = 0; x < COLS; x++) {
        indices.push(idx(x, y), idx(x + 1, y), idx(x, y + 1));
        indices.push(idx(x + 1, y), idx(x + 1, y + 1), idx(x, y + 1));
      }
    }

    vao = gl.createVertexArray();
    gl.bindVertexArray(vao);

    const aPos = gl.getAttribLocation(program, "a_pos");
    const aUv = gl.getAttribLocation(program, "a_uv");

    posBuf = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, posBuf);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);
    gl.enableVertexAttribArray(aPos);
    gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);

    uvBuf = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, uvBuf);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(uvs), gl.STATIC_DRAW);
    gl.enableVertexAttribArray(aUv);
    gl.vertexAttribPointer(aUv, 2, gl.FLOAT, false, 0, 0);

    idxBuf = gl.createBuffer();
    gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, idxBuf);
    gl.bufferData(
      gl.ELEMENT_ARRAY_BUFFER,
      new Uint16Array(indices),
      gl.STATIC_DRAW,
    );

    // --- Texture (text drawn to a 2D canvas) ---
    tex = gl.createTexture();
    const textCanvas = document.createElement("canvas");
    const tctx = textCanvas.getContext("2d")!;

    const resolveColor = () => {
      if (color) return color;
      // Read the foreground token off the wrapper so themes work.
      const cs = getComputedStyle(wrap);
      return cs.color || "#111";
    };

    const drawTexture = (w: number, h: number) => {
      textCanvas.width = w;
      textCanvas.height = h;
      tctx.clearRect(0, 0, w, h);
      const fam =
        getComputedStyle(wrap).fontFamily ||
        "system-ui, -apple-system, sans-serif";
      tctx.fillStyle = resolveColor();
      tctx.textAlign = "center";
      tctx.textBaseline = "middle";
      // Fit the font size to the canvas width so long text never clips.
      let fs = fontSize * (w / 800);
      tctx.font = `${fontWeight} ${fs}px ${fam}`;
      let measured = tctx.measureText(text).width;
      const maxW = w * 0.9;
      if (measured > maxW) {
        fs *= maxW / measured;
        tctx.font = `${fontWeight} ${fs}px ${fam}`;
        measured = maxW;
      }
      tctx.fillText(text, w / 2, h / 2);

      gl.bindTexture(gl.TEXTURE_2D, tex);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
      gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
      gl.texImage2D(
        gl.TEXTURE_2D,
        0,
        gl.RGBA,
        gl.RGBA,
        gl.UNSIGNED_BYTE,
        textCanvas,
      );
    };

    // --- Uniforms ---
    const uPointer = gl.getUniformLocation(program, "u_pointer");
    const uRadius = gl.getUniformLocation(program, "u_radius");
    const uForce = gl.getUniformLocation(program, "u_force");
    const uActive = gl.getUniformLocation(program, "u_active");
    const uSplit = gl.getUniformLocation(program, "u_split");
    const uTex = gl.getUniformLocation(program, "u_tex");

    gl.enable(gl.BLEND);
    gl.blendFuncSeparate(
      gl.SRC_ALPHA,
      gl.ONE_MINUS_SRC_ALPHA,
      gl.ONE,
      gl.ONE_MINUS_SRC_ALPHA,
    );

    // --- Pointer state (spring toward target) ---
    const target = { x: 0, y: 0, on: 0 };
    const cur = { x: 0, y: 0, on: 0 };

    const onMove = (e: PointerEvent) => {
      const r = canvas.getBoundingClientRect();
      target.x = ((e.clientX - r.left) / r.width) * 2 - 1;
      target.y = (1 - (e.clientY - r.top) / r.height) * 2 - 1;
      target.on = 1;
    };
    const onLeave = () => {
      target.on = 0;
    };

    const resize = () => {
      const rect = wrap.getBoundingClientRect();
      const dpr = Math.min(2, window.devicePixelRatio || 1);
      const w = Math.max(1, Math.round(rect.width * dpr));
      const h = Math.max(1, Math.round(rect.height * dpr));
      if (canvas.width !== w || canvas.height !== h) {
        canvas.width = w;
        canvas.height = h;
      }
      gl.viewport(0, 0, w, h);
      drawTexture(w, h);
    };

    const render = () => {
      if (disposed) return;
      if (!running) {
        raf = 0;
        return;
      }
      // Ease pointer + engagement.
      cur.x += (target.x - cur.x) * 0.2;
      cur.y += (target.y - cur.y) * 0.2;
      cur.on += (target.on - cur.on) * 0.08;

      gl.clearColor(0, 0, 0, 0);
      gl.clear(gl.COLOR_BUFFER_BIT);
      gl.useProgram(program);
      gl.bindVertexArray(vao);
      gl.activeTexture(gl.TEXTURE0);
      gl.bindTexture(gl.TEXTURE_2D, tex);
      gl.uniform1i(uTex, 0);
      gl.uniform2f(uPointer, cur.x, cur.y);
      gl.uniform1f(uRadius, Math.max(0.02, radius) * 2);
      gl.uniform1f(uForce, force);
      gl.uniform1f(uActive, cur.on);
      gl.uniform1f(uSplit, colorSplit);
      gl.drawElements(gl.TRIANGLES, indices.length, gl.UNSIGNED_SHORT, 0);
      raf = requestAnimationFrame(render);
    };

    const ro = new ResizeObserver(resize);
    ro.observe(wrap);
    const io = new IntersectionObserver(
      ([entry]) => {
        running = entry.isIntersecting;
        if (running && !raf && !disposed) raf = requestAnimationFrame(render);
      },
      { threshold: 0 },
    );
    io.observe(wrap);

    canvas.addEventListener("pointermove", onMove);
    canvas.addEventListener("pointerleave", onLeave);

    resize();
    raf = requestAnimationFrame(render);

    return () => {
      disposed = true;
      running = false;
      cancelAnimationFrame(raf);
      ro.disconnect();
      io.disconnect();
      canvas.removeEventListener("pointermove", onMove);
      canvas.removeEventListener("pointerleave", onLeave);
      gl.deleteBuffer(posBuf);
      gl.deleteBuffer(uvBuf);
      gl.deleteBuffer(idxBuf);
      gl.deleteTexture(tex);
      gl.deleteVertexArray(vao);
      gl.deleteProgram(program);
      const lose = gl.getExtension("WEBGL_lose_context");
      lose?.loseContext();
    };
  }, [
    disabled,
    text,
    force,
    radius,
    colorSplit,
    color,
    fontSize,
    fontWeight,
  ]);

  if (disabled) {
    return (
      <div
        ref={wrapRef}
        data-slot="mesh-text"
        className={cn(
          "flex min-h-[4em] items-center justify-center text-center text-6xl font-bold tracking-tight",
          className,
        )}
        style={{ color, ...style }}
        {...props}
      >
        {text}
      </div>
    );
  }

  return (
    <div
      ref={wrapRef}
      data-slot="mesh-text"
      className={cn(
        "relative flex min-h-[4em] w-full items-center justify-center text-6xl font-bold tracking-tight select-none",
        className,
      )}
      style={{ color, touchAction: "none", ...style }}
      {...props}
    >
      <span className="sr-only">{text}</span>
      <canvas
        ref={canvasRef}
        aria-hidden
        className="absolute inset-0 h-full w-full"
      />
    </div>
  );
}