All components

Pixelated Text Hover Shaders

WebGL animation component · Published 2026-07-27 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

A large wordmark is rendered to a 2D canvas texture and drawn on a full-screen Three.js orthographic plane. A custom fragment shader quantizes the UVs into a 40x40 pixel grid and, near the cursor, displaces the texture along the mouse's movement direction to create a rippling pixelated smear. Movement is eased frame-by-frame in JS (requestAnimationFrame lerp, no GSAP) on mousemove/enter/leave.

How it's built

Categoryhover
Techthree
Complexitypage
Performance costheavy
Mobile-safeyes

three-js webgl shader pixelate text hover cursor distortion experimental

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

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>
  • #textContainer is the mount point. JS does document.getElementById("textContainer"), builds the Three.js renderer, and appends renderer.domElement into 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"):

  1. Create a <canvas> sized to twice the viewport: canvasWidth = window.innerWidth * 2, canvasHeight = window.innerHeight * 2.
  2. Fill the whole canvas with the background color (color, passed as "#ffffff") via fillRect.
  3. fontSize = size || Math.floor(canvasWidth * 2) — deliberately gigantic; it gets scaled down in step 6.
  4. Set text style: ctx.fillStyle = "#1a1a1a", ctx.font = \${fontWeight} ${fontSize}px "${font}"\` (fontWeight "100", font "Blanquotey"), textAlign:"center", textBaseline:"middle", imageSmoothingEnabled = true, imageSmoothingQuality = "high"`.
  5. Measure the text: textWidth = ctx.measureText(text).width.
  6. Compute a fit transform:
  7. scaleFactor = Math.min(1, (canvasWidth * 1) / textWidth) — shrink the huge glyphs so the word spans the canvas width.
  8. aspectCorrection = canvasWidth / canvasHeight (= viewport aspect ratio).
  9. ctx.setTransform(scaleFactor, 0, 0, scaleFactor / aspectCorrection, canvasWidth/2, canvasHeight/2) — horizontal scale scaleFactor, vertical scale scaleFactor / aspectCorrection (squashes vertically to compensate for the wide viewport), origin recentered to the canvas middle.
  10. Thicken the thin weight-100 glyphs by stroking before filling: ctx.strokeStyle = "#1a1a1a", ctx.lineWidth = fontSize * 0.005, then strokeText(text, 0, 0) three times in a loop, then ctx.fillText(text, 0, 0).
  11. 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); append renderer.domElement into #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:

  1. Lerp the live mouse toward the target with the current ease factor:

mousePosition.x += (targetMousePosition.x - mousePosition.x) * easeFactor; (same for .y).

  1. 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);

  1. 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; then prevPosition = { ...targetMousePosition }; (snapshot the old target), then set the new targetMousePosition.x/y from 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; and targetMousePosition = { ...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; set camera.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 set planeMesh.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, full devicePixelRatio). 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.