Pixelated Text Hover Shaders — full-viewport wordmark
Goal
Build a full-viewport white hero showing one huge, thin lowercase wordmark centered on screen. The wordmark is not DOM text — it is painted to an offscreen 2D canvas and used as a texture on a full-screen Three.js plane rendered through a custom fragment shader. The star effect: as the cursor moves across the wordmark, the shader quantizes the image into a 40×40 grid of square cells and, in a soft radius around the cursor, shoves each cell's texture sample in the direction the mouse is travelling — producing a chunky, blocky, pixelated "smear" that ripples off the cursor and eases back to rest when the mouse stops or leaves. All motion is a hand-written requestAnimationFrame lerp feeding two shader uniforms; there is no GSAP and no scroll.
Tech
Vanilla HTML/CSS/JS with an ES-module entry (<script type="module">). No animation library at all — no GSAP, no Lenis. The only dependency is Three.js. Import it as:
import * as THREE from "three";
The whole effect is: one WebGL ShaderMaterial on a 2×2 plane under an orthographic camera, a canvas-generated text texture, and a raw rAF loop that eases a mouse position each frame and writes it to the shader. Runs in a fresh Vite project with three as the only npm dep.
Layout / HTML
The body is essentially empty — a single container the renderer's <canvas> is appended into:
<body>
<div id="textContainer"></div>
<script type="module" src="./script.js"></script>
</body>
#textContaineris the mount point. JS doesdocument.getElementById("textContainer"), builds the Three.js renderer, and appendsrenderer.domElementinto it.- No headings, no images, no other markup. Everything visible is WebGL.
Styling
Font: import a thin display face at the top of the CSS. The original uses Blanquotey (a very light, elegant display font):
@import url("https://fonts.cdnfonts.com/css/blanquotey");
body, html:margin:0; padding:0; width:100%; height:100%; overflow:hidden; background-color:#ffffff; font-family:"Blanquotey", sans-serif;#textContainer:position:absolute; width:100%; height:100%; overflow:hidden;canvas:position:absolute; top:45%; left:50%; transform:translate(-50%,-50%); display:block; width:100%; height:100%;— note the canvas is centered at 45% vertically (slightly above true center), not 50%.
Palette is deliberately minimal:
- Page background: white
#ffffff. - Text fill + stroke color (inside the canvas texture): near-black
#1a1a1a. - Renderer clear color: white
0xffffff.
There are no CSS-styled type elements — the only "typography" is the wordmark drawn onto the offscreen canvas (see below).
The wordmark texture (createTextTexture — reproduce exactly)
A helper builds a THREE.CanvasTexture from a 2D canvas. This is what the shader samples.
createTextTexture(text, font, size, color, fontWeight = "100"):
- Create a
<canvas>sized to twice the viewport:canvasWidth = window.innerWidth * 2,canvasHeight = window.innerHeight * 2. - Fill the whole canvas with the background color (
color, passed as"#ffffff") viafillRect. fontSize = size || Math.floor(canvasWidth * 2)— deliberately gigantic; it gets scaled down in step 6.- Set text style:
ctx.fillStyle = "#1a1a1a",ctx.font = \${fontWeight} ${fontSize}px "${font}"\`(fontWeight"100", font"Blanquotey"),textAlign:"center",textBaseline:"middle",imageSmoothingEnabled = true,imageSmoothingQuality = "high"`. - Measure the text:
textWidth = ctx.measureText(text).width. - Compute a fit transform:
scaleFactor = Math.min(1, (canvasWidth * 1) / textWidth)— shrink the huge glyphs so the word spans the canvas width.aspectCorrection = canvasWidth / canvasHeight(= viewport aspect ratio).ctx.setTransform(scaleFactor, 0, 0, scaleFactor / aspectCorrection, canvasWidth/2, canvasHeight/2)— horizontal scalescaleFactor, vertical scalescaleFactor / aspectCorrection(squashes vertically to compensate for the wide viewport), origin recentered to the canvas middle.- Thicken the thin weight-100 glyphs by stroking before filling:
ctx.strokeStyle = "#1a1a1a",ctx.lineWidth = fontSize * 0.005, thenstrokeText(text, 0, 0)three times in a loop, thenctx.fillText(text, 0, 0). return new THREE.CanvasTexture(canvas);
The demo word is a short lowercase wordmark (~5 letters). Use a neutral placeholder such as lumen — any short lowercase word works; do not use a real brand name.
Scene setup (initializeScene)
scene = new THREE.Scene();aspectRatio = window.innerWidth / window.innerHeight;- Orthographic camera (full-screen quad rig):
new THREE.OrthographicCamera(-1, 1, 1/aspectRatio, -1/aspectRatio, 0.1, 1000);camera.position.z = 1. (Left/right are fixed at ±1; top/bottom are ±1/aspectRatio, so on a landscape viewport the 2×2 plane overflows top and bottom and is cropped.) - Uniforms:
``js { u_mouse: { type: "v2", value: new THREE.Vector2() }, u_prevMouse: { type: "v2", value: new THREE.Vector2() }, u_texture: { type: "t", value: texture }, } ``
planeMesh = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), new THREE.ShaderMaterial({ uniforms, vertexShader, fragmentShader }));scene.add(planeMesh).renderer = new THREE.WebGLRenderer({ antialias: true });renderer.setClearColor(0xffffff, 1);renderer.setSize(window.innerWidth, window.innerHeight);renderer.setPixelRatio(window.devicePixelRatio); appendrenderer.domElementinto#textContainer.
Initialize once at load: initializeScene(createTextTexture("lumen", "Blanquotey", null, "#ffffff", "100")).
The shaders (be exhaustive — this is the whole effect)
Vertex shader (passthrough, exposes UVs):
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
Fragment shader — the pixelated cursor smear. Reproduce every constant:
varying vec2 vUv;
uniform sampler2D u_texture;
uniform vec2 u_mouse;
uniform vec2 u_prevMouse;
void main() {
// 1) Quantize UV space into a 40x40 grid of cells
vec2 gridUV = floor(vUv * vec2(40.0, 40.0)) / vec2(40.0, 40.0);
vec2 centerOfPixel = gridUV + vec2(1.0/40.0, 1.0/40.0);
// 2) Direction the mouse is currently travelling (this frame's delta)
vec2 mouseDirection = u_mouse - u_prevMouse;
// 3) Per-cell falloff around the cursor
vec2 pixelToMouseDirection = centerOfPixel - u_mouse;
float pixelDistanceToMouse = length(pixelToMouseDirection);
float strength = smoothstep(0.3, 0.0, pixelDistanceToMouse); // 1 at cursor, 0 beyond 0.3 UV
// 4) Displace the sample, quantized per cell -> blocky smear
vec2 uvOffset = strength * -mouseDirection * 0.4;
vec2 uv = vUv - uvOffset;
vec4 color = texture2D(u_texture, uv);
gl_FragColor = color;
}
Why it looks pixelated (the critical mechanism): strength and the offset are derived from centerOfPixel, which is constant across all fragments inside one 40×40 cell (it is floor()-quantized). So every fragment in a given cell shares the exact same uvOffset, and the whole cell shifts as one block. The smoothstep(0.3, 0.0, dist) makes cells within ~0.3 UV of the cursor displace strongest and fade to zero at the edge of that radius. The offset magnitude is mouseDirection * 0.4 — proportional to how fast the mouse is moving — so a fast sweep smears hard, a slow crawl barely nudges, and a stationary mouse (mouseDirection ≈ 0) leaves the text pristine. Multiplying by -mouseDirection pushes the sampled texture *along* the cursor's travel, so the wordmark appears to be dragged and torn in the direction of motion.
Motion / eased mouse (rAF loop — no GSAP)
State (module scope):
let easeFactor = 0.02;
let mousePosition = { x: 0.5, y: 0.5 };
let targetMousePosition = { x: 0.5, y: 0.5 };
let prevPosition = { x: 0.5, y: 0.5 };
animateScene() runs every frame via requestAnimationFrame:
- Lerp the live mouse toward the target with the current ease factor:
mousePosition.x += (targetMousePosition.x - mousePosition.x) * easeFactor; (same for .y).
- Write to the shader with Y flipped (canvas Y-down → GL Y-up):
u_mouse.value.set(mousePosition.x, 1.0 - mousePosition.y); u_prevMouse.value.set(prevPosition.x, 1.0 - prevPosition.y);
renderer.render(scene, camera);
Because u_prevMouse holds the *previous* target and u_mouse chases it with a small ease factor, mouseDirection = u_mouse - u_prevMouse in the shader is a smoothly decaying velocity vector — the smear trails behind the cursor and relaxes to zero when input stops.
Input handlers (attached to #textContainer)
Coordinates are normalized against the container's getBoundingClientRect(): x = (clientX - rect.left)/rect.width, y = (clientY - rect.top)/rect.height.
mousemove:easeFactor = 0.035;thenprevPosition = { ...targetMousePosition };(snapshot the old target), then set the newtargetMousePosition.x/yfrom the normalized cursor. The higher ease (0.035) makes the smear responsive while moving.mouseenter:easeFactor = 0.01;and snap both live and target to the entry point:mousePosition.x = targetMousePosition.x = normalizedX;(same for.y). Prevents a whip from wherever the cursor last was.mouseleave:easeFactor = 0.01;andtargetMousePosition = { ...prevPosition };— retarget to the last-but-one position so the wordmark eases back and settles instead of freezing mid-smear.
Exact ease-factor values matter: 0.02 at rest/init, 0.035 while actively moving (snappier), 0.01 on enter/leave (gentle settle).
Resize (onWindowResize)
On window resize:
- Recompute
aspectRatio = innerWidth / innerHeight; setcamera.left=-1; camera.right=1; camera.top=1/aspectRatio; camera.bottom=-1/aspectRatio; camera.updateProjectionMatrix(); renderer.setSize(innerWidth, innerHeight);- Rebuild the texture at the new size and reassign it: recreate via
createTextTexture(...)and setplaneMesh.material.uniforms.u_texture.value = newTexture;(keeps the wordmark crisp after a resize).
Assets / images
None. There are no image files. The only visual is the wordmark, which is generated at runtime by drawing text onto an offscreen 2D canvas and uploading it as a THREE.CanvasTexture. Use a neutral lowercase placeholder word (e.g. lumen); no logos or brand names.
Behavior notes
- The scene renders continuously from load (the rAF loop never stops), but with no cursor movement the wordmark is perfectly still and sharp — the distortion only appears where and while the mouse moves over it.
- Pointer-only: the effect is driven by
mousemove/mouseenter/mouseleave. Touch devices don't fire these, so the wordmark just sits static; treat this as a desktop-first, GPU-heavy hero (antialias:true, fulldevicePixelRatio). No reduced-motion branch in the original. - No GSAP, no ScrollTrigger, no SplitText — motion is purely the per-frame lerp of a normalized mouse position feeding
u_mouse/u_prevMouse, plus the fragment shader's per-cell displacement.