Refractive Glass Blob

A noise-displaced icosahedron rendered as fake glass: the headline and drifting colour blobs behind it are rendered to a texture and refracted through the blob with chromatic aberration, a lime Fresnel rim and a specular hit, while it follows the pointer.

Loading

AI prompt

Recreate this effect with any AI tool

Paste into Claude, Cursor, v0, Lovable or Bolt. 1782 words of exact values — targets, eases, durations, triggers.

prompt.md · Refractive Glass Blob
### EFFECT: Refractive Glass Blob
**What it does (visual):** A full-viewport dark canvas. Behind everything, a soft vertical gradient (surface tone at the top to the page background at the bottom) carries four large, blurry colour blobs — lime, violet, ember and teal — that drift slowly in loose ellipses. Centred on that background is a two-line light-weight serif headline, "Through glass, / softly.", rendered into the same texture. In front floats an organic blob the size of roughly a third of the viewport: an icosahedron whose surface is pushed in and out by slowly flowing 3D simplex noise, so it looks like a lump of molten glass with a few large lobes. Through the blob you see the headline and colour blobs refracted — pulled sideways and magnified where the surface tilts, with the red/green/blue channels splitting slightly along steep edges (chromatic aberration). The silhouette glows with a thin white-lime Fresnel rim, and a sharp specular highlight from the upper right slides over the lobes as the blob rotates slowly (≈0.15 rad/s around Y with a gentle ±0.25 rad nod on X). The blob follows the pointer with a lazy lag (8% of the remaining distance per frame), drifting back to the centre when the pointer leaves. Mono-spaced labels sit in the corners of the DOM over the canvas.

**Tech stack:**
- Three.js r160+ (`import * as THREE from 'three'`), two custom `ShaderMaterial`s + one copy material, a `WebGLRenderTarget`, a `CanvasTexture` for the headline. No post-processing library.
- npm: `npm i three`. CDN: `<script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.170/build/three.module.js"}}</script>`.
- Fonts: a light variable serif for the headline (demo uses Fraunces 300) drawn on a 2D canvas.

**HTML structure:**
```html
<section class="glass">
  <canvas class="glass__canvas" aria-label="A blob of glass that refracts the headline behind it and follows the pointer"></canvas>
  <p class="glass__eyebrow">WebGL · Refraction</p>
  <p class="glass__caption">Move the pointer — the blob follows and bends what sits behind it.</p>
  <p class="glass__corner">Halden Glassworks · N°04</p>
</section>
```

**CSS requirements:**
- `.glass`: `position: relative; height: 100vh; min-height: 480px; overflow: hidden; background: var(--bg); color: var(--text); touch-action: none;` (so touch-drag moves the blob instead of scrolling).
- `.glass__canvas`: `position: absolute; inset: 0; width: 100%; height: 100%; display: block;`
- Labels: `position: absolute; font-family: var(--font-mono); font-size: 11px; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); pointer-events: none;` — eyebrow `top: 2rem; left: 6vw`, corner `top: 2rem; right: 6vw`, caption `left/right: 6vw; bottom: 2rem` in the sans at `clamp(.85rem, 1vw, .95rem)`, max 44ch.
- `.glass__fallback` (reduced motion / no WebGL): a static composition with the same four radial colour blobs over `linear-gradient(180deg, var(--surface), var(--bg))`, the headline as a real `<h1>` (`clamp(2.5rem, 8vw, 7.5rem)`, weight 300, `letter-spacing: -0.04em`) and a centred circle (`min(42vw, 42vh)`) with `backdrop-filter: blur(6px)` and a 1px inset ring.
- `@media (prefers-reduced-motion: reduce) { .glass__canvas { display: none } }`.

**Animation spec (exact values):**
Constants: `NOISE_STRENGTH = 0.35`, `NOISE_FREQ = 1.4`, `REFRACT = 0.12`, `FOLLOW = 0.08`.
1. **Renderer**: `new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false })`; `dpr = Math.min(devicePixelRatio, isMobile ? 1.5 : 2)` (halved in preview); `renderer.autoClear = false`. Camera `PerspectiveCamera(30, aspect, 0.1, 20)` at `z = 5`. `orthoCam = OrthographicCamera(-1, 1, 1, -1, 0, 1)` for the two full-screen quads (`PlaneGeometry(2, 2)`, vertex shader writes `gl_Position = vec4(position.xy, 0, 1)`).
2. **Background pass** → `rt = new THREE.WebGLRenderTarget(bufW, bufH, { depthBuffer: false, stencilBuffer: false, minFilter: LinearFilter, magFilter: LinearFilter })`, sized from `renderer.getDrawingBufferSize()`. Background fragment shader (uniforms `uTime, uAspect, uLight, uBg, uSurface, uAccent, tText`):
   ```glsl
   float blob(vec2 p, vec2 c, float r) { vec2 d = (p - c) * vec2(uAspect, 1.0); return exp(-dot(d, d) / (r * r)); }
   void main() {
     vec3 col = mix(uSurface, uBg, smoothstep(0.0, 1.0, vUv.y));
     float t = uTime * 0.18;
     vec2 c1 = vec2(0.28 + 0.10 * sin(t * 1.1), 0.62 + 0.08 * cos(t * 0.9));
     vec2 c2 = vec2(0.72 + 0.09 * cos(t * 0.8 + 1.7), 0.35 + 0.10 * sin(t * 1.3 + 0.4));
     vec2 c3 = vec2(0.55 + 0.12 * sin(t * 0.7 + 3.1), 0.78 + 0.06 * sin(t * 1.0 + 2.0));
     vec2 c4 = vec2(0.18 + 0.08 * cos(t * 1.2 + 4.2), 0.22 + 0.09 * sin(t * 0.6 + 1.1));
     float k = mix(0.55, 0.32, uLight);
     col += uAccent            * blob(vUv, c1, 0.30) * k;
     col += vec3(0.47,0.35,1.0) * blob(vUv, c2, 0.28) * k * 0.9;
     col += vec3(1.0,0.43,0.27) * blob(vUv, c3, 0.24) * k * 0.6;
     col += vec3(0.25,0.80,0.78)* blob(vUv, c4, 0.22) * k * 0.5;
     if (uLight > 0.5) col = min(col, vec3(1.0));
     vec4 txt = texture2D(tText, vUv);
     col = mix(col, txt.rgb, txt.a);
     gl_FragColor = vec4(col, 1.0);
   }
   ```
   `tText` is a `CanvasTexture` the size of the drawing buffer (max 2048 wide): clear, `fillStyle = var(--text)`, `font = '300 ' + min(w*0.115, h*0.26) + 'px Fraunces'`, `letterSpacing = -0.04em`, `textAlign = 'center'`, draw "Through glass," at `(w/2, h/2 - 0.12·fs)` and "softly." at `(w/2, h/2 + 0.82·fs)`; redraw after `document.fonts.ready`, on resize and on theme change.
3. **Screen pass**: render a quad with `gl_FragColor = texture2D(tMap, vUv)` (`tMap = rt.texture`, `depthTest/depthWrite: false`) to the canvas, then render the blob scene on top without clearing.
4. **Blob geometry**: `new THREE.IcosahedronGeometry(1, 64)` (32 under 768px). Scale `clamp(min(viewW, viewH) * 0.27, 0.42, 0.85)` where `viewH = 2·tan(fov/2)·5` and `viewW = viewH·aspect`.
5. **Blob vertex shader** (uniforms `uTime, uNoiseStrength, uNoiseFreq`; varyings `vNormal, vViewPos, vNoise`): include Ashima's `snoise(vec3)` (the standard `permute / taylorInvSqrt` 3D simplex implementation), then:
   ```glsl
   vec3 orthogonal(vec3 v) { return normalize(abs(v.x) > abs(v.z) ? vec3(-v.y, v.x, 0.0) : vec3(0.0, -v.z, v.y)); }
   float field(vec3 p) {
     vec3 q = p * uNoiseFreq + vec3(0.0, uTime * 0.25, uTime * 0.18);
     return snoise(q) * 0.88 + snoise(q * 2.1 + 7.1) * 0.12;
   }
   vec3 displace(vec3 p) { return p + normalize(p) * field(p) * uNoiseStrength; }
   void main() {
     vec3 n0 = normalize(position);
     vec3 tangent = orthogonal(n0);
     vec3 bitangent = normalize(cross(n0, tangent));
     float eps = 0.012;
     vec3 p0 = displace(position);
     vec3 p1 = displace(normalize(position + tangent * eps));
     vec3 p2 = displace(normalize(position + bitangent * eps));
     vec3 n = normalize(cross(p1 - p0, p2 - p0));
     vNoise = field(position);
     vNormal = normalize(normalMatrix * n);
     vec4 mv = modelViewMatrix * vec4(p0, 1.0);
     vViewPos = mv.xyz;
     gl_Position = projectionMatrix * mv;
   }
   ```
6. **Blob fragment shader** (uniforms `tBg, uResolution, uRefract, uLight, uAccent`):
   ```glsl
   void main() {
     vec3 n = normalize(vNormal);
     vec3 v = normalize(-vViewPos);
     vec2 uv = gl_FragCoord.xy / uResolution;
     vec3 r = refract(-v, n, 1.0 / 1.45);
     vec2 offset = r.xy * uRefract;                         // uRefract = 0.12
     float cr = texture2D(tBg, uv + offset * 0.94).r;
     float cg = texture2D(tBg, uv + offset * 1.00).g;
     float cb = texture2D(tBg, uv + offset * 1.07).b;
     vec3 col = vec3(cr, cg, cb);
     float fres = pow(1.0 - clamp(dot(n, v), 0.0, 1.0), 3.0);
     vec3 rimTint = mix(vec3(1.0), uAccent, 0.45);
     if (uLight > 0.5) rimTint = mix(vec3(0.1), uAccent, 0.6);
     vec3 l = normalize(vec3(0.55, 0.8, 1.0));
     float spec = pow(max(dot(n, normalize(l + v)), 0.0), 140.0) * 0.75;
     vec3 l2 = normalize(vec3(-0.7, -0.3, 0.8));
     spec += pow(max(dot(n, normalize(l2 + v)), 0.0), 60.0) * 0.18;
     col = col * (0.94 + 0.06 * fres) + uAccent * max(vNoise, 0.0) * 0.04 * (1.0 - uLight);
     col += rimTint * fres * (0.85 - 0.35 * uLight);
     col += vec3(spec) * (1.0 - 0.5 * uLight);
     gl_FragColor = vec4(col, 1.0);
   }
   ```
7. **Per frame** (`dt = min(clock.getDelta(), 0.05)`, `t = clock.elapsedTime`): set both `uTime = t`; `target = (pointerNDC.x · viewW/2 · 0.82, pointerNDC.y · viewH/2 · 0.78, 0)`; `blob.position.lerp(target, 0.08)`; `blob.rotation.y += dt · 0.15`; `blob.rotation.x = sin(t · 0.21) · 0.25`. Then: `setRenderTarget(rt); clear(); render(bgScene, orthoCam); setRenderTarget(null); clear(); render(screenScene, orthoCam); render(scene, camera)`.
8. Colours: read `--bg`, `--surface`, `--text`, `--accent` from `getComputedStyle(document.documentElement)` and convert hex → `vec3` yourself (raw sRGB; do **not** go through `THREE.Color`, which converts to linear). A `MutationObserver` on `data-theme` refreshes the uniforms, sets `uLight = 1` for the light theme and redraws the headline texture.

**Interaction:** `pointermove` / `pointerdown` on the section map the pointer to NDC (−1…1) using the section's bounding rect; `pointerleave` resets it to `(0, 0)` so the blob eases home. Touch works through the same pointer events (`touch-action: none`). No clicks, no scroll dependency.

**Responsive behavior:** Under 768px (`matchMedia('(max-width: 767px)')`): icosahedron detail 32 instead of 64, DPR cap 1.5 instead of 2, the corner label is hidden and the blob scale clamps to a minimum of 0.42 so it stays readable on a tall portrait viewport. The headline font size is `min(w · 0.115, h · 0.26)` so it fits both orientations. On resize: `renderer.setSize`, camera aspect, `rt.setSize(drawingBuffer)`, `uResolution`, `uAspect`, blob scale and the text texture are all recomputed.

**Accessibility:** With `prefers-reduced-motion: reduce` the canvas is hidden and a static `.glass__fallback` is appended: the same gradient composition, the headline as a real `<h1>`, and a blurred glass circle made with `backdrop-filter` — no motion, no WebGL. The same fallback is used if `WebGLRenderer` throws. The canvas carries an `aria-label` describing the scene; all labels are real DOM text.

**Performance rules:** DPR capped at 2 (1.5 on mobile, halved in preview). The rAF loop stops on the `ml:pause` window event and on `visibilitychange` (hidden), resumes on `ml:resume`/visible, and clamps `dt` so the first frame after a pause doesn't jump. Only one render target at drawing-buffer size, no depth/stencil on it. On cleanup: cancel the rAF, remove every listener, disconnect the observer, dispose geometry, the three materials, both quad geometries, the canvas texture, the render target and the renderer.

**Customization knobs:**
- `NOISE_STRENGTH` (0.35) — how far lobes push out; 0.2 is a gentle pebble, 0.5 is molten.
- `NOISE_FREQ` (1.4) — number of lobes; 2.5+ turns into a brain.
- `REFRACT` (0.12) — bend amount; 0.06 for a subtle lens, 0.2 for heavy distortion.
- Aberration spread (`0.94 / 1.00 / 1.07`) — widen to 0.9/1.0/1.12 for stronger fringing.
- Fresnel power (3.0) and `rimTint` mix (0.45 toward `--accent`).
- `FOLLOW` (0.08) — pointer lag; `uTime` multipliers (`0.25`, `0.18`) — flow speed.
- The headline strings and the four blob colours in the background shader.

**Acceptance criteria:**
- [ ] Background shows a vertical gradient with four slowly drifting soft colour blobs and a centred two-line serif headline.
- [ ] The blob is a smooth organic lump (few large lobes, no faceting) and visibly refracts the headline with RGB fringing at steep edges.
- [ ] A thin bright rim follows the silhouette; a sharp highlight sits upper-right and moves as the blob rotates.
- [ ] The blob lags behind the pointer (≈0.08 lerp) and returns to centre on pointer leave.
- [ ] Refracted colours match the background's brightness (no colour-space shift between the two).
- [ ] Reduced motion shows the static fallback; no console errors; everything is disposed on unmount.

Source

Full source code

<section class="glass">
  <canvas class="glass__canvas" aria-label="A blob of glass that refracts the headline behind it and follows the pointer"></canvas>
  <p class="glass__eyebrow">WebGL · Refraction</p>
  <p class="glass__caption">Move the pointer — the blob follows and bends what sits behind it.</p>
  <p class="glass__corner">Halden Glassworks · N°04</p>
</section>