Text drawn to a canvas texture on a WebGL2 grid mesh that the pointer drags and warps with spring-back and chromatic split.
npx shadcn@latest add @paragon/mesh-text"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>
);
}