3D & Experimental
3DTypography
Heavy enamel letters with metal edges fixed to a plaster wall. One lamp walks past and the shadows swing with it; the mouse or arrow keys carry the lamp. Any word, any font. WebGL, no three.js.
Download
- React
- Tailwind CSS v4
- motion
components/ui/3d-typography.tsx
"use client";
import { useEffect, useRef } from "react";
import { useReducedMotion } from "motion/react";
type Typography3DProps = {
/** The word on the wall. One line. */
text?: string;
/** Six-digit hex colour of the enamel letter faces. */
color?: string;
/** Six-digit hex colour of the metal edges. */
metal?: string;
/** Six-digit hex colour of the wall. */
wall?: string;
/** CSS font family. A heavy font works best. */
font?: string;
/** 1 is the default pace; 2 is twice as fast. */
speed?: number;
/** Set the size here. */
className?: string;
};
const MAX_PIXELS = 1200000;
const REACH = 28;
const PAD = 34;
const VERTEX = `attribute vec2 p;void main(){gl_Position=vec4(p,0.,1.);}`;
/* The word is baked to a distance map, then cut as real solid letters: flat face, one chamfer, straight sides, mounted on a wall. */
const FRAGMENT = `precision highp float;
uniform sampler2D map2;
uniform vec2 res,span,eye;
uniform vec3 lamp,face,edge,back;
uniform float reach,tall,bevel,step2;
float flat2(vec2 p){return (texture2D(map2,p/span+.5).r-.5)*reach;}
float solid(vec3 p,float d){return max(max(d,p.z-tall),(d+p.z-tall+bevel)*.7071);}
float scene(vec3 p){return min(solid(p,flat2(p.xy)),p.z);}
float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}
float noise(vec2 p){
vec2 i=floor(p),f=fract(p);
f=f*f*(3.-2.*f);
return mix(mix(hash(i),hash(i+vec2(1.,0.)),f.x),mix(hash(i+vec2(0.,1.)),hash(i+vec2(1.,1.)),f.x),f.y);
}
float plaster(vec2 p){return noise(p*7.)*.5+noise(p*19.)*.3+noise(p*53.)*.2;}
void main(){
vec2 uv=(gl_FragCoord.xy-.5*res)/(.5*res.y);
vec3 o=vec3(eye,3.4),d=normalize(vec3(uv-eye,-3.4));
float t=(tall+.002-o.z)/d.z;
vec3 p=o+d*t;
for(int i=0;i<40;i++){
float h=scene(p);
if(h<.0012)break;
t+=h*.9;
p=o+d*t;
}
float dist=flat2(p.xy);
vec3 n=vec3(0.,0.,1.);
float kind=0.;
if(p.z>.004){
vec2 g=vec2(flat2(p.xy+vec2(step2,0.))-flat2(p.xy-vec2(step2,0.)),flat2(p.xy+vec2(0.,step2))-flat2(p.xy-vec2(0.,step2)));
g/=max(length(g),1e-4);
float cham=(dist+p.z-tall+bevel)*.7071;
if(dist>=p.z-tall&&dist>=cham){kind=2.;n=vec3(g,0.);}
else if(cham>=p.z-tall){kind=2.;n=normalize(vec3(g,1.));}
else kind=1.;
}
vec3 toLamp=lamp-p;
float gap=length(toLamp);
vec3 L=toLamp/gap;
float light=1.,s=.02;
vec3 from=p+n*.006;
for(int i=0;i<20;i++){
float h=scene(from+L*s);
light=min(light,9.*h/s);
if(h<.0008||s>gap)break;
s+=clamp(h,.012,.1);
}
light=clamp(light,0.,1.);
float fall=1.25/(1.+gap*gap*.5);
vec3 H=normalize(L-d);
vec3 c;
if(kind<.5){
float grain=plaster(p.xy);
vec2 slope=vec2(plaster(p.xy+vec2(.004,0.))-grain,plaster(p.xy+vec2(0.,.004))-grain);
vec3 m=normalize(vec3(-slope*.8,1.));
float near=smoothstep(0.,reach*.2,dist);
c=back*(.55+.9*grain)*(max(dot(m,L),0.)*light*fall*2.1+.05)*(.5+.5*near);
}else if(kind<1.5){
float rough=noise(p.xy*90.)*.04;
c=face*(max(dot(n,L),0.)*light*fall*2.2+.13);
c+=vec3(1.,.95,.86)*(pow(max(dot(n,H),0.),70.)*.5+rough*.1)*light*fall;
}else{
vec3 r=reflect(d,n);
float sky=.5+.5*r.y;
c=edge*(.14+.32*sky*sky+.3*pow(1.-max(dot(-d,n),0.),3.));
c+=edge*max(dot(n,L),0.)*light*fall*1.3;
c+=(edge+.35)*pow(max(dot(n,H),0.),26.)*light*fall*2.8;
}
c=1.-exp(-c*1.5);
gl_FragColor=vec4(pow(c,vec3(.72)),1.);
}`;
function program(gl: WebGLRenderingContext) {
const made = gl.createProgram();
for (const [type, text] of [[gl.VERTEX_SHADER, VERTEX], [gl.FRAGMENT_SHADER, FRAGMENT]] as const) {
const shader = gl.createShader(type);
if (!made || !shader) return null;
gl.shaderSource(shader, text);
gl.compileShader(shader);
gl.attachShader(made, shader);
}
if (!made) return null;
gl.linkProgram(made);
return gl.getProgramParameter(made, gl.LINK_STATUS) ? made : null;
}
function sweep(grid: Float64Array, start: number, stride: number, size: number, f: Float64Array, v: Uint16Array, z: Float64Array) {
v[0] = 0;
z[0] = -1e20;
z[1] = 1e20;
f[0] = grid[start];
for (let q = 1, k = 0; q < size; q++) {
f[q] = grid[start + q * stride];
const q2 = q * q;
let s = 0;
do {
const r = v[k];
s = (f[q] - f[r] + q2 - r * r) / (q - r) / 2;
} while (s <= z[k] && --k > -1);
k++;
v[k] = q;
z[k] = s;
z[k + 1] = 1e20;
}
for (let q = 0, k = 0; q < size; q++) {
while (z[k + 1] < q) k++;
const r = v[k];
grid[start + q * stride] = f[r] + (q - r) * (q - r);
}
}
function distance(grid: Float64Array, w: number, h: number) {
const size = Math.max(w, h);
const f = new Float64Array(size);
const v = new Uint16Array(size);
const z = new Float64Array(size + 1);
for (let x = 0; x < w; x++) sweep(grid, x, w, h, f, v, z);
for (let y = 0; y < h; y++) sweep(grid, y * w, 1, w, f, v, z);
}
/* Draws the word on a hidden canvas, then keeps each pixel's distance to the nearest letter edge in one byte. */
function bake(text: string, font: string) {
const paper = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
if (!paper) return null;
paper.font = `900 200px ${font}`;
const size = Math.min(320, (200 * (1024 - PAD * 2)) / paper.measureText(text).width);
paper.font = `900 ${size}px ${font}`;
const w = Math.ceil(paper.measureText(text).width) + PAD * 2;
const h = Math.ceil(size * 1.05) + PAD * 2;
paper.canvas.width = w;
paper.canvas.height = h;
paper.font = `900 ${size}px ${font}`;
paper.textAlign = "center";
paper.textBaseline = "middle";
paper.fillText(text, w / 2, h / 2 + size * 0.05);
const ink = paper.getImageData(0, 0, w, h).data;
const outer = new Float64Array(w * h);
const inner = new Float64Array(w * h);
for (let i = 0; i < w * h; i++) {
const a = ink[i * 4 + 3] / 255;
outer[i] = a === 1 ? 0 : a === 0 ? 1e20 : Math.max(0, 0.5 - a) ** 2;
inner[i] = a === 1 ? 1e20 : a === 0 ? 0 : Math.max(0, a - 0.5) ** 2;
}
distance(outer, w, h);
distance(inner, w, h);
const bytes = new Uint8Array(w * h);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = y * w + x;
const d = Math.sqrt(outer[i]) - Math.sqrt(inner[i]);
bytes[(h - 1 - y) * w + x] = Math.max(0, Math.min(255, Math.round((d / (REACH * 2) + 0.5) * 255)));
}
}
return { bytes, w, h, size };
}
const rgb = (hex: string) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
/* Typography3D: heavy enamel letters with metal edges fixed to a plaster wall. One lamp walks past and the shadows swing with it; the mouse or arrow keys carry the lamp. Runs only while on screen. */
export function Typography3D({
text = "FORGE",
color = "#efe3c8",
metal = "#c8933c",
wall = "#143a33",
font = "'Arial Black', Impact, system-ui, sans-serif",
speed = 1,
className = "aspect-[16/9] w-[28rem]",
}: Typography3DProps) {
const reduce = useReducedMotion();
const boxRef = useRef<HTMLDivElement>(null);
const plainRef = useRef<HTMLDivElement>(null);
const hues = [color, metal, wall].join();
useEffect(() => {
const box = boxRef.current;
const plain = plainRef.current;
if (!box || !plain) return;
const canvas = document.createElement("canvas");
canvas.setAttribute("aria-hidden", "true");
canvas.style.cssText = "position:absolute;inset:0;width:100%;height:100%";
const gl = canvas.getContext("webgl", { alpha: false, antialias: false });
const made = gl && program(gl);
if (!gl || !made) return;
gl.useProgram(made);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
const corner = gl.getAttribLocation(made, "p");
gl.enableVertexAttribArray(corner);
gl.vertexAttribPointer(corner, 2, gl.FLOAT, false, 0, 0);
const at = (name: string) => gl.getUniformLocation(made, name);
const [one, two, three] = hues.split(",").map(rgb);
gl.uniform3fv(at("face"), one);
gl.uniform3fv(at("edge"), two);
gl.uniform3fv(at("back"), three);
gl.bindTexture(gl.TEXTURE_2D, gl.createTexture());
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_ALIGNMENT, 1);
let baked = bake(text, font);
const upload = () => {
if (baked) gl.texImage2D(gl.TEXTURE_2D, 0, gl.LUMINANCE, baked.w, baked.h, 0, gl.LUMINANCE, gl.UNSIGNED_BYTE, baked.bytes);
};
upload();
let raf = 0;
let last = 0;
let inView = false;
let time = 0;
let aspect = 1;
let lampX = -0.9;
let lampY = 0.55;
let eyeX = 0;
let eyeY = 0;
let goalX = 0;
let goalY = 0;
let pointed = false;
const draw = () => {
gl.uniform3f(at("lamp"), lampX, lampY, 0.62);
gl.uniform2f(at("eye"), eyeX, eyeY - 0.16);
gl.drawArrays(gl.TRIANGLES, 0, 3);
};
const scale = () => {
if (!baked) return;
const px = Math.min((2 * aspect * 0.9) / (baked.w - PAD * 2), (2 * 0.68) / (baked.h - PAD * 2));
const tall = baked.size * px * 0.15;
gl.uniform2f(at("span"), baked.w * px, baked.h * px);
gl.uniform1f(at("reach"), REACH * 2 * px);
gl.uniform1f(at("tall"), tall);
gl.uniform1f(at("bevel"), tall * 0.42);
gl.uniform1f(at("step2"), px * 3);
};
const frame = (now: number) => {
const dt = Math.min(0.05, (now - last) / 1000 || 0);
last = now;
time += dt * speed;
if (!pointed) {
goalX = Math.sin(time * 0.42) * aspect * 0.95;
goalY = 0.5 + Math.cos(time * 0.27) * 0.42;
}
lampX += (goalX - lampX) * Math.min(1, dt * 3.6);
lampY += (goalY - lampY) * Math.min(1, dt * 3.6);
eyeX += (lampX * 0.1 - eyeX) * Math.min(1, dt * 2);
eyeY += (lampY * 0.06 - eyeY) * Math.min(1, dt * 2);
draw();
raf = inView ? requestAnimationFrame(frame) : 0;
};
const start = () => {
if (inView && !raf && !reduce) raf = requestAnimationFrame(frame);
};
const fit = () => {
const { width, height } = box.getBoundingClientRect();
if (!width || !height) return;
const ratio = Math.min(Math.max(window.devicePixelRatio, 1.5), 2, Math.sqrt(MAX_PIXELS / (width * height)));
canvas.width = Math.round(width * ratio);
canvas.height = Math.round(height * ratio);
aspect = width / height;
gl.viewport(0, 0, canvas.width, canvas.height);
gl.uniform2f(at("res"), canvas.width, canvas.height);
scale();
draw();
};
const place = (x: number, y: number) => {
goalX = Math.max(-aspect * 1.15, Math.min(aspect * 1.15, x));
goalY = Math.max(-1.15, Math.min(1.15, y));
if (reduce) {
lampX = goalX;
lampY = goalY;
draw();
}
};
const point = (e: PointerEvent) => {
if (e.pointerType !== "mouse") return;
const r = box.getBoundingClientRect();
pointed = true;
place(((e.clientX - r.left) / r.width - 0.5) * 2 * aspect, (0.5 - (e.clientY - r.top) / r.height) * 2);
};
const leave = () => {
pointed = false;
};
const key = (e: KeyboardEvent) => {
const x = { ArrowLeft: -1, ArrowRight: 1 }[e.key] ?? 0;
const y = { ArrowDown: -1, ArrowUp: 1 }[e.key] ?? 0;
if (!x && !y) return;
e.preventDefault();
pointed = true;
place(goalX + x * 0.22, goalY + y * 0.22);
};
fit();
box.append(canvas);
plain.hidden = true;
let alive = true;
document.fonts?.ready.then(() => {
if (!alive) return;
baked = bake(text, font);
upload();
scale();
draw();
});
const sized = new ResizeObserver(fit);
sized.observe(box);
const watch = new IntersectionObserver(([entry]) => {
inView = entry.isIntersecting;
start();
});
watch.observe(box);
const lost = () => {
canvas.remove();
plain.hidden = false;
};
canvas.addEventListener("webglcontextlost", lost);
box.addEventListener("pointermove", point);
box.addEventListener("pointerleave", leave);
box.addEventListener("blur", leave);
box.addEventListener("keydown", key);
return () => {
alive = false;
cancelAnimationFrame(raf);
sized.disconnect();
watch.disconnect();
canvas.removeEventListener("webglcontextlost", lost);
box.removeEventListener("pointermove", point);
box.removeEventListener("pointerleave", leave);
box.removeEventListener("blur", leave);
box.removeEventListener("keydown", key);
gl.getExtension("WEBGL_lose_context")?.loseContext();
canvas.remove();
plain.hidden = false;
};
}, [text, font, hues, speed, reduce]);
return (
<div
ref={boxRef}
role="application"
tabIndex={0}
aria-label={`${text}. Arrow keys move the lamp.`}
className={`relative overflow-hidden [container-type:size] ${className}`}
style={{ background: wall }}
>
<div
ref={plainRef}
aria-hidden="true"
className="absolute inset-0 grid place-items-center font-black"
style={{ color, fontFamily: font, fontSize: "20cqw", textShadow: `0.03em 0.03em 0 ${metal}, 0.06em 0.06em 0 ${metal}` }}
>
{text}
</div>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/3d-typography.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/3d-typography-demo.tsx
import { Typography3D } from "@/components/ui/3d-typography"; export default function Typography3DExample() { return <Typography3D className="size-full min-h-[260px]" />; }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| text | string | "FORGE" | The word on the wall. One line. |
| color | string | "#efe3c8" | Six-digit hex colour of the enamel faces. |
| metal | string | "#c8933c" | Six-digit hex colour of the metal edges. |
| wall | string | "#143a33" | Six-digit hex colour of the wall. |
| font | string | "Arial Black, Impact, ..." | CSS font family. A heavy font works best. |
| speed | number | 1 | 1 is the default pace; 2 is twice as fast. |
| className | string | "aspect-[16/9] w-[28rem]" | Set the size here. |