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.
- WebGL & 3D
- threejs
- glsl
- canvas2d
- expert
- heavy · WebGL
- mobile: full
- reduced-motion ✓
AI prompt
Recreate this effect with any AI tool
Paste into Claude, Cursor, v0, Lovable or Bolt. 1855 words of exact values — rewritten live as you tune the Playground.
### 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 — the accent (`#d4ff3a`), 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 (bend strength 0.12), with the red/green/blue channels splitting slightly along steep edges (chromatic aberration, when `true` is on). The surface is pushed in and out by 0.35 of the radius at a noise frequency of 1.4. The silhouette glows with a thin white-to-accent 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 (0.08 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`, `ABERRATION = true`, accent colour `#d4ff3a`.
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, uAberration` — `uAberration` is `1.0` when `true` is true, else `0.0`):
```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 * mix(1.0, 0.94, uAberration)).r;
float cg = texture2D(tBg, uv + offset * 1.00).g;
float cb = texture2D(tBg, uv + offset * mix(1.0, 1.07, uAberration)).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` from `getComputedStyle(document.documentElement)` (the accent is `#d4ff3a`) 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` (true) — chromatic aberration on/off; spread (`0.94 / 1.00 / 1.07`) — widen to 0.9/1.0/1.12 for stronger fringing.
- `--accent` (#d4ff3a) — the first background blob, the Fresnel rim tint and the internal thickness tint.
- 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. Add this effect to my existing site/section: **Refractive Glass Blob**. Put a full-bleed `<canvas class="glass__canvas">` behind my hero headline's container (`position: absolute; inset: 0`), and render my headline text into a `CanvasTexture` (same font, weight 300, letter-spacing −0.04em) composited over a gradient-plus-four-soft-colour-blobs background shader; render that background to a `WebGLRenderTarget` sized from `renderer.getDrawingBufferSize()`, draw it to the screen with a copy quad, then draw an `IcosahedronGeometry(1, 64)` with a custom `ShaderMaterial`: vertex shader displaces along `normalize(position)` by `snoise(p * 1.4 + vec3(0, t*0.25, t*0.18)) * 0.35` (plus a 0.12-weight second octave at 2.1×) and recomputes the normal from two tangent-offset neighbours (`eps 0.012`); fragment shader samples the render target at `gl_FragCoord.xy / uResolution + refract(-v, n, 1/1.45).xy * 0.12` with three taps at 0.94/1.00/1.07 for chromatic aberration (collapse them to a single tap when `true` is false), adds `pow(1 - dot(n, v), 3)` Fresnel tinted `mix(white, #d4ff3a, 0.45)` and a `pow(max(dot(n, h), 0), 140) * 0.75` specular from `normalize(vec3(0.55, 0.8, 1.0))`. Lerp the blob toward the pointer (mapped to the z = 0 plane) at 0.08 per frame, rotate Y at 0.15 rad/s. Cap DPR at `Math.min(devicePixelRatio, 2)`, pause the loop when hidden, dispose everything on unmount, and show a static CSS fallback under `prefers-reduced-motion`. Do not change my fonts, colours or layout. ### EFFECT (React / Next.js): Refractive Glass Blob
Build **Refractive Glass Blob** as a self-contained React component for a Next.js App Router project (also valid in Vite + React). Follow the specification below exactly; the vanilla reference implementation at the end is the source of truth for values and structure.
**Setup**
- `npm i gsap three`
- File: `components/RefractiveGlassBlob.tsx`, first line `"use client";` (GSAP touches the DOM).
- Imports:
```tsx
"use client";
import { useRef } from "react";
import * as THREE from "three";
export default function RefractiveGlassBlob() {
const scope = useRef<HTMLDivElement>(null);
useGSAP(() => {
// 1. query elements inside `scope.current` (never document-wide)
// 2. wait for document.fonts.ready before any SplitText
// 3. build the timeline / ScrollTriggers exactly as in the specification
// 4. return nothing — useGSAP reverts everything automatically on unmount
}, { scope });
return (
<div ref={scope} className="glass">
{/* markup from the HTML structure section, class → className */}
</div>
);
}
```
**React-specific rules**
- Everything GSAP-related lives inside `useGSAP(() => {...}, { scope })`; it handles cleanup (`ctx.revert()`) on unmount and in React Strict Mode double-invocation.
- Use refs or `gsap.utils.selector(scope)` instead of `document.querySelector`.
- Event listeners added inside useGSAP must use `contextSafe` (`const { contextSafe } = useGSAP({ scope })`) so their tweens are reverted too.
- ScrollTrigger: call `ScrollTrigger.refresh()` after images/fonts load; if the page uses Lenis, sync it once at the app root (`lenis.on('scroll', ScrollTrigger.update)`).
- Styles: copy the CSS requirements into a CSS Module (`RefractiveGlassBlob.module.css`) or Tailwind; keep the class names so the spec maps 1:1.
- Respect `prefers-reduced-motion` with `gsap.matchMedia()` as in the spec.
- Three.js: create renderer/scene inside a \`useEffect\` with a cleanup that calls \`renderer.dispose()\` and disposes geometries/materials; cap DPR at \`Math.min(devicePixelRatio, 2)\`.
---
### 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 — the accent (`#d4ff3a`), 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 (bend strength 0.12), with the red/green/blue channels splitting slightly along steep edges (chromatic aberration, when `true` is on). The surface is pushed in and out by 0.35 of the radius at a noise frequency of 1.4. The silhouette glows with a thin white-to-accent 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 (0.08 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`, `ABERRATION = true`, accent colour `#d4ff3a`.
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, uAberration` — `uAberration` is `1.0` when `true` is true, else `0.0`):
```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 * mix(1.0, 0.94, uAberration)).r;
float cg = texture2D(tBg, uv + offset * 1.00).g;
float cb = texture2D(tBg, uv + offset * mix(1.0, 1.07, uAberration)).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` from `getComputedStyle(document.documentElement)` (the accent is `#d4ff3a`) 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` (true) — chromatic aberration on/off; spread (`0.94 / 1.00 / 1.07`) — widen to 0.9/1.0/1.12 for stronger fringing.
- `--accent` (#d4ff3a) — the first background blob, the Fresnel rim tint and the internal thickness tint.
- 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.
---
**Reference implementation (vanilla, converts 1:1 — same class names, same values):**
```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
.glass { position: relative; height: 100vh; min-height: 480px; overflow: hidden; background: var(--bg); color: var(--text); touch-action: none; }
.glass__canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.glass__eyebrow, .glass__caption, .glass__corner {
position: absolute; font-family: var(--font-mono); font-size: 11px; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); pointer-events: none;
}
.glass__eyebrow { top: 2rem; left: 6vw; }
.glass__corner { top: 2rem; right: 6vw; }
.glass__caption { left: 6vw; right: 6vw; bottom: 2rem; text-transform: none; letter-spacing: .02em; font-family: var(--font-sans); font-size: clamp(.85rem, 1vw, .95rem); max-width: 44ch; }
/* Fallback / reduced motion: the same composition as the texture, static */
.glass__fallback {
position: absolute; inset: 0; display: grid; place-content: center; text-align: center; padding: 0 6vw;
background:
radial-gradient(45% 55% at 25% 30%, color-mix(in srgb, var(--knob-accent, var(--accent)) 40%, transparent), transparent 70%),
radial-gradient(40% 50% at 75% 70%, rgba(120, 90, 255, .35), transparent 70%),
radial-gradient(50% 50% at 60% 20%, rgba(255, 110, 70, .22), transparent 70%),
linear-gradient(180deg, var(--surface), var(--bg));
}
.glass__fallback h1 { font-family: var(--font-display); font-weight: 300; font-size: clamp(2.5rem, 8vw, 7.5rem); letter-spacing: -0.04em; line-height: .95; text-wrap: balance; }
.glass__fallback::after {
content: ''; position: absolute; left: 50%; top: 50%; width: min(42vw, 42vh); aspect-ratio: 1; translate: -50% -50%; border-radius: 50%;
background: radial-gradient(circle at 35% 30%, rgba(255,255,255,.35), rgba(255,255,255,.06) 55%, transparent 70%);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text) 25%, transparent), 0 40px 120px -40px rgba(0,0,0,.6);
backdrop-filter: blur(6px) saturate(1.2);
}
@media (max-width: 767px) {
.glass__eyebrow, .glass__corner { top: 1.4rem; }
.glass__corner { display: none; }
.glass__caption { bottom: 1.4rem; }
}
@media (prefers-reduced-motion: reduce) { .glass__canvas { display: none; } }
```
```js
// Refractive Glass Blob — a noise-displaced icosahedron fakes glass by refracting a render target of the scene behind it.
import * as THREE from 'three';
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { noiseStrength: 0.35, noiseFreq: 1.4, refract: 0.12, follow: 0.08, accent: '#d4ff3a', aberration: true };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const root = document.querySelector('.glass');
const canvas = root.querySelector('.glass__canvas');
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const isMobile = matchMedia('(max-width: 767px)').matches;
const preview = !!window.MotionLab?.preview;
/* ---------- tokens (CSS vars → raw sRGB vec3, no colour-management conversion) ---------- */
const hexToVec3 = (hex) => {
const h = hex.trim().replace('#', '');
const n = parseInt(h.length === 3 ? h.split('').map((c) => c + c).join('') : h, 16);
return new THREE.Vector3(((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255);
};
const readTokens = () => {
const cs = getComputedStyle(document.documentElement);
const light = document.documentElement.getAttribute('data-theme') === 'light';
return {
bg: hexToVec3(cs.getPropertyValue('--bg') || '#0A0A0A'),
surface: hexToVec3(cs.getPropertyValue('--surface') || '#121212'),
text: hexToVec3(cs.getPropertyValue('--text') || '#EDEDED'),
accent: hexToVec3(K('accent')), // the accent is a Playground knob
light,
};
};
function showFallback() {
const fb = document.createElement('div');
fb.className = 'glass__fallback';
fb.innerHTML = '<h1>Through glass,<br>softly.</h1>';
root.appendChild(fb);
}
// Live parameters + the WebGL-side knob applier (set up by init); build() is the Playground entry point.
const params = { follow: DEFAULTS.follow };
let applyKnobs = null;
if (reduced) {
showFallback();
} else {
let renderer;
try {
renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false, powerPreference: 'high-performance' });
} catch (e) { renderer = null; }
if (!renderer) showFallback(); else init(renderer);
}
function init(renderer) {
const dprCap = isMobile ? 1.5 : 2;
const dpr = Math.min(window.devicePixelRatio || 1, dprCap) * (preview ? 0.5 : 1);
renderer.setPixelRatio(dpr);
renderer.autoClear = false;
const tokens = readTokens();
const size = new THREE.Vector2();
/* ---------- background scene: gradient + moving colour blobs + display text (CanvasTexture) ---------- */
const orthoCam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const bgScene = new THREE.Scene();
const textCanvas = document.createElement('canvas');
const textTex = new THREE.CanvasTexture(textCanvas);
textTex.minFilter = THREE.LinearFilter; textTex.generateMipmaps = false;
const bgUniforms = {
uTime: { value: 0 },
uAspect: { value: 1 },
uBg: { value: tokens.bg.clone() },
uSurface: { value: tokens.surface.clone() },
uAccent: { value: tokens.accent.clone() },
uLight: { value: tokens.light ? 1 : 0 },
tText: { value: textTex },
};
const bgMat = new THREE.ShaderMaterial({
uniforms: bgUniforms,
depthTest: false, depthWrite: false,
vertexShader: /* glsl */`
varying vec2 vUv;
void main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }`,
fragmentShader: /* glsl */`
precision highp float;
uniform float uTime, uAspect, uLight;
uniform vec3 uBg, uSurface, uAccent;
uniform sampler2D tText;
varying vec2 vUv;
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));
vec3 lime = uAccent;
vec3 violet = vec3(0.47, 0.35, 1.0);
vec3 ember = vec3(1.0, 0.43, 0.27);
vec3 teal = vec3(0.25, 0.80, 0.78);
float k = mix(0.55, 0.32, uLight);
col += lime * blob(vUv, c1, 0.30) * k;
col += violet * blob(vUv, c2, 0.28) * k * 0.9;
col += ember * blob(vUv, c3, 0.24) * k * 0.6;
col += teal * 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);
}`,
});
bgScene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), bgMat));
const rt = new THREE.WebGLRenderTarget(2, 2, { depthBuffer: false, stencilBuffer: false, minFilter: THREE.LinearFilter, magFilter: THREE.LinearFilter });
/* ---------- screen quad that shows the render target ---------- */
const screenScene = new THREE.Scene();
const screenMat = new THREE.ShaderMaterial({
uniforms: { tMap: { value: rt.texture } },
depthTest: false, depthWrite: false,
vertexShader: /* glsl */`varying vec2 vUv; void main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }`,
fragmentShader: /* glsl */`precision highp float; uniform sampler2D tMap; varying vec2 vUv; void main() { gl_FragColor = texture2D(tMap, vUv); }`,
});
screenScene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), screenMat));
/* ---------- blob scene ---------- */
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(30, 1, 0.1, 20);
camera.position.set(0, 0, 5);
const detail = isMobile ? 32 : 64;
const geometry = new THREE.IcosahedronGeometry(1, detail);
const blobUniforms = {
uTime: { value: 0 },
uNoiseStrength: { value: K('noiseStrength') }, // displacement along the normal
uNoiseFreq: { value: K('noiseFreq') }, // spatial frequency of the simplex field
uRefract: { value: K('refract') }, // screen-space UV offset strength
uAberration: { value: K('aberration') ? 1 : 0 }, // chromatic aberration on/off
uResolution: { value: new THREE.Vector2(1, 1) },
uAccent: { value: tokens.accent.clone() },
uLight: { value: tokens.light ? 1 : 0 },
tBg: { value: rt.texture },
};
const blobMat = new THREE.ShaderMaterial({
uniforms: blobUniforms,
vertexShader: /* glsl */`
uniform float uTime, uNoiseStrength, uNoiseFreq;
varying vec3 vNormal;
varying vec3 vViewPos;
varying float vNoise;
// Simplex 3D noise (Ashima Arts / Stefan Gustavson, MIT)
vec4 permute(vec4 x) { return mod(((x * 34.0) + 1.0) * x, 289.0); }
vec4 taylorInvSqrt(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; }
float snoise(vec3 v) {
const vec2 C = vec2(1.0 / 6.0, 1.0 / 3.0);
const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);
vec3 i = floor(v + dot(v, C.yyy));
vec3 x0 = v - i + dot(i, C.xxx);
vec3 g = step(x0.yzx, x0.xyz);
vec3 l = 1.0 - g;
vec3 i1 = min(g.xyz, l.zxy);
vec3 i2 = max(g.xyz, l.zxy);
vec3 x1 = x0 - i1 + C.xxx;
vec3 x2 = x0 - i2 + C.yyy;
vec3 x3 = x0 - D.yyy;
i = mod(i, 289.0);
vec4 p = permute(permute(permute(i.z + vec4(0.0, i1.z, i2.z, 1.0)) + i.y + vec4(0.0, i1.y, i2.y, 1.0)) + i.x + vec4(0.0, i1.x, i2.x, 1.0));
float n_ = 1.0 / 7.0;
vec3 ns = n_ * D.wyz - D.xzx;
vec4 j = p - 49.0 * floor(p * ns.z * ns.z);
vec4 x_ = floor(j * ns.z);
vec4 y_ = floor(j - 7.0 * x_);
vec4 x = x_ * ns.x + ns.yyyy;
vec4 y = y_ * ns.x + ns.yyyy;
vec4 h = 1.0 - abs(x) - abs(y);
vec4 b0 = vec4(x.xy, y.xy);
vec4 b1 = vec4(x.zw, y.zw);
vec4 s0 = floor(b0) * 2.0 + 1.0;
vec4 s1 = floor(b1) * 2.0 + 1.0;
vec4 sh = -step(h, vec4(0.0));
vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy;
vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww;
vec3 p0 = vec3(a0.xy, h.x);
vec3 p1 = vec3(a0.zw, h.y);
vec3 p2 = vec3(a1.xy, h.z);
vec3 p3 = vec3(a1.zw, h.w);
vec4 norm = taylorInvSqrt(vec4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3)));
p0 *= norm.x; p1 *= norm.y; p2 *= norm.z; p3 *= norm.w;
vec4 m = max(0.6 - vec4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0);
m = m * m;
return 42.0 * dot(m * m, vec4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3)));
}
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)); }
// Two octaves of simplex noise flowing through the unit sphere.
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;
}`,
fragmentShader: /* glsl */`
precision highp float;
uniform sampler2D tBg;
uniform vec2 uResolution;
uniform float uRefract, uLight, uAberration;
uniform vec3 uAccent;
varying vec3 vNormal;
varying vec3 vViewPos;
varying float vNoise;
void main() {
vec3 n = normalize(vNormal);
vec3 v = normalize(-vViewPos); // surface → eye, view space
vec2 uv = gl_FragCoord.xy / uResolution; // screen-space UV into the background render target
// Refraction: bend the eye ray through the surface (IOR 1.45) and use its xy as a UV offset.
vec3 r = refract(-v, n, 1.0 / 1.45);
vec2 offset = r.xy * uRefract;
// Chromatic aberration: three taps with slightly different offsets (collapse to one when uAberration = 0).
float cr = texture2D(tBg, uv + offset * mix(1.0, 0.94, uAberration)).r;
float cg = texture2D(tBg, uv + offset * 1.00).g;
float cb = texture2D(tBg, uv + offset * mix(1.0, 1.07, uAberration)).b;
vec3 col = vec3(cr, cg, cb);
// Fresnel rim, tinted between white and the accent.
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);
// Specular highlight from a key light up-right.
vec3 l = normalize(vec3(0.55, 0.8, 1.0));
vec3 h = normalize(l + v);
float spec = pow(max(dot(n, h), 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;
// Thickness: slightly brighten the body and add a faint internal tint where the surface is pushed outward.
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);
}`,
});
const blob = new THREE.Mesh(geometry, blobMat);
scene.add(blob);
/* ---------- text texture ---------- */
function drawText() {
const w = Math.min(Math.round(size.x * dpr), 2048);
const h = Math.round(w * (size.y / size.x));
textCanvas.width = w; textCanvas.height = h;
const c = textCanvas.getContext('2d');
c.clearRect(0, 0, w, h);
const t = readTokens();
const col = `rgb(${Math.round(t.text.x * 255)}, ${Math.round(t.text.y * 255)}, ${Math.round(t.text.z * 255)})`;
const fs = Math.min(w * 0.115, h * 0.26);
c.fillStyle = col;
c.textAlign = 'center'; c.textBaseline = 'alphabetic';
c.font = `300 ${fs}px Fraunces, Georgia, serif`;
const letter = -0.04 * fs;
if ('letterSpacing' in c) c.letterSpacing = `${letter}px`;
c.fillText('Through glass,', w / 2, h / 2 - fs * 0.12);
c.fillText('softly.', w / 2, h / 2 + fs * 0.82);
textTex.needsUpdate = true;
}
/* ---------- sizing ---------- */
const viewAt = (z) => {
const hgt = 2 * Math.tan(THREE.MathUtils.degToRad(camera.fov) / 2) * (camera.position.z - z);
return { w: hgt * camera.aspect, h: hgt };
};
let view = { w: 1, h: 1 };
function resize() {
size.set(root.clientWidth, root.clientHeight);
renderer.setSize(size.x, size.y, false);
camera.aspect = size.x / size.y;
camera.updateProjectionMatrix();
const buf = renderer.getDrawingBufferSize(new THREE.Vector2());
rt.setSize(buf.x, buf.y);
blobUniforms.uResolution.value.copy(buf);
bgUniforms.uAspect.value = size.x / size.y;
view = viewAt(0);
const s = THREE.MathUtils.clamp(Math.min(view.w, view.h) * 0.27, 0.42, 0.85);
blob.scale.setScalar(s);
drawText();
}
resize();
document.fonts.ready.then(drawText);
document.fonts.load('300 100px Fraunces').then(drawText).catch(() => {});
/* ---------- pointer follow ---------- */
const pointer = new THREE.Vector2(0, 0); // NDC
const target = new THREE.Vector3(0, 0, 0);
const onPointer = (e) => {
const r = root.getBoundingClientRect();
pointer.set(((e.clientX - r.left) / r.width) * 2 - 1, -(((e.clientY - r.top) / r.height) * 2 - 1));
};
const onLeave = () => pointer.set(0, 0);
root.addEventListener('pointermove', onPointer, { passive: true });
root.addEventListener('pointerdown', onPointer, { passive: true });
root.addEventListener('pointerleave', onLeave);
/* ---------- theme sync (also re-reads the accent knob) ---------- */
const syncTokens = () => {
const t = readTokens();
bgUniforms.uBg.value.copy(t.bg); bgUniforms.uSurface.value.copy(t.surface); bgUniforms.uAccent.value.copy(t.accent);
bgUniforms.uLight.value = t.light ? 1 : 0;
blobUniforms.uAccent.value.copy(t.accent); blobUniforms.uLight.value = t.light ? 1 : 0;
drawText();
};
const mo = new MutationObserver(syncTokens);
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
/* ---------- knobs → uniforms / params (the renderer and scene are kept; the change is visible on the next frame) ---------- */
applyKnobs = () => {
blobUniforms.uNoiseStrength.value = K('noiseStrength');
blobUniforms.uNoiseFreq.value = K('noiseFreq');
blobUniforms.uRefract.value = K('refract');
blobUniforms.uAberration.value = K('aberration') ? 1 : 0;
params.follow = K('follow');
syncTokens();
};
/* ---------- loop ---------- */
const clock = new THREE.Clock();
let raf = 0; let running = false;
function frame() {
raf = requestAnimationFrame(frame);
const dt = Math.min(clock.getDelta(), 0.05);
const t = clock.elapsedTime;
bgUniforms.uTime.value = t;
blobUniforms.uTime.value = t;
target.set(pointer.x * view.w * 0.5 * 0.82, pointer.y * view.h * 0.5 * 0.78, 0);
blob.position.lerp(target, params.follow);
blob.rotation.y += dt * 0.15;
blob.rotation.x = Math.sin(t * 0.21) * 0.25;
renderer.setRenderTarget(rt);
renderer.clear();
renderer.render(bgScene, orthoCam);
renderer.setRenderTarget(null);
renderer.clear();
renderer.render(screenScene, orthoCam);
renderer.render(scene, camera);
}
const start = () => { if (running || document.hidden) return; running = true; clock.start(); raf = requestAnimationFrame(frame); };
const stop = () => { running = false; cancelAnimationFrame(raf); clock.stop(); };
const onVisibility = () => (document.hidden ? stop() : start());
window.addEventListener('ml:pause', stop);
window.addEventListener('ml:resume', start);
document.addEventListener('visibilitychange', onVisibility);
window.addEventListener('resize', resize);
start();
window.MotionLab?.onCleanup(() => {
stop();
applyKnobs = null;
mo.disconnect();
window.removeEventListener('ml:pause', stop);
window.removeEventListener('ml:resume', start);
document.removeEventListener('visibilitychange', onVisibility);
window.removeEventListener('resize', resize);
root.removeEventListener('pointermove', onPointer);
root.removeEventListener('pointerdown', onPointer);
root.removeEventListener('pointerleave', onLeave);
geometry.dispose(); blobMat.dispose(); bgMat.dispose(); screenMat.dispose();
screenScene.children[0].geometry.dispose(); bgScene.children[0].geometry.dispose();
textTex.dispose(); rt.dispose();
renderer.dispose();
});
}
/* ---------- Playground entry point: CSS accent for the fallback + WebGL params ---------- */
function build() {
root.style.setProperty('--knob-accent', K('accent'));
applyKnobs?.();
}
build();
// Playground: re-apply the new values (uniforms update in place — no renderer rebuild needed)
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => window.removeEventListener('ml:knobs', build));
```
### EFFECT (single HTML file): Refractive Glass Blob
Produce **one self-contained `index.html`** (inline `<style>` and `<script type="module">`, no build step) that implements **Refractive Glass Blob** exactly as specified below. Libraries load from jsDelivr:
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Refractive Glass Blob</title>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..600;1,9..144,300..600&family=Inter+Tight:wght@300..700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root{--bg:#0a0a0a;--surface:#121212;--line:rgba(255,255,255,.08);--text:#ededed;--muted:#8a8a8a;--accent:#d4ff3a;--font-display:"Fraunces",Georgia,serif;--font-sans:"Inter Tight",system-ui,sans-serif;--font-mono:"JetBrains Mono",ui-monospace,monospace}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--text);font-family:var(--font-sans);-webkit-font-smoothing:antialiased;overflow-x:hidden}
/* + the CSS requirements below */
</style>
<script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.186/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.186/examples/jsm/"}}</script>
</head>
<body>
<!-- markup from the HTML structure section -->
<script type="module">
// the JS from the animation spec (window.MotionLab is optional — keep the ?. guards or remove them)
</script>
</body>
</html>
```
The file must open directly from a local server (`npx serve .`) with zero console errors, work at 375px and 1440px, and honour `prefers-reduced-motion`.
---
### 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 — the accent (`#d4ff3a`), 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 (bend strength 0.12), with the red/green/blue channels splitting slightly along steep edges (chromatic aberration, when `true` is on). The surface is pushed in and out by 0.35 of the radius at a noise frequency of 1.4. The silhouette glows with a thin white-to-accent 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 (0.08 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`, `ABERRATION = true`, accent colour `#d4ff3a`.
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, uAberration` — `uAberration` is `1.0` when `true` is true, else `0.0`):
```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 * mix(1.0, 0.94, uAberration)).r;
float cg = texture2D(tBg, uv + offset * 1.00).g;
float cb = texture2D(tBg, uv + offset * mix(1.0, 1.07, uAberration)).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` from `getComputedStyle(document.documentElement)` (the accent is `#d4ff3a`) 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` (true) — chromatic aberration on/off; spread (`0.94 / 1.00 / 1.07`) — widen to 0.9/1.0/1.12 for stronger fringing.
- `--accent` (#d4ff3a) — the first background blob, the Fresnel rim tint and the internal thickness tint.
- 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.
---
**Reference implementation (use these exact values and class names):**
```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
.glass { position: relative; height: 100vh; min-height: 480px; overflow: hidden; background: var(--bg); color: var(--text); touch-action: none; }
.glass__canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.glass__eyebrow, .glass__caption, .glass__corner {
position: absolute; font-family: var(--font-mono); font-size: 11px; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); pointer-events: none;
}
.glass__eyebrow { top: 2rem; left: 6vw; }
.glass__corner { top: 2rem; right: 6vw; }
.glass__caption { left: 6vw; right: 6vw; bottom: 2rem; text-transform: none; letter-spacing: .02em; font-family: var(--font-sans); font-size: clamp(.85rem, 1vw, .95rem); max-width: 44ch; }
/* Fallback / reduced motion: the same composition as the texture, static */
.glass__fallback {
position: absolute; inset: 0; display: grid; place-content: center; text-align: center; padding: 0 6vw;
background:
radial-gradient(45% 55% at 25% 30%, color-mix(in srgb, var(--knob-accent, var(--accent)) 40%, transparent), transparent 70%),
radial-gradient(40% 50% at 75% 70%, rgba(120, 90, 255, .35), transparent 70%),
radial-gradient(50% 50% at 60% 20%, rgba(255, 110, 70, .22), transparent 70%),
linear-gradient(180deg, var(--surface), var(--bg));
}
.glass__fallback h1 { font-family: var(--font-display); font-weight: 300; font-size: clamp(2.5rem, 8vw, 7.5rem); letter-spacing: -0.04em; line-height: .95; text-wrap: balance; }
.glass__fallback::after {
content: ''; position: absolute; left: 50%; top: 50%; width: min(42vw, 42vh); aspect-ratio: 1; translate: -50% -50%; border-radius: 50%;
background: radial-gradient(circle at 35% 30%, rgba(255,255,255,.35), rgba(255,255,255,.06) 55%, transparent 70%);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text) 25%, transparent), 0 40px 120px -40px rgba(0,0,0,.6);
backdrop-filter: blur(6px) saturate(1.2);
}
@media (max-width: 767px) {
.glass__eyebrow, .glass__corner { top: 1.4rem; }
.glass__corner { display: none; }
.glass__caption { bottom: 1.4rem; }
}
@media (prefers-reduced-motion: reduce) { .glass__canvas { display: none; } }
```
```js
// Refractive Glass Blob — a noise-displaced icosahedron fakes glass by refracting a render target of the scene behind it.
import * as THREE from 'three';
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { noiseStrength: 0.35, noiseFreq: 1.4, refract: 0.12, follow: 0.08, accent: '#d4ff3a', aberration: true };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const root = document.querySelector('.glass');
const canvas = root.querySelector('.glass__canvas');
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const isMobile = matchMedia('(max-width: 767px)').matches;
const preview = !!window.MotionLab?.preview;
/* ---------- tokens (CSS vars → raw sRGB vec3, no colour-management conversion) ---------- */
const hexToVec3 = (hex) => {
const h = hex.trim().replace('#', '');
const n = parseInt(h.length === 3 ? h.split('').map((c) => c + c).join('') : h, 16);
return new THREE.Vector3(((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255);
};
const readTokens = () => {
const cs = getComputedStyle(document.documentElement);
const light = document.documentElement.getAttribute('data-theme') === 'light';
return {
bg: hexToVec3(cs.getPropertyValue('--bg') || '#0A0A0A'),
surface: hexToVec3(cs.getPropertyValue('--surface') || '#121212'),
text: hexToVec3(cs.getPropertyValue('--text') || '#EDEDED'),
accent: hexToVec3(K('accent')), // the accent is a Playground knob
light,
};
};
function showFallback() {
const fb = document.createElement('div');
fb.className = 'glass__fallback';
fb.innerHTML = '<h1>Through glass,<br>softly.</h1>';
root.appendChild(fb);
}
// Live parameters + the WebGL-side knob applier (set up by init); build() is the Playground entry point.
const params = { follow: DEFAULTS.follow };
let applyKnobs = null;
if (reduced) {
showFallback();
} else {
let renderer;
try {
renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false, powerPreference: 'high-performance' });
} catch (e) { renderer = null; }
if (!renderer) showFallback(); else init(renderer);
}
function init(renderer) {
const dprCap = isMobile ? 1.5 : 2;
const dpr = Math.min(window.devicePixelRatio || 1, dprCap) * (preview ? 0.5 : 1);
renderer.setPixelRatio(dpr);
renderer.autoClear = false;
const tokens = readTokens();
const size = new THREE.Vector2();
/* ---------- background scene: gradient + moving colour blobs + display text (CanvasTexture) ---------- */
const orthoCam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const bgScene = new THREE.Scene();
const textCanvas = document.createElement('canvas');
const textTex = new THREE.CanvasTexture(textCanvas);
textTex.minFilter = THREE.LinearFilter; textTex.generateMipmaps = false;
const bgUniforms = {
uTime: { value: 0 },
uAspect: { value: 1 },
uBg: { value: tokens.bg.clone() },
uSurface: { value: tokens.surface.clone() },
uAccent: { value: tokens.accent.clone() },
uLight: { value: tokens.light ? 1 : 0 },
tText: { value: textTex },
};
const bgMat = new THREE.ShaderMaterial({
uniforms: bgUniforms,
depthTest: false, depthWrite: false,
vertexShader: /* glsl */`
varying vec2 vUv;
void main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }`,
fragmentShader: /* glsl */`
precision highp float;
uniform float uTime, uAspect, uLight;
uniform vec3 uBg, uSurface, uAccent;
uniform sampler2D tText;
varying vec2 vUv;
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));
vec3 lime = uAccent;
vec3 violet = vec3(0.47, 0.35, 1.0);
vec3 ember = vec3(1.0, 0.43, 0.27);
vec3 teal = vec3(0.25, 0.80, 0.78);
float k = mix(0.55, 0.32, uLight);
col += lime * blob(vUv, c1, 0.30) * k;
col += violet * blob(vUv, c2, 0.28) * k * 0.9;
col += ember * blob(vUv, c3, 0.24) * k * 0.6;
col += teal * 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);
}`,
});
bgScene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), bgMat));
const rt = new THREE.WebGLRenderTarget(2, 2, { depthBuffer: false, stencilBuffer: false, minFilter: THREE.LinearFilter, magFilter: THREE.LinearFilter });
/* ---------- screen quad that shows the render target ---------- */
const screenScene = new THREE.Scene();
const screenMat = new THREE.ShaderMaterial({
uniforms: { tMap: { value: rt.texture } },
depthTest: false, depthWrite: false,
vertexShader: /* glsl */`varying vec2 vUv; void main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }`,
fragmentShader: /* glsl */`precision highp float; uniform sampler2D tMap; varying vec2 vUv; void main() { gl_FragColor = texture2D(tMap, vUv); }`,
});
screenScene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), screenMat));
/* ---------- blob scene ---------- */
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(30, 1, 0.1, 20);
camera.position.set(0, 0, 5);
const detail = isMobile ? 32 : 64;
const geometry = new THREE.IcosahedronGeometry(1, detail);
const blobUniforms = {
uTime: { value: 0 },
uNoiseStrength: { value: K('noiseStrength') }, // displacement along the normal
uNoiseFreq: { value: K('noiseFreq') }, // spatial frequency of the simplex field
uRefract: { value: K('refract') }, // screen-space UV offset strength
uAberration: { value: K('aberration') ? 1 : 0 }, // chromatic aberration on/off
uResolution: { value: new THREE.Vector2(1, 1) },
uAccent: { value: tokens.accent.clone() },
uLight: { value: tokens.light ? 1 : 0 },
tBg: { value: rt.texture },
};
const blobMat = new THREE.ShaderMaterial({
uniforms: blobUniforms,
vertexShader: /* glsl */`
uniform float uTime, uNoiseStrength, uNoiseFreq;
varying vec3 vNormal;
varying vec3 vViewPos;
varying float vNoise;
// Simplex 3D noise (Ashima Arts / Stefan Gustavson, MIT)
vec4 permute(vec4 x) { return mod(((x * 34.0) + 1.0) * x, 289.0); }
vec4 taylorInvSqrt(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; }
float snoise(vec3 v) {
const vec2 C = vec2(1.0 / 6.0, 1.0 / 3.0);
const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);
vec3 i = floor(v + dot(v, C.yyy));
vec3 x0 = v - i + dot(i, C.xxx);
vec3 g = step(x0.yzx, x0.xyz);
vec3 l = 1.0 - g;
vec3 i1 = min(g.xyz, l.zxy);
vec3 i2 = max(g.xyz, l.zxy);
vec3 x1 = x0 - i1 + C.xxx;
vec3 x2 = x0 - i2 + C.yyy;
vec3 x3 = x0 - D.yyy;
i = mod(i, 289.0);
vec4 p = permute(permute(permute(i.z + vec4(0.0, i1.z, i2.z, 1.0)) + i.y + vec4(0.0, i1.y, i2.y, 1.0)) + i.x + vec4(0.0, i1.x, i2.x, 1.0));
float n_ = 1.0 / 7.0;
vec3 ns = n_ * D.wyz - D.xzx;
vec4 j = p - 49.0 * floor(p * ns.z * ns.z);
vec4 x_ = floor(j * ns.z);
vec4 y_ = floor(j - 7.0 * x_);
vec4 x = x_ * ns.x + ns.yyyy;
vec4 y = y_ * ns.x + ns.yyyy;
vec4 h = 1.0 - abs(x) - abs(y);
vec4 b0 = vec4(x.xy, y.xy);
vec4 b1 = vec4(x.zw, y.zw);
vec4 s0 = floor(b0) * 2.0 + 1.0;
vec4 s1 = floor(b1) * 2.0 + 1.0;
vec4 sh = -step(h, vec4(0.0));
vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy;
vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww;
vec3 p0 = vec3(a0.xy, h.x);
vec3 p1 = vec3(a0.zw, h.y);
vec3 p2 = vec3(a1.xy, h.z);
vec3 p3 = vec3(a1.zw, h.w);
vec4 norm = taylorInvSqrt(vec4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3)));
p0 *= norm.x; p1 *= norm.y; p2 *= norm.z; p3 *= norm.w;
vec4 m = max(0.6 - vec4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0);
m = m * m;
return 42.0 * dot(m * m, vec4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3)));
}
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)); }
// Two octaves of simplex noise flowing through the unit sphere.
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;
}`,
fragmentShader: /* glsl */`
precision highp float;
uniform sampler2D tBg;
uniform vec2 uResolution;
uniform float uRefract, uLight, uAberration;
uniform vec3 uAccent;
varying vec3 vNormal;
varying vec3 vViewPos;
varying float vNoise;
void main() {
vec3 n = normalize(vNormal);
vec3 v = normalize(-vViewPos); // surface → eye, view space
vec2 uv = gl_FragCoord.xy / uResolution; // screen-space UV into the background render target
// Refraction: bend the eye ray through the surface (IOR 1.45) and use its xy as a UV offset.
vec3 r = refract(-v, n, 1.0 / 1.45);
vec2 offset = r.xy * uRefract;
// Chromatic aberration: three taps with slightly different offsets (collapse to one when uAberration = 0).
float cr = texture2D(tBg, uv + offset * mix(1.0, 0.94, uAberration)).r;
float cg = texture2D(tBg, uv + offset * 1.00).g;
float cb = texture2D(tBg, uv + offset * mix(1.0, 1.07, uAberration)).b;
vec3 col = vec3(cr, cg, cb);
// Fresnel rim, tinted between white and the accent.
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);
// Specular highlight from a key light up-right.
vec3 l = normalize(vec3(0.55, 0.8, 1.0));
vec3 h = normalize(l + v);
float spec = pow(max(dot(n, h), 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;
// Thickness: slightly brighten the body and add a faint internal tint where the surface is pushed outward.
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);
}`,
});
const blob = new THREE.Mesh(geometry, blobMat);
scene.add(blob);
/* ---------- text texture ---------- */
function drawText() {
const w = Math.min(Math.round(size.x * dpr), 2048);
const h = Math.round(w * (size.y / size.x));
textCanvas.width = w; textCanvas.height = h;
const c = textCanvas.getContext('2d');
c.clearRect(0, 0, w, h);
const t = readTokens();
const col = `rgb(${Math.round(t.text.x * 255)}, ${Math.round(t.text.y * 255)}, ${Math.round(t.text.z * 255)})`;
const fs = Math.min(w * 0.115, h * 0.26);
c.fillStyle = col;
c.textAlign = 'center'; c.textBaseline = 'alphabetic';
c.font = `300 ${fs}px Fraunces, Georgia, serif`;
const letter = -0.04 * fs;
if ('letterSpacing' in c) c.letterSpacing = `${letter}px`;
c.fillText('Through glass,', w / 2, h / 2 - fs * 0.12);
c.fillText('softly.', w / 2, h / 2 + fs * 0.82);
textTex.needsUpdate = true;
}
/* ---------- sizing ---------- */
const viewAt = (z) => {
const hgt = 2 * Math.tan(THREE.MathUtils.degToRad(camera.fov) / 2) * (camera.position.z - z);
return { w: hgt * camera.aspect, h: hgt };
};
let view = { w: 1, h: 1 };
function resize() {
size.set(root.clientWidth, root.clientHeight);
renderer.setSize(size.x, size.y, false);
camera.aspect = size.x / size.y;
camera.updateProjectionMatrix();
const buf = renderer.getDrawingBufferSize(new THREE.Vector2());
rt.setSize(buf.x, buf.y);
blobUniforms.uResolution.value.copy(buf);
bgUniforms.uAspect.value = size.x / size.y;
view = viewAt(0);
const s = THREE.MathUtils.clamp(Math.min(view.w, view.h) * 0.27, 0.42, 0.85);
blob.scale.setScalar(s);
drawText();
}
resize();
document.fonts.ready.then(drawText);
document.fonts.load('300 100px Fraunces').then(drawText).catch(() => {});
/* ---------- pointer follow ---------- */
const pointer = new THREE.Vector2(0, 0); // NDC
const target = new THREE.Vector3(0, 0, 0);
const onPointer = (e) => {
const r = root.getBoundingClientRect();
pointer.set(((e.clientX - r.left) / r.width) * 2 - 1, -(((e.clientY - r.top) / r.height) * 2 - 1));
};
const onLeave = () => pointer.set(0, 0);
root.addEventListener('pointermove', onPointer, { passive: true });
root.addEventListener('pointerdown', onPointer, { passive: true });
root.addEventListener('pointerleave', onLeave);
/* ---------- theme sync (also re-reads the accent knob) ---------- */
const syncTokens = () => {
const t = readTokens();
bgUniforms.uBg.value.copy(t.bg); bgUniforms.uSurface.value.copy(t.surface); bgUniforms.uAccent.value.copy(t.accent);
bgUniforms.uLight.value = t.light ? 1 : 0;
blobUniforms.uAccent.value.copy(t.accent); blobUniforms.uLight.value = t.light ? 1 : 0;
drawText();
};
const mo = new MutationObserver(syncTokens);
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
/* ---------- knobs → uniforms / params (the renderer and scene are kept; the change is visible on the next frame) ---------- */
applyKnobs = () => {
blobUniforms.uNoiseStrength.value = K('noiseStrength');
blobUniforms.uNoiseFreq.value = K('noiseFreq');
blobUniforms.uRefract.value = K('refract');
blobUniforms.uAberration.value = K('aberration') ? 1 : 0;
params.follow = K('follow');
syncTokens();
};
/* ---------- loop ---------- */
const clock = new THREE.Clock();
let raf = 0; let running = false;
function frame() {
raf = requestAnimationFrame(frame);
const dt = Math.min(clock.getDelta(), 0.05);
const t = clock.elapsedTime;
bgUniforms.uTime.value = t;
blobUniforms.uTime.value = t;
target.set(pointer.x * view.w * 0.5 * 0.82, pointer.y * view.h * 0.5 * 0.78, 0);
blob.position.lerp(target, params.follow);
blob.rotation.y += dt * 0.15;
blob.rotation.x = Math.sin(t * 0.21) * 0.25;
renderer.setRenderTarget(rt);
renderer.clear();
renderer.render(bgScene, orthoCam);
renderer.setRenderTarget(null);
renderer.clear();
renderer.render(screenScene, orthoCam);
renderer.render(scene, camera);
}
const start = () => { if (running || document.hidden) return; running = true; clock.start(); raf = requestAnimationFrame(frame); };
const stop = () => { running = false; cancelAnimationFrame(raf); clock.stop(); };
const onVisibility = () => (document.hidden ? stop() : start());
window.addEventListener('ml:pause', stop);
window.addEventListener('ml:resume', start);
document.addEventListener('visibilitychange', onVisibility);
window.addEventListener('resize', resize);
start();
window.MotionLab?.onCleanup(() => {
stop();
applyKnobs = null;
mo.disconnect();
window.removeEventListener('ml:pause', stop);
window.removeEventListener('ml:resume', start);
document.removeEventListener('visibilitychange', onVisibility);
window.removeEventListener('resize', resize);
root.removeEventListener('pointermove', onPointer);
root.removeEventListener('pointerdown', onPointer);
root.removeEventListener('pointerleave', onLeave);
geometry.dispose(); blobMat.dispose(); bgMat.dispose(); screenMat.dispose();
screenScene.children[0].geometry.dispose(); bgScene.children[0].geometry.dispose();
textTex.dispose(); rt.dispose();
renderer.dispose();
});
}
/* ---------- Playground entry point: CSS accent for the fallback + WebGL params ---------- */
function build() {
root.style.setProperty('--knob-accent', K('accent'));
applyKnobs?.();
}
build();
// Playground: re-apply the new values (uniforms update in place — no renderer rebuild needed)
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => window.removeEventListener('ml:knobs', build));
```
### EFFECT (Framer & Webflow notes): Refractive Glass Blob
**Framer**
- Implement as a **Code Component** (Assets → Code → New component) when the effect owns its own markup (hero, gallery, section), or as a **Code Override** when it should animate existing Framer layers.
- Code Component: paste the React version (`"use client"` is not needed in Framer; keep `useGSAP` with a `scope` ref). Install `gsap` and `@gsap/react` from the package panel. Expose the customization knobs as `addPropertyControls` (numbers/colors/enums) so they are editable on the canvas.
- Code Override: `export function withRefractiveGlassBlob(Component) { return (props) => { const ref = useRef(null); useGSAP(() => { /* spec */ }, { scope: ref }); return <Component ref={ref} {...props} /> } }` and apply it to the layer in the right-hand panel.
- Framer already smooth-scrolls; do **not** add Lenis.
- Preview in the Framer preview window, not the canvas (GSAP runs at runtime only).
- Three.js in Framer: fine inside a Code Component; dispose the renderer in the effect cleanup.
**Webflow**
- Build the markup in the Designer with **exactly the class names from the HTML structure section** (prefix `glass`), so the script finds the elements.
- Styles: either recreate the CSS requirements as Webflow classes or paste the CSS in **Page settings → Custom code → Inside <head>** inside `<style>`.
- Scripts: **Page settings → Before </body>**:
```html
<script type="module">
// paste the JS from the animation spec; remove the optional window.MotionLab?.… lines
</script>
```
- Native alternative: parts that are pure show/hide, fade or move can be Webflow Interactions (IX2); keep the timeline sequencing in code — IX2 cannot do stagger/ease control at this precision.
- Custom code only runs on the **published** site or in Preview, never on the Designer canvas.
- Webflow's own smooth scroll is off by default; if you add Lenis, sync it: `lenis.on('scroll', ScrollTrigger.update)`.
---
### 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 — the accent (`#d4ff3a`), 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 (bend strength 0.12), with the red/green/blue channels splitting slightly along steep edges (chromatic aberration, when `true` is on). The surface is pushed in and out by 0.35 of the radius at a noise frequency of 1.4. The silhouette glows with a thin white-to-accent 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 (0.08 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`, `ABERRATION = true`, accent colour `#d4ff3a`.
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, uAberration` — `uAberration` is `1.0` when `true` is true, else `0.0`):
```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 * mix(1.0, 0.94, uAberration)).r;
float cg = texture2D(tBg, uv + offset * 1.00).g;
float cb = texture2D(tBg, uv + offset * mix(1.0, 1.07, uAberration)).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` from `getComputedStyle(document.documentElement)` (the accent is `#d4ff3a`) 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` (true) — chromatic aberration on/off; spread (`0.94 / 1.00 / 1.07`) — widen to 0.9/1.0/1.12 for stronger fringing.
- `--accent` (#d4ff3a) — the first background blob, the Fresnel rim tint and the internal thickness tint.
- 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>
.glass { position: relative; height: 100vh; min-height: 480px; overflow: hidden; background: var(--bg); color: var(--text); touch-action: none; }
.glass__canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.glass__eyebrow, .glass__caption, .glass__corner {
position: absolute; font-family: var(--font-mono); font-size: 11px; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); pointer-events: none;
}
.glass__eyebrow { top: 2rem; left: 6vw; }
.glass__corner { top: 2rem; right: 6vw; }
.glass__caption { left: 6vw; right: 6vw; bottom: 2rem; text-transform: none; letter-spacing: .02em; font-family: var(--font-sans); font-size: clamp(.85rem, 1vw, .95rem); max-width: 44ch; }
/* Fallback / reduced motion: the same composition as the texture, static */
.glass__fallback {
position: absolute; inset: 0; display: grid; place-content: center; text-align: center; padding: 0 6vw;
background:
radial-gradient(45% 55% at 25% 30%, color-mix(in srgb, var(--knob-accent, var(--accent)) 40%, transparent), transparent 70%),
radial-gradient(40% 50% at 75% 70%, rgba(120, 90, 255, .35), transparent 70%),
radial-gradient(50% 50% at 60% 20%, rgba(255, 110, 70, .22), transparent 70%),
linear-gradient(180deg, var(--surface), var(--bg));
}
.glass__fallback h1 { font-family: var(--font-display); font-weight: 300; font-size: clamp(2.5rem, 8vw, 7.5rem); letter-spacing: -0.04em; line-height: .95; text-wrap: balance; }
.glass__fallback::after {
content: ''; position: absolute; left: 50%; top: 50%; width: min(42vw, 42vh); aspect-ratio: 1; translate: -50% -50%; border-radius: 50%;
background: radial-gradient(circle at 35% 30%, rgba(255,255,255,.35), rgba(255,255,255,.06) 55%, transparent 70%);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text) 25%, transparent), 0 40px 120px -40px rgba(0,0,0,.6);
backdrop-filter: blur(6px) saturate(1.2);
}
@media (max-width: 767px) {
.glass__eyebrow, .glass__corner { top: 1.4rem; }
.glass__corner { display: none; }
.glass__caption { bottom: 1.4rem; }
}
@media (prefers-reduced-motion: reduce) { .glass__canvas { display: none; } }
// Refractive Glass Blob — a noise-displaced icosahedron fakes glass by refracting a render target of the scene behind it.
import * as THREE from 'three';
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { noiseStrength: 0.35, noiseFreq: 1.4, refract: 0.12, follow: 0.08, accent: '#d4ff3a', aberration: true };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const root = document.querySelector('.glass');
const canvas = root.querySelector('.glass__canvas');
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const isMobile = matchMedia('(max-width: 767px)').matches;
const preview = !!window.MotionLab?.preview;
/* ---------- tokens (CSS vars → raw sRGB vec3, no colour-management conversion) ---------- */
const hexToVec3 = (hex) => {
const h = hex.trim().replace('#', '');
const n = parseInt(h.length === 3 ? h.split('').map((c) => c + c).join('') : h, 16);
return new THREE.Vector3(((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255);
};
const readTokens = () => {
const cs = getComputedStyle(document.documentElement);
const light = document.documentElement.getAttribute('data-theme') === 'light';
return {
bg: hexToVec3(cs.getPropertyValue('--bg') || '#0A0A0A'),
surface: hexToVec3(cs.getPropertyValue('--surface') || '#121212'),
text: hexToVec3(cs.getPropertyValue('--text') || '#EDEDED'),
accent: hexToVec3(K('accent')), // the accent is a Playground knob
light,
};
};
function showFallback() {
const fb = document.createElement('div');
fb.className = 'glass__fallback';
fb.innerHTML = '<h1>Through glass,<br>softly.</h1>';
root.appendChild(fb);
}
// Live parameters + the WebGL-side knob applier (set up by init); build() is the Playground entry point.
const params = { follow: DEFAULTS.follow };
let applyKnobs = null;
if (reduced) {
showFallback();
} else {
let renderer;
try {
renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false, powerPreference: 'high-performance' });
} catch (e) { renderer = null; }
if (!renderer) showFallback(); else init(renderer);
}
function init(renderer) {
const dprCap = isMobile ? 1.5 : 2;
const dpr = Math.min(window.devicePixelRatio || 1, dprCap) * (preview ? 0.5 : 1);
renderer.setPixelRatio(dpr);
renderer.autoClear = false;
const tokens = readTokens();
const size = new THREE.Vector2();
/* ---------- background scene: gradient + moving colour blobs + display text (CanvasTexture) ---------- */
const orthoCam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const bgScene = new THREE.Scene();
const textCanvas = document.createElement('canvas');
const textTex = new THREE.CanvasTexture(textCanvas);
textTex.minFilter = THREE.LinearFilter; textTex.generateMipmaps = false;
const bgUniforms = {
uTime: { value: 0 },
uAspect: { value: 1 },
uBg: { value: tokens.bg.clone() },
uSurface: { value: tokens.surface.clone() },
uAccent: { value: tokens.accent.clone() },
uLight: { value: tokens.light ? 1 : 0 },
tText: { value: textTex },
};
const bgMat = new THREE.ShaderMaterial({
uniforms: bgUniforms,
depthTest: false, depthWrite: false,
vertexShader: /* glsl */`
varying vec2 vUv;
void main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }`,
fragmentShader: /* glsl */`
precision highp float;
uniform float uTime, uAspect, uLight;
uniform vec3 uBg, uSurface, uAccent;
uniform sampler2D tText;
varying vec2 vUv;
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));
vec3 lime = uAccent;
vec3 violet = vec3(0.47, 0.35, 1.0);
vec3 ember = vec3(1.0, 0.43, 0.27);
vec3 teal = vec3(0.25, 0.80, 0.78);
float k = mix(0.55, 0.32, uLight);
col += lime * blob(vUv, c1, 0.30) * k;
col += violet * blob(vUv, c2, 0.28) * k * 0.9;
col += ember * blob(vUv, c3, 0.24) * k * 0.6;
col += teal * 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);
}`,
});
bgScene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), bgMat));
const rt = new THREE.WebGLRenderTarget(2, 2, { depthBuffer: false, stencilBuffer: false, minFilter: THREE.LinearFilter, magFilter: THREE.LinearFilter });
/* ---------- screen quad that shows the render target ---------- */
const screenScene = new THREE.Scene();
const screenMat = new THREE.ShaderMaterial({
uniforms: { tMap: { value: rt.texture } },
depthTest: false, depthWrite: false,
vertexShader: /* glsl */`varying vec2 vUv; void main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }`,
fragmentShader: /* glsl */`precision highp float; uniform sampler2D tMap; varying vec2 vUv; void main() { gl_FragColor = texture2D(tMap, vUv); }`,
});
screenScene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), screenMat));
/* ---------- blob scene ---------- */
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(30, 1, 0.1, 20);
camera.position.set(0, 0, 5);
const detail = isMobile ? 32 : 64;
const geometry = new THREE.IcosahedronGeometry(1, detail);
const blobUniforms = {
uTime: { value: 0 },
uNoiseStrength: { value: K('noiseStrength') }, // displacement along the normal
uNoiseFreq: { value: K('noiseFreq') }, // spatial frequency of the simplex field
uRefract: { value: K('refract') }, // screen-space UV offset strength
uAberration: { value: K('aberration') ? 1 : 0 }, // chromatic aberration on/off
uResolution: { value: new THREE.Vector2(1, 1) },
uAccent: { value: tokens.accent.clone() },
uLight: { value: tokens.light ? 1 : 0 },
tBg: { value: rt.texture },
};
const blobMat = new THREE.ShaderMaterial({
uniforms: blobUniforms,
vertexShader: /* glsl */`
uniform float uTime, uNoiseStrength, uNoiseFreq;
varying vec3 vNormal;
varying vec3 vViewPos;
varying float vNoise;
// Simplex 3D noise (Ashima Arts / Stefan Gustavson, MIT)
vec4 permute(vec4 x) { return mod(((x * 34.0) + 1.0) * x, 289.0); }
vec4 taylorInvSqrt(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; }
float snoise(vec3 v) {
const vec2 C = vec2(1.0 / 6.0, 1.0 / 3.0);
const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);
vec3 i = floor(v + dot(v, C.yyy));
vec3 x0 = v - i + dot(i, C.xxx);
vec3 g = step(x0.yzx, x0.xyz);
vec3 l = 1.0 - g;
vec3 i1 = min(g.xyz, l.zxy);
vec3 i2 = max(g.xyz, l.zxy);
vec3 x1 = x0 - i1 + C.xxx;
vec3 x2 = x0 - i2 + C.yyy;
vec3 x3 = x0 - D.yyy;
i = mod(i, 289.0);
vec4 p = permute(permute(permute(i.z + vec4(0.0, i1.z, i2.z, 1.0)) + i.y + vec4(0.0, i1.y, i2.y, 1.0)) + i.x + vec4(0.0, i1.x, i2.x, 1.0));
float n_ = 1.0 / 7.0;
vec3 ns = n_ * D.wyz - D.xzx;
vec4 j = p - 49.0 * floor(p * ns.z * ns.z);
vec4 x_ = floor(j * ns.z);
vec4 y_ = floor(j - 7.0 * x_);
vec4 x = x_ * ns.x + ns.yyyy;
vec4 y = y_ * ns.x + ns.yyyy;
vec4 h = 1.0 - abs(x) - abs(y);
vec4 b0 = vec4(x.xy, y.xy);
vec4 b1 = vec4(x.zw, y.zw);
vec4 s0 = floor(b0) * 2.0 + 1.0;
vec4 s1 = floor(b1) * 2.0 + 1.0;
vec4 sh = -step(h, vec4(0.0));
vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy;
vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww;
vec3 p0 = vec3(a0.xy, h.x);
vec3 p1 = vec3(a0.zw, h.y);
vec3 p2 = vec3(a1.xy, h.z);
vec3 p3 = vec3(a1.zw, h.w);
vec4 norm = taylorInvSqrt(vec4(dot(p0, p0), dot(p1, p1), dot(p2, p2), dot(p3, p3)));
p0 *= norm.x; p1 *= norm.y; p2 *= norm.z; p3 *= norm.w;
vec4 m = max(0.6 - vec4(dot(x0, x0), dot(x1, x1), dot(x2, x2), dot(x3, x3)), 0.0);
m = m * m;
return 42.0 * dot(m * m, vec4(dot(p0, x0), dot(p1, x1), dot(p2, x2), dot(p3, x3)));
}
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)); }
// Two octaves of simplex noise flowing through the unit sphere.
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;
}`,
fragmentShader: /* glsl */`
precision highp float;
uniform sampler2D tBg;
uniform vec2 uResolution;
uniform float uRefract, uLight, uAberration;
uniform vec3 uAccent;
varying vec3 vNormal;
varying vec3 vViewPos;
varying float vNoise;
void main() {
vec3 n = normalize(vNormal);
vec3 v = normalize(-vViewPos); // surface → eye, view space
vec2 uv = gl_FragCoord.xy / uResolution; // screen-space UV into the background render target
// Refraction: bend the eye ray through the surface (IOR 1.45) and use its xy as a UV offset.
vec3 r = refract(-v, n, 1.0 / 1.45);
vec2 offset = r.xy * uRefract;
// Chromatic aberration: three taps with slightly different offsets (collapse to one when uAberration = 0).
float cr = texture2D(tBg, uv + offset * mix(1.0, 0.94, uAberration)).r;
float cg = texture2D(tBg, uv + offset * 1.00).g;
float cb = texture2D(tBg, uv + offset * mix(1.0, 1.07, uAberration)).b;
vec3 col = vec3(cr, cg, cb);
// Fresnel rim, tinted between white and the accent.
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);
// Specular highlight from a key light up-right.
vec3 l = normalize(vec3(0.55, 0.8, 1.0));
vec3 h = normalize(l + v);
float spec = pow(max(dot(n, h), 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;
// Thickness: slightly brighten the body and add a faint internal tint where the surface is pushed outward.
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);
}`,
});
const blob = new THREE.Mesh(geometry, blobMat);
scene.add(blob);
/* ---------- text texture ---------- */
function drawText() {
const w = Math.min(Math.round(size.x * dpr), 2048);
const h = Math.round(w * (size.y / size.x));
textCanvas.width = w; textCanvas.height = h;
const c = textCanvas.getContext('2d');
c.clearRect(0, 0, w, h);
const t = readTokens();
const col = `rgb(${Math.round(t.text.x * 255)}, ${Math.round(t.text.y * 255)}, ${Math.round(t.text.z * 255)})`;
const fs = Math.min(w * 0.115, h * 0.26);
c.fillStyle = col;
c.textAlign = 'center'; c.textBaseline = 'alphabetic';
c.font = `300 ${fs}px Fraunces, Georgia, serif`;
const letter = -0.04 * fs;
if ('letterSpacing' in c) c.letterSpacing = `${letter}px`;
c.fillText('Through glass,', w / 2, h / 2 - fs * 0.12);
c.fillText('softly.', w / 2, h / 2 + fs * 0.82);
textTex.needsUpdate = true;
}
/* ---------- sizing ---------- */
const viewAt = (z) => {
const hgt = 2 * Math.tan(THREE.MathUtils.degToRad(camera.fov) / 2) * (camera.position.z - z);
return { w: hgt * camera.aspect, h: hgt };
};
let view = { w: 1, h: 1 };
function resize() {
size.set(root.clientWidth, root.clientHeight);
renderer.setSize(size.x, size.y, false);
camera.aspect = size.x / size.y;
camera.updateProjectionMatrix();
const buf = renderer.getDrawingBufferSize(new THREE.Vector2());
rt.setSize(buf.x, buf.y);
blobUniforms.uResolution.value.copy(buf);
bgUniforms.uAspect.value = size.x / size.y;
view = viewAt(0);
const s = THREE.MathUtils.clamp(Math.min(view.w, view.h) * 0.27, 0.42, 0.85);
blob.scale.setScalar(s);
drawText();
}
resize();
document.fonts.ready.then(drawText);
document.fonts.load('300 100px Fraunces').then(drawText).catch(() => {});
/* ---------- pointer follow ---------- */
const pointer = new THREE.Vector2(0, 0); // NDC
const target = new THREE.Vector3(0, 0, 0);
const onPointer = (e) => {
const r = root.getBoundingClientRect();
pointer.set(((e.clientX - r.left) / r.width) * 2 - 1, -(((e.clientY - r.top) / r.height) * 2 - 1));
};
const onLeave = () => pointer.set(0, 0);
root.addEventListener('pointermove', onPointer, { passive: true });
root.addEventListener('pointerdown', onPointer, { passive: true });
root.addEventListener('pointerleave', onLeave);
/* ---------- theme sync (also re-reads the accent knob) ---------- */
const syncTokens = () => {
const t = readTokens();
bgUniforms.uBg.value.copy(t.bg); bgUniforms.uSurface.value.copy(t.surface); bgUniforms.uAccent.value.copy(t.accent);
bgUniforms.uLight.value = t.light ? 1 : 0;
blobUniforms.uAccent.value.copy(t.accent); blobUniforms.uLight.value = t.light ? 1 : 0;
drawText();
};
const mo = new MutationObserver(syncTokens);
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
/* ---------- knobs → uniforms / params (the renderer and scene are kept; the change is visible on the next frame) ---------- */
applyKnobs = () => {
blobUniforms.uNoiseStrength.value = K('noiseStrength');
blobUniforms.uNoiseFreq.value = K('noiseFreq');
blobUniforms.uRefract.value = K('refract');
blobUniforms.uAberration.value = K('aberration') ? 1 : 0;
params.follow = K('follow');
syncTokens();
};
/* ---------- loop ---------- */
const clock = new THREE.Clock();
let raf = 0; let running = false;
function frame() {
raf = requestAnimationFrame(frame);
const dt = Math.min(clock.getDelta(), 0.05);
const t = clock.elapsedTime;
bgUniforms.uTime.value = t;
blobUniforms.uTime.value = t;
target.set(pointer.x * view.w * 0.5 * 0.82, pointer.y * view.h * 0.5 * 0.78, 0);
blob.position.lerp(target, params.follow);
blob.rotation.y += dt * 0.15;
blob.rotation.x = Math.sin(t * 0.21) * 0.25;
renderer.setRenderTarget(rt);
renderer.clear();
renderer.render(bgScene, orthoCam);
renderer.setRenderTarget(null);
renderer.clear();
renderer.render(screenScene, orthoCam);
renderer.render(scene, camera);
}
const start = () => { if (running || document.hidden) return; running = true; clock.start(); raf = requestAnimationFrame(frame); };
const stop = () => { running = false; cancelAnimationFrame(raf); clock.stop(); };
const onVisibility = () => (document.hidden ? stop() : start());
window.addEventListener('ml:pause', stop);
window.addEventListener('ml:resume', start);
document.addEventListener('visibilitychange', onVisibility);
window.addEventListener('resize', resize);
start();
window.MotionLab?.onCleanup(() => {
stop();
applyKnobs = null;
mo.disconnect();
window.removeEventListener('ml:pause', stop);
window.removeEventListener('ml:resume', start);
document.removeEventListener('visibilitychange', onVisibility);
window.removeEventListener('resize', resize);
root.removeEventListener('pointermove', onPointer);
root.removeEventListener('pointerdown', onPointer);
root.removeEventListener('pointerleave', onLeave);
geometry.dispose(); blobMat.dispose(); bgMat.dispose(); screenMat.dispose();
screenScene.children[0].geometry.dispose(); bgScene.children[0].geometry.dispose();
textTex.dispose(); rt.dispose();
renderer.dispose();
});
}
/* ---------- Playground entry point: CSS accent for the fallback + WebGL params ---------- */
function build() {
root.style.setProperty('--knob-accent', K('accent'));
applyKnobs?.();
}
build();
// Playground: re-apply the new values (uniforms update in place — no renderer rebuild needed)
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => window.removeEventListener('ml:knobs', build));