Fractal Glass Parallax Hero — build prompt
Goal
Build a full-viewport hero for a fictional design studio called "Glassform". A single editorial portrait fills the screen, but it is not shown directly: it is rendered on a WebGL plane and refracted through a custom fragment shader that bends the image into dozens of vertical fractal-glass ribbons (like looking through fluted / reeded glass). Moving the mouse feeds a smoothed pointer position into the shader that produces a subtle horizontal parallax, amplified inside the distorted stripes so the ribbons appear to slide over each other. The star effect is the shader itself, driven by a lerped mouse uniform on a requestAnimationFrame loop.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use three (npm) only — there is no GSAP and no scroll library in this component. All motion comes from the Three.js render loop + a GLSL fragment shader. Assume a fresh Vite project; three is installed via npm and imported as import * as THREE from "three".
Split the code into three files:
index.htmlstyles.cssscript.js— the Three.js app (<script type="module" src="./script.js">)- Put the GLSL strings in a small
shaders.jsmodule that exportsvertexShaderandfragmentShader, imported byscript.js. (Inlining them inscript.jsis also fine.)
Layout / HTML
nav
.logo > a → "Ω Glassform" (use the Ω ohm sign, HTML entity Ω, before the word)
.nav-links > a ×3 → "Experiments", "Objects", "Exhibits"
section.hero
img#glassTexture → src to the hero image, alt="" (this <img> is hidden via CSS; it is only a source for the WebGL texture)
.hero-content
h1 → "Designed for the space between silence and noise."
p → "Developed by Glassform"
The Three.js renderer's <canvas> is appended to .hero by JS at runtime (it becomes a child of .hero).
Styling
- Font: Manrope (Google Fonts, variable weight
200..800).body { font-family: "Manrope", sans-serif; }. - Colors: pure black background
#000, pure white text#fff. No other colors in CSS — all color comes from the refracted image. - Global reset:
* { margin:0; padding:0; box-sizing:border-box; }. h1:font-size: 4rem; font-weight: 500; letter-spacing: -0.1rem; line-height: 1;a, p:color:#fff; text-decoration:none; font-size:0.85rem; font-weight:550; line-height:1; display:inline-block;nav:position:fixed; width:100%; padding:2rem; display:flex; justify-content:space-between; align-items:flex-start; z-index:2;nav .nav-links:display:flex; gap:0.75rem;.hero:position:relative; width:100%; height:100svh; overflow:hidden;.hero img#glassTexture:display:none;(critical — the raw<img>must never be visible; it only feeds the texture)..hero-content:position:absolute; left:0; bottom:0; width:100%; padding:2rem; display:flex; justify-content:space-between; align-items:flex-end;(it paints above the static WebGL canvas because it is positioned)..hero-content h1:width:60%;- Responsive
@media (max-width:1000px):.hero-content { align-items:flex-start; flex-direction:column-reverse; gap:1rem; }and.hero-content h1 { width:100%; }.
The effect (be exact — this is the whole component)
Three.js scene setup
scene = new THREE.Scene().camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1)— left/right/top/bottom/near/far. This maps a[-1,1]quad to the full viewport.renderer = new THREE.WebGLRenderer({ antialias: true });renderer.setSize(window.innerWidth, window.innerHeight);renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); appendrenderer.domElementto the.herocontainer.- Geometry:
new THREE.PlaneGeometry(2, 2)(fills the whole ortho space).mesh = new THREE.Mesh(geometry, material);scene.add(mesh). - Material:
THREE.ShaderMaterialwith the uniforms below plus the vertex + fragment shaders.
Config constants (exact values — they define the look)
lerpFactor: 0.035 // pointer smoothing per frame
parallaxStrength: 0.1 // base parallax amount (uParallaxStrength)
distortionMultiplier: 10 // how much the ribbons amplify parallax (uDistortionMultiplier)
glassStrength: 2.0 // displacement gain per stripe (uGlassStrength)
glassSmoothness: 0.0001 // sample spacing for the 11-tap blur (uglassSmoothness)
stripesFrequency: 35 // number of vertical glass ribbons (ustripesFrequency)
edgePadding: 0.1 // fade width at left/right edges (uEdgePadding)
Uniforms (initial values)
uTexture:nullinitially, set once the image loads.uResolution:vec2(window.innerWidth, window.innerHeight).uTextureSize:vec2(1, 1)initially, set to the image's natural pixel size on load.uMouse:vec2(0.5, 0.5)(centered).uParallaxStrength= 0.1,uDistortionMultiplier= 10,uGlassStrength= 2.0,ustripesFrequency= 35,uglassSmoothness= 0.0001,uEdgePadding= 0.1.
Texture loading
The hidden #glassTexture <img> is the source. When it is complete (or on its onload), build new THREE.Texture(imgElement), read naturalWidth/naturalHeight into uTextureSize, set texture.needsUpdate = true, and assign it to uTexture. Handle both the already-cached case and the async onload case.
Pointer smoothing (the only "animation" driver)
- Keep two objects:
mouse = {x:0.5, y:0.5}(current, smoothed) andtargetMouse = {x:0.5, y:0.5}(raw target). windowmousemove:targetMouse.x = e.clientX / innerWidth;targetMouse.y = 1.0 - e.clientY / innerHeight(note the Y flip — WebGL origin is bottom-left).lerp(a, b, f) = a + (b - a) * f.- In the render loop every frame:
mouse.x = lerp(mouse.x, targetMouse.x, 0.035), same fory, thenuMouse.value.set(mouse.x, mouse.y). This gives a slow, floaty follow (~0.035 easing factor).
Render loop
function animate(){ requestAnimationFrame(animate); /* lerp mouse + update uMouse */ renderer.render(scene, camera); } — call animate() once. Runs continuously; no start/stop trigger.
Resize
On window resize: renderer.setSize(innerWidth, innerHeight) and update uResolution to the new size. (Do not touch uTextureSize.)
Vertex shader
Pass-through: a varying vec2 vUv; vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);.
Fragment shader (the fractal-glass refraction — reproduce this math exactly)
Helpers:
getCoverUV(uv, textureSize)— emulates CSSbackground-size: cover. If eithertextureSizecomponent< 1.0, returnuvunchanged. Else:s = uResolution / textureSize; scale = max(s.x, s.y); scaledSize = textureSize * scale; offset = (uResolution - scaledSize) * 0.5; return (uv * uResolution - offset) / scaledSize;displacement(x, num_stripes, strength)→modulus = 1.0 / num_stripes; return mod(x, modulus) * strength;(a per-stripe sawtooth ramp).fractalGlass(x)— an 11-tap box blur of the sawtooth:float d = 0.0; for (int i = -5; i <= 5; i++) { d += displacement(x + float(i) * uglassSmoothness, ustripesFrequency, uGlassStrength); } d /= 11.0; return x + d;(i.e. offset X by the averaged displacement — this is what "bends" the image into ribbons).smoothEdge(x, padding)— returns 1.0 in the middle and fades the effect to 0 at the left/right edges:edge = padding; if (x < edge) return smoothstep(0.0, edge, x); else if (x > 1.0 - edge) return smoothstep(1.0, 1.0 - edge, x); return 1.0;(note the descendingsmoothstep(1.0, 1.0-edge, x)on the right side).
main() order:
vec2 uv = vUv; float originalX = uv.x;float edgeFactor = smoothEdge(originalX, uEdgePadding);float distortedX = fractalGlass(originalX);uv.x = mix(originalX, distortedX, edgeFactor);— apply the ribbon distortion, faded near edges.float distortionFactor = uv.x - originalX;— how much this pixel got bent.- Parallax:
float parallaxDirection = -sign(0.5 - uMouse.x);(mouse right of center ⇒ +1, left ⇒ −1).vec2 parallaxOffset = vec2( parallaxDirection * abs(uMouse.x - 0.5) * uParallaxStrength * (1.0 + abs(distortionFactor) * uDistortionMultiplier), 0.0 );— horizontal only, scaled by pointer distance from center and amplified where the glass is most distorted.parallaxOffset *= edgeFactor;(also fade parallax at edges).uv += parallaxOffset;vec2 coverUV = getCoverUV(uv, uTextureSize);- If any component of
coverUVis< 0.0or> 1.0,coverUV = clamp(coverUV, 0.0, 1.0);(clamp to avoid sampling outside the image). gl_FragColor = texture2D(uTexture, coverUV);
Net visual result: 35 crisp vertical glass ribbons refracting the portrait, softly faded to the true image at the far-left/far-right ~10% of the width, with the whole field sliding horizontally as the (smoothed) mouse moves off-center — the slide being strongest along the ribbon seams.
Assets / images
- 1 image sampled at a time, chosen from 4 interchangeable full-bleed source photos. Each is landscape, roughly 16:9 (about 1440×810–1080), and only ever one is wired into
#glassTexture— the others are swappable alternates that all read well through the ribbons because each has strong tonal or color contrast. No brand logos or text in any of them. - hero image (primary) — a tightly cropped editorial fashion shot: a figure's torso and bent arm in a colorful abstract brushstroke-print garment against a saturated orange backdrop; warm skin tone, multicolor cloth (teal, mustard, deep red, navy, cream).
- alternate 1 — a minimal still life: a single smooth rounded pebble/pod form nestled in a soft dimpled surface; near-monochrome cool grey-white with gentle shadow gradients.
- alternate 2 — a cinematic space scene: a lone dark human silhouette standing before a huge glowing orange sun in a deep midnight-blue starfield; very high contrast (bright orange core, dark foreground).
- alternate 3 — an abstract texture: concentric spiral grooves carved into a white plaster/clay surface; near-monochrome white and pale grey with fine dark line detail.
- Set the chosen image as the
srcof#glassTexture. It is the only asset the shader ever samples; the<img>staysdisplay:none. High-contrast subjects (the orange fashion crop and the sun silhouette especially) make the vertical glass ribbons read most clearly.
Behavior notes
- Desktop, pointer-driven. There is no click, scroll, or keyboard interaction; touch devices simply see the centered (un-parallaxed) refraction.
- The loop runs forever at rAF cadence; there is no reduced-motion branch in the original (the motion is subtle and pointer-gated — at rest the image sits still and centered).
pixelRatiois capped at 2 for performance. The canvas always matches the window size.- Cap the effect to WebGL-capable browsers; if
uTextureis still null (image not yet decoded) the plane simply renders empty/black until the texture loads.