3D CRT Display
Goal
Build a full-screen hero where a 3D CRT monitor (an external .glb model) floats center-stage, its screen driven by a custom GLSL shader that fakes a real cathode-ray tube: scanlines, an RGB aperture-grille mask, a vignette, warm phosphor tint and chromatic aberration. A row of clickable project pills sits at the bottom. Hovering a pill swaps the on-screen texture and fires a GSAP-driven glitch burst — horizontal line-tearing, RGB split and static noise that spike to full and decay to zero over 0.75s. The whole monitor lazily parallax-rotates toward the mouse via a per-frame lerp. The star effect is the shader-based CRT + the glitch-on-swap.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) and three (npm). No GSAP plugins are needed — GSAP is used only for a single gsap.to() tween and for gsap.utils.interpolate(). Three.js supplies the WebGL scene and the GLTF loader. Plain Vite-style imports:
import gsap from "gsap";
import * as THREE from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
Put the two shader source strings in a sibling module shaders.js and import them: import { vertexShader, fragmentShader } from "./shaders.js";
Layout / HTML
Minimal DOM — the visual is a WebGL canvas that JS appends into the hero.
<section class="hero">
<ul class="projects">
<li data-img="/path/project-img-1.jpg">District</li>
<li data-img="/path/project-img-2.jpg">Waypoint</li>
<li data-img="/path/project-img-3.jpg">Corridor</li>
<li data-img="/path/project-img-4.jpg">Archive</li>
<li data-img="/path/project-img-5.jpg">Terminal</li>
</ul>
</section>
<script type="module" src="./script.js"></script>
.herois the WebGL container (the renderer's<canvas>is appended here)..projectsis a horizontal list of five pills. Each<li>carries adata-imgattribute pointing at the texture to display when hovered. Labels are neutral demo words (District, Waypoint, Corridor, Archive, Terminal) — invent your own if you like.
Styling
Font import: @import url("https://fonts.googleapis.com/css2?family=Geist+Mono:wght@100..900&display=swap");
Global reset: * { margin:0; padding:0; box-sizing:border-box; }
.hero:position:relative; width:100%; height:100svh; background-color:#b0b0b0; overflow:hidden;— a flat mid-grey backdrop, canvas fills it..projects:position:absolute; left:50%; bottom:4rem; transform:translateX(-50%); width:100%; display:flex; justify-content:center; gap:0.5rem; list-style:none; z-index:2;.projects li: the pill look —text-transform:uppercase; font-family:"Geist Mono", Arial, sans-serif; font-size:0.7rem; font-weight:450; color:#000; width:max-content; padding:0.5rem 1rem; background-color:#fff; border:1px solid #000; box-shadow:4px 4px 0px -1px rgba(0,0,0,1); cursor:pointer;— white chip, 1px black border, hard offset drop shadow (neo-brutalist sticker)..projects li:hover:color:#fff; background-color:#000;(invert to black on hover).- Responsive:
@media (max-width:1000px) { .projects { flex-wrap:wrap; padding:0 4rem; } }
Three.js scene setup (the stage the shader lives on)
All inside DOMContentLoaded.
Scene / camera / renderer:
scene = new THREE.Scene();camera = new THREE.PerspectiveCamera(30, innerWidth/innerHeight, 0.1, 1000);thencamera.position.set(0, 0.15, 1);andcamera.lookAt(0,0,0);renderer = new THREE.WebGLRenderer({ antialias:true, alpha:true });renderer.setSize(innerWidth, innerHeight);renderer.setPixelRatio(Math.min(devicePixelRatio, 2));renderer.toneMapping = THREE.ACESFilmicToneMapping;renderer.toneMappingExposure = 1.25;thenhero.appendChild(renderer.domElement);- After the animate loop starts, override zoom for small screens:
camera.position.z = Math.max(1, 768/innerWidth);(pushes the camera back on narrow viewports so the monitor still fits).
Lights (bright, so the ACES tone-map has range to compress):
new THREE.AmbientLight(0xffffff, 5)const dirLight = new THREE.DirectionalLight(0xffffff, 2.5); dirLight.position.set(15, 10, -5);const topLight = new THREE.PointLight(0xffffff, 5, 10); topLight.position.set(-5, -2.5, 0); topLight.decay = 0.3;
Monitor model:
const monitorGroup = new THREE.Group(); scene.add(monitorGroup);new GLTFLoader().load("/path/monitor.glb", (gltf) => { ... })— inside the callback, compute the model's bounding-box center withnew THREE.Box3().setFromObject(model).getCenter(...), subtract it frommodel.positionto recenter on the origin, thenmonitorGroup.add(model);.
Screen plane geometry — a rounded-rectangle plane with custom UVs so the shader can sample edge-to-edge. Write a helper createScreenGeometry(w, h, r):
- Build a
THREE.Shape()as a rounded rect of widthw, heighth, corner radiusr, centered on origin (usemoveTo/lineTo/quadraticCurveTofor the four rounded corners). const geometry = new THREE.ShapeGeometry(shape);- Recompute UVs manually: for each position,
u = (x - xMin)/w,v = (y - yMin)/h, and set them as a newuvBufferAttribute (ShapeGeometry's default UVs are in world units, not 0–1 — this normalization is required for the shader). - Call it as
createScreenGeometry(1, 1, 0.03).
Screen mesh + material:
const displayMaterial = new THREE.ShaderMaterial({
uniforms: {
map: { value: defaultTexture },
imageAspect: { value: 1 },
planeAspect: { value: 0.28 / 0.235 }, // ≈ 1.191
iResolution: { value: new THREE.Vector2(512, 512) },
glitchIntensity:{ value: 0.0 },
time: { value: 0.0 },
},
vertexShader,
fragmentShader,
});
const displayPlane = new THREE.Mesh(createScreenGeometry(1, 1, 0.03), displayMaterial);
displayPlane.scale.set(0.28, 0.235, 1);
displayPlane.position.set(-0.008, 0.005, 0.041); // nudge it into the bezel opening
displayPlane.rotation.set(-0.18, 0, 0); // slight backward tilt to match the CRT face
monitorGroup.add(displayPlane);
Texture loader — a small cache keyed by src:
function loadTexture(src) {
if (cache[src]) return cache[src];
const texture = textureLoader.load(src, () => {
displayMaterial.uniforms.imageAspect.value = texture.image.width / texture.image.height;
});
texture.colorSpace = THREE.SRGBColorSpace;
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
cache[src] = texture;
return texture;
}
Load default.jpg up front as the idle screen: const defaultTexture = loadTexture("/path/default.jpg");
Shaders (shaders.js)
Vertex shader — passthrough that forwards UVs:
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
Fragment shader — this is the CRT + glitch. Uniforms: sampler2D map; float imageAspect, planeAspect, glitchIntensity, time; vec2 iResolution; varying vec2 vUv;
Helpers:
float hash(float n) { return fract(sin(n) * 43758.5453123); }
// cover-fit: rescale uv so the image fills the plane without stretching (like CSS object-fit: cover)
vec2 coverUV(vec2 uv) {
if (planeAspect > imageAspect) {
float s = imageAspect / planeAspect;
uv.y = uv.y * s + (1.0 - s) * 0.5;
} else {
float s = planeAspect / imageAspect;
uv.x = uv.x * s + (1.0 - s) * 0.5;
}
return uv;
}
Main, in order (let gi = glitchIntensity, uv = vUv):
- Horizontal line-tear (glitch):
uv.x += (hash(floor(uv.y*20.0 + time*80.0) + time*7.0) - 0.5) * 2.0 * gi * 0.15;— per-scanline random x-shift, animated fast, scaled by glitch. - Vertical jitter (glitch):
uv.y += (hash(floor(time*50.0)) - 0.5) * gi * 0.06; - Chromatic aberration / RGB split:
float rs = 0.001 + gi*0.025;(always a hair of split, more during glitch). Sample each channel from a different offset ofcoverUV, and add a 0.05 lift: col.r = texture2D(map, coverUV(vec2(uv.x + rs, uv.y + rs))).r + 0.05;col.g = texture2D(map, coverUV(vec2(uv.x, uv.y - rs*2.0))).g + 0.05;col.b = texture2D(map, coverUV(vec2(uv.x - rs*2.0, uv.y))).b + 0.05;- Ghost smear — add faint fixed-offset re-samples per channel:
col.r += 0.08 * texture2D(map, coverUV(vec2(uv.x + 0.026, uv.y - 0.026))).r;col.g += 0.05 * texture2D(map, coverUV(vec2(uv.x - 0.022, uv.y - 0.022))).g;col.b += 0.08 * texture2D(map, coverUV(vec2(uv.x - 0.022, uv.y - 0.018))).b; - Soft contrast curve:
col = clamp(col*0.93 + 0.07*col*col, 0.0, 1.0); - Vignette (barrel-corner darkening):
col *= vec3(pow(16.0*uv.x*uv.y*(1.0-uv.x)*(1.0-uv.y), 0.12)); - Phosphor tint + boost:
col *= vec3(0.95, 1.05, 0.95) * 2.5;(greenish CRT cast, 2.5× gain that the tone-map reins back in). - Horizontal scanlines:
col *= vec3(0.6 + 0.4*pow(clamp(0.35 + 0.35*sin(uv.y*iResolution.y*1.5), 0.0, 1.0), 1.2)); - Vertical RGB aperture-grille mask:
col *= 1.0 - 0.65*vec3(clamp((mod(vUv.x*iResolution.x, 2.0) - 1.0)*2.0, 0.0, 1.0));(darkens every other column → the pixel-grid shimmer). - Static noise (glitch):
col += vec3(hash(uv.x*100.0 + uv.y*1000.0 + time*300.0) * gi * 0.3); gl_FragColor = vec4(col, 1.0);
Steps 1, 2, 10 and the gi term in rs are the only parts gated by glitchIntensity; steps 5–9 are the always-on CRT look.
GSAP effect (the important part — be exact)
1) Continuous render loop with mouse-parallax lerp
A requestAnimationFrame loop drives both the shader clock and the monitor's lazy rotation. Use a THREE.Timer for elapsed time and gsap.utils.interpolate for the smoothing:
const mouse = { x: 0, y: 0 };
const lerpedMouse = { x: 0, y: 0 };
const timer = new THREE.Timer();
function animate() {
requestAnimationFrame(animate);
timer.update();
displayMaterial.uniforms.time.value = timer.getElapsed();
// ease the tracked mouse toward the target by 5% each frame
lerpedMouse.x = gsap.utils.interpolate(lerpedMouse.x, mouse.x, 0.05);
lerpedMouse.y = gsap.utils.interpolate(lerpedMouse.y, mouse.y, 0.05);
monitorGroup.rotation.x = lerpedMouse.y * 0.15;
monitorGroup.rotation.y = lerpedMouse.x * 0.3;
renderer.render(scene, camera);
}
animate();
- Lerp factor
0.05→ heavy inertia; the monitor drifts toward the cursor over ~1s, never snaps. - Mouse mapping (in the
mousemovelistener):mouse.x = (e.clientX/innerWidth - 0.5) * 10;andmouse.y = (e.clientY/innerHeight - 0.5) * 5;— so raw range is ±5 (x) / ±2.5 (y). - Applied rotation range:
rotation.y≈ ±1.5 rad max theoretically but clamped by real cursor travel; the*0.3(yaw) and*0.15(pitch) multipliers keep it a gentle tilt, yaw twice as strong as pitch.
2) Glitch burst on texture swap (the GSAP tween)
There is one GSAP tween in the whole component. When the displayed image changes, spike a plain-object glitchState.intensity to 1 and animate it back to 0, piping the value into the glitchIntensity uniform every frame:
const glitchState = { intensity: 0 };
let glitchAnimation = null;
function setDisplayImage(src) {
const texture = loadTexture(src);
displayMaterial.uniforms.map.value = texture;
if (glitchAnimation) glitchAnimation.kill(); // restart cleanly if hovering fast
glitchState.intensity = 1.0; // spike to full immediately
glitchAnimation = gsap.to(glitchState, {
intensity: 0,
duration: 0.75,
ease: "power3.out",
onUpdate() {
displayMaterial.uniforms.glitchIntensity.value = glitchState.intensity;
},
});
// keep imageAspect correct once the new texture has decoded
const updateAspect = () => {
displayMaterial.uniforms.imageAspect.value = texture.image.width / texture.image.height;
};
texture.image ? updateAspect() : texture.addEventListener("load", updateAspect);
}
- Target:
glitchState.intensity1 → 0. duration: 0.75,ease: "power3.out"→ violent at the instant of swap, then a fast tail-off, most of the tear/noise gone in the first ~0.3s.- No delay, no stagger, no timeline — a fresh tween per swap.
glitchAnimation.kill()before re-spiking prevents overlapping tweens when the user sweeps across pills quickly.
3) Triggers (hover)
document.querySelectorAll(".projects li").forEach((li) => {
li.addEventListener("mouseenter", () => {
const img = li.getAttribute("data-img");
if (img) setDisplayImage(img);
});
});
document.querySelector(".projects").addEventListener("mouseleave", () => {
setDisplayImage(defaultDisplayImg); // revert to the idle screen when the cursor leaves the whole list
});
mouseenteron each pill → swap to that pill'sdata-img+ glitch burst.mouseleaveon the.projectsUL (not the individual li) → swap back to the default idle texture + glitch burst.
Resize handler
window.addEventListener("resize", () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
Assets / images
- 1 GLB model — a stylized 3D CRT/tube television or vintage computer monitor with a chunky bezel and a recessed screen opening. Any low-poly retro monitor model works; the code recenters it automatically. The screen plane is positioned/tilted (
rotation.x = -0.18) to sit inside the bezel face, so pick a model whose screen faces roughly +Z. - 6 screen texture files, all landscape 1200×857 px (~7:5, ≈1.40:1). They're cover-fit onto a 0.28×0.235 plane, so exact aspect isn't critical — the shader crops.
- 1 idle/default texture (
default.jpg) — a flat, fully saturated electric-blue field filling the frame, with a white two-line pixel/bitmap-font wordmark centered on it (placeholder text). Shown on load and whenever the cursor leaves the pill row. Use a neutral wordmark, no real brand. - 5 project textures (
project-img-1..5.jpg), one per pill in list order — a cohesive set of moody, cinematic cyberpunk / retro-futuristic night scenes all dominated by violet–purple haze and magenta/pink neon with warm amber–orange accents, fog and wet reflective ground: - *img-1* — a rainy neon downtown street at night: a boxy retro sedan with lit headlights heading toward the camera, crowds on the sidewalks, glowing storefront signage, dense purple haze.
- *img-2* — a foggy plaza where two helmeted figures flank a tall glowing orange-red circuit-etched monolith; dark high-rises and a lit neon-ringed transit pod behind, heavy violet mist and light-streaked wet road.
- *img-3* — a tree-lined boulevard seen through overhanging branches, pink/purple neon throughout: rounded futuristic cars with glowing ring headlights on wet cobblestones, rows of glowing lamp-posts, a neon-crowned tower catching a pink sky in the distance.
- *img-4* — a lone silhouetted figure facing a large glowing orange/pink neon shop window full of chrome machinery, set against a deep-purple foggy cyberpunk cityscape with wet pavement.
- *img-5* — a second rainy neon street scene, wider and differently framed than *img-1*: a boxy retro sedan lower-left with headlights on, an overhead traffic signal, a brightly lit magenta-fronted storefront at right, pedestrians crossing, thick violet fog. (Similar mood and subject to *img-1* but a distinct image.)
- Each texture is just the on-screen picture; described generically by subject and color, no brands. The five project scenes share one palette so any swap glitch keeps the CRT looking cohesive.
Behavior notes
- Desktop-first, mouse-driven. The parallax rotation and the pill hovers assume a pointer; on touch, the monitor simply sits still at its idle image (nothing autoplays besides the always-running shader clock, which animates scanline shimmer subtly via
time). - The shader
timeuniform runs continuously, so the CRT has a faint living shimmer even at rest; the *glitch* only appears on swap. - On narrow viewports the camera is pushed back (
camera.position.z = Math.max(1, 768/innerWidth)) and the pill row wraps (flex-wrapat ≤1000px). pixelRatiois capped at 2 for performance. No reduced-motion branch in the original.