Noise Gradient Mesh Background
A full-screen fragment shader blends four drifting colour fields through domain-warped simplex noise, adds animated film grain and a soft vignette, and lets the pointer gently pull the warp; a CSS gradient stands in when WebGL or motion is unavailable.
- Backgrounds
- threejs
- glsl
- advanced
- medium perf
- mobile: full
- reduced-motion ✓
AI prompt
Recreate this effect with any AI tool
Paste into Claude, Cursor, v0, Lovable or Bolt. 1788 words of exact values — rewritten live as you tune the Playground.
### EFFECT: Noise Gradient Mesh Background
**What it does (visual):** The whole viewport is a slowly moving painting: a deep navy field (`#1b2f6e`), a plum field, a muted lime field (`#93b04f`) and an ink-black field drift on gentle looping paths (one full cycle takes 20–35s at speed 1), and where they meet the boundaries are not straight blends but folded, marbled edges — the colours look like ink dispersing in water because the lookup position is warped (amplitude 0.55) by layered simplex noise that itself slowly flows. Fine animated film grain (amplitude 0.07) sits over everything and the corners fall off into a soft vignette (strength 0.85). Moving the pointer gently drags the folds toward it (the warp centre follows with a long lag), so the picture responds without ever feeling like a cursor effect. Over the canvas, bottom-left, a two-line thin serif headline ("Colour that / *breathes*") rises line by line through a mask, followed by a muted paragraph and a lime pill CTA. If WebGL is unavailable or the user prefers reduced motion, the same four colours render as a static layered CSS radial-gradient with SVG grain.
**Tech stack:**
- Three.js r160+ (`import * as THREE from 'three'`) used only for the `WebGLRenderer`, an `OrthographicCamera(-1, 1, 1, -1, 0, 1)` and a `ShaderMaterial` on a `PlaneGeometry(2, 2)`.
- GLSL ES 1.0 fragment shader (below). No textures, no external assets.
- GSAP 3.13+ (core) for the DOM intro only. CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>`.
**HTML structure:**
```html
<section class="ngm" data-speed="1">
<div class="ngm__canvas"></div> <!-- renderer canvas appended here -->
<div class="ngm__fallback"></div> <!-- CSS gradient, shown only with .is-static -->
<div class="ngm__content">
<p class="ngm__eyebrow">Background · Fragment shader</p>
<h1 class="ngm__title"><span class="ngm__line"><span class="ngm__word">Colour that</span></span><span class="ngm__line"><em class="ngm__word">breathes</em></span></h1>
<p class="ngm__copy">Four slow colour fields, folded through domain-warped simplex noise…</p>
<a class="ngm__cta" href="#collection">Explore the collection <span>→</span></a>
</div>
<p class="ngm__meta">Simplex · fbm · domain warp · grain</p>
</section>
```
**CSS requirements:**
- `.ngm`: `position: relative; min-height: 100vh; display: grid; align-items: end; overflow: hidden; isolation: isolate; background: var(--bg, #0a0a0a); color: var(--text, #ededed);`
- `.ngm__canvas`, `.ngm__fallback`: `position: absolute; inset: 0; z-index: 0;` canvas element `width: 100%; height: 100%; display: block`.
- `.ngm__fallback`: `display: none;` background = `radial-gradient(60% 55% at 28% 36%, #1b2f6e, transparent 70%), radial-gradient(55% 60% at 76% 30%, #4a1f4e, transparent 70%), radial-gradient(50% 45% at 56% 80%, #93b04f, transparent 70%), radial-gradient(45% 50% at 18% 82%, #0a0a0a, transparent 70%), #0a0a0a`; `::before` = inline SVG `feTurbulence` (`baseFrequency .9`, 2 octaves) tile at `opacity .28; mix-blend-mode: overlay`; `::after` = vignette `radial-gradient(70% 70% at 50% 50%, transparent 50%, rgba(0,0,0,.55))`. `.ngm.is-static .ngm__fallback { display: block } .ngm.is-static .ngm__canvas { display: none }`. Light theme swaps the four colours for `#c9d4f0 #e4cfea #dce9a8 #f4f2ee`.
- `.ngm__content`: `position: relative; z-index: 1; padding: clamp(1.5rem, 6vw, 5rem); max-width: 56rem;`
- `.ngm__title`: display serif `font-weight: 300; font-size: clamp(3rem, 9vw, 9rem); line-height: .9; letter-spacing: -0.04em;` `em` italic. `.ngm__line { display: block; overflow: hidden; padding-bottom: .08em; margin-bottom: -.08em; }` (mask for the rise); `.ngm__word { display: inline-block; will-change: transform }` is what actually translates.
- `.ngm__copy`: `max-width: 44ch; color: var(--muted); font-size: clamp(.95rem, 1.2vw, 1.15rem); line-height: 1.5; margin-top: 1.6rem;`
- `.ngm__cta`: pill, `background: var(--accent, #D4FF3A); color: #0a0a0a; padding: .85em 1.5em; border-radius: 999px; margin-top: 2.2rem; font-size: .9rem; font-weight: 500;` hover `translateY(-2px)` + arrow `translateX(4px)` over `.35s cubic-bezier(.2,.7,.2,1)`.
- `.ngm__meta`: mono 11px uppercase muted, `position: absolute; right/bottom: clamp(1.5rem, 6vw, 5rem)`; hidden under 768px.
**Animation spec (exact values):**
1. **Setup**: `WebGLRenderer({ antialias: false, alpha: false })`, `setPixelRatio(Math.min(devicePixelRatio, 1.5))`; `OrthographicCamera(-1, 1, 1, -1, 0, 1)`; `Mesh(PlaneGeometry(2, 2), ShaderMaterial({ depthTest: false, depthWrite: false }))`.
2. **Uniforms**: `uTime` (s, accumulated from a clamped clock delta ≤ 0.05), `uRes` (drawing-buffer px), `uPointer` (vec2 0..1, y up, lerped `0.04`/frame toward the pointer; returns to `(0.5, 0.5)` on `pointerleave`), `uSpeed` (`data-speed`, default 1), `uColors` (`vec3[4]` raw sRGB: `#1b2f6e`, `#4a1f4e`, `#93b04f`, `#0a0a0a`; light theme `#c9d4f0 #e4cfea #dce9a8 #f4f2ee`, swapped by a `MutationObserver` on `data-theme`), `uGrain 0.07`, `uVignette 0.85`, `uWarp 0.55`.
3. **Vertex shader**: `varying vec2 vUv; void main(){ vUv = uv; gl_Position = vec4(position, 1.0); }`
4. **Fragment shader (complete)**:
```glsl
precision highp float;
uniform float uTime; uniform vec2 uRes; uniform vec2 uPointer; uniform float uSpeed;
uniform vec3 uColors[4]; uniform float uGrain; uniform float uVignette; uniform float uWarp;
varying vec2 vUv;
vec3 mod289(vec3 x){ return x - floor(x * (1.0/289.0)) * 289.0; }
vec2 mod289(vec2 x){ return x - floor(x * (1.0/289.0)) * 289.0; }
vec3 permute(vec3 x){ return mod289(((x*34.0)+1.0)*x); }
float snoise(vec2 v){
const vec4 C = vec4(0.211324865405187, 0.366025403784439, -0.577350269189626, 0.024390243902439);
vec2 i = floor(v + dot(v, C.yy)); vec2 x0 = v - i + dot(i, C.xx);
vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
vec4 x12 = x0.xyxy + C.xxzz; x12.xy -= i1; i = mod289(i);
vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0));
vec3 m = max(0.5 - vec3(dot(x0,x0), dot(x12.xy,x12.xy), dot(x12.zw,x12.zw)), 0.0); m = m*m; m = m*m;
vec3 x = 2.0 * fract(p * C.www) - 1.0; vec3 h = abs(x) - 0.5; vec3 ox = floor(x + 0.5); vec3 a0 = x - ox;
m *= 1.79284291400159 - 0.85373472095314 * (a0*a0 + h*h);
vec3 g; g.x = a0.x * x0.x + h.x * x0.y; g.yz = a0.yz * x12.xz + h.yz * x12.yw;
return 130.0 * dot(m, g);
}
float fbm(vec2 p){ float v = 0.0, a = 0.5; mat2 rot = mat2(0.8, 0.6, -0.6, 0.8);
for (int i = 0; i < 4; i++){ v += a * snoise(p); p = rot * p * 2.0 + 10.0; a *= 0.5; } return v; }
float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
void main(){
float aspect = uRes.x / uRes.y; vec2 uv = vUv; vec2 p = vec2(uv.x * aspect, uv.y); float t = uTime * uSpeed;
vec2 pc = vec2(uPointer.x * aspect, uPointer.y); vec2 toP = p - pc; float pull = exp(-dot(toP, toP) * 1.6);
vec2 q = vec2(fbm(p * 1.4 + t * 0.15), fbm(p * 1.4 - t * 0.12 + 5.2));
vec2 r = vec2(fbm(p * 1.4 + q * 1.6 + vec2(1.7, 9.2) + t * 0.10), fbm(p * 1.4 + q * 1.6 + vec2(8.3, 2.8) - t * 0.08));
vec2 w = p + r * uWarp - toP * pull * 0.25;
vec2 c0 = vec2(0.30 + 0.22 * sin(t * 0.31), 0.35 + 0.20 * cos(t * 0.23));
vec2 c1 = vec2(0.75 + 0.20 * cos(t * 0.27 + 1.3), 0.30 + 0.24 * sin(t * 0.19 + 2.1));
vec2 c2 = vec2(0.55 + 0.26 * sin(t * 0.17 + 4.2), 0.78 + 0.16 * cos(t * 0.29 + 0.7));
vec2 c3 = vec2(0.20 + 0.18 * cos(t * 0.21 + 2.6), 0.80 + 0.18 * sin(t * 0.33 + 1.1));
c0.x *= aspect; c1.x *= aspect; c2.x *= aspect; c3.x *= aspect;
float w0 = exp(-dot(w - c0, w - c0) * 2.6); float w1 = exp(-dot(w - c1, w - c1) * 3.0);
float w2 = exp(-dot(w - c2, w - c2) * 3.4); float w3 = exp(-dot(w - c3, w - c3) * 3.0);
float sum = w0 + w1 + w2 + w3 + 1e-4;
vec3 col = (uColors[0]*w0 + uColors[1]*w1 + uColors[2]*w2 + uColors[3]*w3) / sum;
col = mix(uColors[3], col, clamp(sum * 0.9, 0.0, 1.0));
col += fbm(w * 2.5 + t * 0.05) * 0.12;
col += (hash(uv * uRes + fract(uTime) * 100.0) - 0.5) * uGrain;
vec2 vc = uv - 0.5; float vig = 1.0 - smoothstep(0.35, 0.95, dot(vc, vc) * 2.2);
col *= mix(1.0, vig, uVignette);
gl_FragColor = vec4(col, 1.0);
}
```
5. **Loop**: every frame `uTime += dt; uPointer.lerp(target, 0.04); renderer.render(scene, camera)`.
6. **DOM intro** (GSAP, skipped under reduced motion): `gsap.from('.ngm__word', { yPercent: 110, duration: 1.2, ease: 'power4.out', stagger: 0.1, delay: 0.2 })` — the lines rise through their `overflow: hidden` mask; then `gsap.from(['.ngm__eyebrow', '.ngm__copy', '.ngm__cta', '.ngm__meta'], { y: 18, opacity: 0, duration: 1, ease: 'power3.out', stagger: 0.08, delay: 0.6 })`.
**Interaction:** Pointer position over the section sets the warp target; the uniform follows with `lerp 0.04` (≈ 1.2s to settle) and the folds bend toward it with a Gaussian falloff (`exp(-d² × 1.6)`) and a maximum displacement of `0.25` units. Leaving the section returns the centre to the middle. CTA hover lifts 2px and nudges the arrow 4px over 0.35s.
**Responsive behavior:** The shader is resolution-independent (aspect-corrected in `p`), so it simply fills any viewport; the renderer resizes from a `ResizeObserver` on the wrapper and updates `uRes`. Under 768px the mono meta line is hidden and the copy caps at 32ch; the headline scales with `clamp(3rem, 9vw, 9rem)`. On touch devices the pointer pull only happens during a touch-move (no hover), which is fine — the field still drifts on its own.
**Accessibility:** `prefers-reduced-motion: reduce` → no renderer is created; `.ngm` gets `.is-static`, showing the CSS gradient fallback (same palette, SVG grain, vignette) with the content fully visible and no intro. The same fallback is used when `canvas.getContext('webgl2'|'webgl')` returns null. Canvas and fallback are `aria-hidden`; the headline is a real `<h1>`; the CTA is an `<a>` with a visible `outline: 2px solid var(--text)` focus ring and dark-on-lime text (contrast > 10:1).
**Performance rules:** DPR capped at `Math.min(devicePixelRatio, 1.5)` (0.75 in the library hover preview). One draw call, one quad, zero textures. `antialias: false` (the grain hides aliasing anyway). Stop the rAF loop on `ml:pause` and `visibilitychange`, resume on `ml:resume`; `dt` is clamped to 0.05s. Cleanup disposes the geometry, material and renderer, disconnects the `ResizeObserver` and `MutationObserver`, removes listeners and the canvas. The DOM intro lives in `gsap.context()` and is reverted on unmount.
**Customization knobs:**
- `data-speed` on the section → `uSpeed` (1 = demo; 0.5 is meditative, 2 is lively).
- `PALETTE_DARK` / `PALETTE_LIGHT` — four hex colours; order = navy (#1b2f6e), plum, lime (#93b04f), ink (the fourth also fills empty space).
- `GRAIN` (0.07), `VIGNETTE` (0.85), `WARP` (0.55).
- Field tightness: the `2.6`/`3.0`/`3.4` in `exp(-d² × k)` — larger = smaller, crisper blobs.
- Pointer pull: `1.6` (radius) and `0.25` (strength) in the shader; `POINTER_LERP` (0.04).
- Octaves in `fbm` (4) — drop to 3 for low-end devices.
**Acceptance criteria:**
- [ ] The background is in motion from the first frame with marbled, non-linear colour boundaries (not simple radial blends).
- [ ] All four palette colours are visible somewhere on screen at 1200×750 and at 375×812.
- [ ] Moving the pointer bends the field toward it with visible lag; nothing snaps.
- [ ] Grain is fine and animated; corners are darker than the centre.
- [ ] Headline lines rise through a mask over 1.2s with a 0.1s stagger; copy and CTA follow.
- [ ] With reduced motion or no WebGL, the CSS gradient fallback shows the same palette and the content is immediately visible.
- [ ] No console errors; renderer DPR never exceeds 1.5. Add this effect to my existing site/section: **Noise Gradient Mesh Background**. Behind my hero content, mount a Three.js `WebGLRenderer` (DPR ≤ 1.5, `antialias: false`) rendering a single `PlaneGeometry(2, 2)` with an `OrthographicCamera(-1, 1, 1, -1, 0, 1)` and a `ShaderMaterial` whose vertex shader is `gl_Position = vec4(position, 1.0)` and whose fragment shader is the one in the animation spec above (simplex `snoise`, 4-octave `fbm`, domain warp `q → r` with `uWarp 0.55`, four Gaussian colour fields on the listed Lissajous paths, `uGrain 0.07`, `uVignette 0.85`, `uSpeed 1`). Pass my brand colours as `uColors` (vec3[4], raw sRGB — the demo uses `#1b2f6e`, `#4a1f4e`, `#93b04f`, `#0a0a0a`), accumulate `uTime` from a clamped clock delta, and lerp `uPointer` toward the normalised pointer position at `0.04` per frame. Add a `.is-static` CSS radial-gradient fallback with the same colours for `prefers-reduced-motion` and when WebGL is unavailable. Pause the loop when hidden and dispose on unmount. Keep my headline, copy, fonts and layout — only the background changes. ### EFFECT (React / Next.js): Noise Gradient Mesh Background
Build **Noise Gradient Mesh Background** 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 @gsap/react three`
- File: `components/NoiseGradientMeshBackground.tsx`, first line `"use client";` (GSAP touches the DOM).
- Imports:
```tsx
"use client";
import { useRef } from "react";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
import * as THREE from "three";
gsap.registerPlugin(useGSAP);
export default function NoiseGradientMeshBackground() {
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="ngm">
{/* 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 (`NoiseGradientMeshBackground.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: Noise Gradient Mesh Background
**What it does (visual):** The whole viewport is a slowly moving painting: a deep navy field (`#1b2f6e`), a plum field, a muted lime field (`#93b04f`) and an ink-black field drift on gentle looping paths (one full cycle takes 20–35s at speed 1), and where they meet the boundaries are not straight blends but folded, marbled edges — the colours look like ink dispersing in water because the lookup position is warped (amplitude 0.55) by layered simplex noise that itself slowly flows. Fine animated film grain (amplitude 0.07) sits over everything and the corners fall off into a soft vignette (strength 0.85). Moving the pointer gently drags the folds toward it (the warp centre follows with a long lag), so the picture responds without ever feeling like a cursor effect. Over the canvas, bottom-left, a two-line thin serif headline ("Colour that / *breathes*") rises line by line through a mask, followed by a muted paragraph and a lime pill CTA. If WebGL is unavailable or the user prefers reduced motion, the same four colours render as a static layered CSS radial-gradient with SVG grain.
**Tech stack:**
- Three.js r160+ (`import * as THREE from 'three'`) used only for the `WebGLRenderer`, an `OrthographicCamera(-1, 1, 1, -1, 0, 1)` and a `ShaderMaterial` on a `PlaneGeometry(2, 2)`.
- GLSL ES 1.0 fragment shader (below). No textures, no external assets.
- GSAP 3.13+ (core) for the DOM intro only. CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>`.
**HTML structure:**
```html
<section class="ngm" data-speed="1">
<div class="ngm__canvas"></div> <!-- renderer canvas appended here -->
<div class="ngm__fallback"></div> <!-- CSS gradient, shown only with .is-static -->
<div class="ngm__content">
<p class="ngm__eyebrow">Background · Fragment shader</p>
<h1 class="ngm__title"><span class="ngm__line"><span class="ngm__word">Colour that</span></span><span class="ngm__line"><em class="ngm__word">breathes</em></span></h1>
<p class="ngm__copy">Four slow colour fields, folded through domain-warped simplex noise…</p>
<a class="ngm__cta" href="#collection">Explore the collection <span>→</span></a>
</div>
<p class="ngm__meta">Simplex · fbm · domain warp · grain</p>
</section>
```
**CSS requirements:**
- `.ngm`: `position: relative; min-height: 100vh; display: grid; align-items: end; overflow: hidden; isolation: isolate; background: var(--bg, #0a0a0a); color: var(--text, #ededed);`
- `.ngm__canvas`, `.ngm__fallback`: `position: absolute; inset: 0; z-index: 0;` canvas element `width: 100%; height: 100%; display: block`.
- `.ngm__fallback`: `display: none;` background = `radial-gradient(60% 55% at 28% 36%, #1b2f6e, transparent 70%), radial-gradient(55% 60% at 76% 30%, #4a1f4e, transparent 70%), radial-gradient(50% 45% at 56% 80%, #93b04f, transparent 70%), radial-gradient(45% 50% at 18% 82%, #0a0a0a, transparent 70%), #0a0a0a`; `::before` = inline SVG `feTurbulence` (`baseFrequency .9`, 2 octaves) tile at `opacity .28; mix-blend-mode: overlay`; `::after` = vignette `radial-gradient(70% 70% at 50% 50%, transparent 50%, rgba(0,0,0,.55))`. `.ngm.is-static .ngm__fallback { display: block } .ngm.is-static .ngm__canvas { display: none }`. Light theme swaps the four colours for `#c9d4f0 #e4cfea #dce9a8 #f4f2ee`.
- `.ngm__content`: `position: relative; z-index: 1; padding: clamp(1.5rem, 6vw, 5rem); max-width: 56rem;`
- `.ngm__title`: display serif `font-weight: 300; font-size: clamp(3rem, 9vw, 9rem); line-height: .9; letter-spacing: -0.04em;` `em` italic. `.ngm__line { display: block; overflow: hidden; padding-bottom: .08em; margin-bottom: -.08em; }` (mask for the rise); `.ngm__word { display: inline-block; will-change: transform }` is what actually translates.
- `.ngm__copy`: `max-width: 44ch; color: var(--muted); font-size: clamp(.95rem, 1.2vw, 1.15rem); line-height: 1.5; margin-top: 1.6rem;`
- `.ngm__cta`: pill, `background: var(--accent, #D4FF3A); color: #0a0a0a; padding: .85em 1.5em; border-radius: 999px; margin-top: 2.2rem; font-size: .9rem; font-weight: 500;` hover `translateY(-2px)` + arrow `translateX(4px)` over `.35s cubic-bezier(.2,.7,.2,1)`.
- `.ngm__meta`: mono 11px uppercase muted, `position: absolute; right/bottom: clamp(1.5rem, 6vw, 5rem)`; hidden under 768px.
**Animation spec (exact values):**
1. **Setup**: `WebGLRenderer({ antialias: false, alpha: false })`, `setPixelRatio(Math.min(devicePixelRatio, 1.5))`; `OrthographicCamera(-1, 1, 1, -1, 0, 1)`; `Mesh(PlaneGeometry(2, 2), ShaderMaterial({ depthTest: false, depthWrite: false }))`.
2. **Uniforms**: `uTime` (s, accumulated from a clamped clock delta ≤ 0.05), `uRes` (drawing-buffer px), `uPointer` (vec2 0..1, y up, lerped `0.04`/frame toward the pointer; returns to `(0.5, 0.5)` on `pointerleave`), `uSpeed` (`data-speed`, default 1), `uColors` (`vec3[4]` raw sRGB: `#1b2f6e`, `#4a1f4e`, `#93b04f`, `#0a0a0a`; light theme `#c9d4f0 #e4cfea #dce9a8 #f4f2ee`, swapped by a `MutationObserver` on `data-theme`), `uGrain 0.07`, `uVignette 0.85`, `uWarp 0.55`.
3. **Vertex shader**: `varying vec2 vUv; void main(){ vUv = uv; gl_Position = vec4(position, 1.0); }`
4. **Fragment shader (complete)**:
```glsl
precision highp float;
uniform float uTime; uniform vec2 uRes; uniform vec2 uPointer; uniform float uSpeed;
uniform vec3 uColors[4]; uniform float uGrain; uniform float uVignette; uniform float uWarp;
varying vec2 vUv;
vec3 mod289(vec3 x){ return x - floor(x * (1.0/289.0)) * 289.0; }
vec2 mod289(vec2 x){ return x - floor(x * (1.0/289.0)) * 289.0; }
vec3 permute(vec3 x){ return mod289(((x*34.0)+1.0)*x); }
float snoise(vec2 v){
const vec4 C = vec4(0.211324865405187, 0.366025403784439, -0.577350269189626, 0.024390243902439);
vec2 i = floor(v + dot(v, C.yy)); vec2 x0 = v - i + dot(i, C.xx);
vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
vec4 x12 = x0.xyxy + C.xxzz; x12.xy -= i1; i = mod289(i);
vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0));
vec3 m = max(0.5 - vec3(dot(x0,x0), dot(x12.xy,x12.xy), dot(x12.zw,x12.zw)), 0.0); m = m*m; m = m*m;
vec3 x = 2.0 * fract(p * C.www) - 1.0; vec3 h = abs(x) - 0.5; vec3 ox = floor(x + 0.5); vec3 a0 = x - ox;
m *= 1.79284291400159 - 0.85373472095314 * (a0*a0 + h*h);
vec3 g; g.x = a0.x * x0.x + h.x * x0.y; g.yz = a0.yz * x12.xz + h.yz * x12.yw;
return 130.0 * dot(m, g);
}
float fbm(vec2 p){ float v = 0.0, a = 0.5; mat2 rot = mat2(0.8, 0.6, -0.6, 0.8);
for (int i = 0; i < 4; i++){ v += a * snoise(p); p = rot * p * 2.0 + 10.0; a *= 0.5; } return v; }
float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
void main(){
float aspect = uRes.x / uRes.y; vec2 uv = vUv; vec2 p = vec2(uv.x * aspect, uv.y); float t = uTime * uSpeed;
vec2 pc = vec2(uPointer.x * aspect, uPointer.y); vec2 toP = p - pc; float pull = exp(-dot(toP, toP) * 1.6);
vec2 q = vec2(fbm(p * 1.4 + t * 0.15), fbm(p * 1.4 - t * 0.12 + 5.2));
vec2 r = vec2(fbm(p * 1.4 + q * 1.6 + vec2(1.7, 9.2) + t * 0.10), fbm(p * 1.4 + q * 1.6 + vec2(8.3, 2.8) - t * 0.08));
vec2 w = p + r * uWarp - toP * pull * 0.25;
vec2 c0 = vec2(0.30 + 0.22 * sin(t * 0.31), 0.35 + 0.20 * cos(t * 0.23));
vec2 c1 = vec2(0.75 + 0.20 * cos(t * 0.27 + 1.3), 0.30 + 0.24 * sin(t * 0.19 + 2.1));
vec2 c2 = vec2(0.55 + 0.26 * sin(t * 0.17 + 4.2), 0.78 + 0.16 * cos(t * 0.29 + 0.7));
vec2 c3 = vec2(0.20 + 0.18 * cos(t * 0.21 + 2.6), 0.80 + 0.18 * sin(t * 0.33 + 1.1));
c0.x *= aspect; c1.x *= aspect; c2.x *= aspect; c3.x *= aspect;
float w0 = exp(-dot(w - c0, w - c0) * 2.6); float w1 = exp(-dot(w - c1, w - c1) * 3.0);
float w2 = exp(-dot(w - c2, w - c2) * 3.4); float w3 = exp(-dot(w - c3, w - c3) * 3.0);
float sum = w0 + w1 + w2 + w3 + 1e-4;
vec3 col = (uColors[0]*w0 + uColors[1]*w1 + uColors[2]*w2 + uColors[3]*w3) / sum;
col = mix(uColors[3], col, clamp(sum * 0.9, 0.0, 1.0));
col += fbm(w * 2.5 + t * 0.05) * 0.12;
col += (hash(uv * uRes + fract(uTime) * 100.0) - 0.5) * uGrain;
vec2 vc = uv - 0.5; float vig = 1.0 - smoothstep(0.35, 0.95, dot(vc, vc) * 2.2);
col *= mix(1.0, vig, uVignette);
gl_FragColor = vec4(col, 1.0);
}
```
5. **Loop**: every frame `uTime += dt; uPointer.lerp(target, 0.04); renderer.render(scene, camera)`.
6. **DOM intro** (GSAP, skipped under reduced motion): `gsap.from('.ngm__word', { yPercent: 110, duration: 1.2, ease: 'power4.out', stagger: 0.1, delay: 0.2 })` — the lines rise through their `overflow: hidden` mask; then `gsap.from(['.ngm__eyebrow', '.ngm__copy', '.ngm__cta', '.ngm__meta'], { y: 18, opacity: 0, duration: 1, ease: 'power3.out', stagger: 0.08, delay: 0.6 })`.
**Interaction:** Pointer position over the section sets the warp target; the uniform follows with `lerp 0.04` (≈ 1.2s to settle) and the folds bend toward it with a Gaussian falloff (`exp(-d² × 1.6)`) and a maximum displacement of `0.25` units. Leaving the section returns the centre to the middle. CTA hover lifts 2px and nudges the arrow 4px over 0.35s.
**Responsive behavior:** The shader is resolution-independent (aspect-corrected in `p`), so it simply fills any viewport; the renderer resizes from a `ResizeObserver` on the wrapper and updates `uRes`. Under 768px the mono meta line is hidden and the copy caps at 32ch; the headline scales with `clamp(3rem, 9vw, 9rem)`. On touch devices the pointer pull only happens during a touch-move (no hover), which is fine — the field still drifts on its own.
**Accessibility:** `prefers-reduced-motion: reduce` → no renderer is created; `.ngm` gets `.is-static`, showing the CSS gradient fallback (same palette, SVG grain, vignette) with the content fully visible and no intro. The same fallback is used when `canvas.getContext('webgl2'|'webgl')` returns null. Canvas and fallback are `aria-hidden`; the headline is a real `<h1>`; the CTA is an `<a>` with a visible `outline: 2px solid var(--text)` focus ring and dark-on-lime text (contrast > 10:1).
**Performance rules:** DPR capped at `Math.min(devicePixelRatio, 1.5)` (0.75 in the library hover preview). One draw call, one quad, zero textures. `antialias: false` (the grain hides aliasing anyway). Stop the rAF loop on `ml:pause` and `visibilitychange`, resume on `ml:resume`; `dt` is clamped to 0.05s. Cleanup disposes the geometry, material and renderer, disconnects the `ResizeObserver` and `MutationObserver`, removes listeners and the canvas. The DOM intro lives in `gsap.context()` and is reverted on unmount.
**Customization knobs:**
- `data-speed` on the section → `uSpeed` (1 = demo; 0.5 is meditative, 2 is lively).
- `PALETTE_DARK` / `PALETTE_LIGHT` — four hex colours; order = navy (#1b2f6e), plum, lime (#93b04f), ink (the fourth also fills empty space).
- `GRAIN` (0.07), `VIGNETTE` (0.85), `WARP` (0.55).
- Field tightness: the `2.6`/`3.0`/`3.4` in `exp(-d² × k)` — larger = smaller, crisper blobs.
- Pointer pull: `1.6` (radius) and `0.25` (strength) in the shader; `POINTER_LERP` (0.04).
- Octaves in `fbm` (4) — drop to 3 for low-end devices.
**Acceptance criteria:**
- [ ] The background is in motion from the first frame with marbled, non-linear colour boundaries (not simple radial blends).
- [ ] All four palette colours are visible somewhere on screen at 1200×750 and at 375×812.
- [ ] Moving the pointer bends the field toward it with visible lag; nothing snaps.
- [ ] Grain is fine and animated; corners are darker than the centre.
- [ ] Headline lines rise through a mask over 1.2s with a 0.1s stagger; copy and CTA follow.
- [ ] With reduced motion or no WebGL, the CSS gradient fallback shows the same palette and the content is immediately visible.
- [ ] No console errors; renderer DPR never exceeds 1.5.
---
**Reference implementation (vanilla, converts 1:1 — same class names, same values):**
```html
<section class="ngm" data-speed="1">
<div class="ngm__canvas" aria-hidden="true"></div>
<div class="ngm__fallback" aria-hidden="true"></div>
<div class="ngm__content">
<p class="ngm__eyebrow">Background · Fragment shader</p>
<h1 class="ngm__title"><span class="ngm__line"><span class="ngm__word">Colour that</span></span><span class="ngm__line"><em class="ngm__word">breathes</em></span></h1>
<p class="ngm__copy">Four slow colour fields, folded through domain-warped simplex noise, finished with film grain and a soft vignette — one full-screen pass, no textures.</p>
<a class="ngm__cta" href="#collection">Explore the collection <span aria-hidden="true">→</span></a>
</div>
<p class="ngm__meta">Simplex · fbm · domain warp · grain</p>
</section>
```
```css
.ngm { position: relative; min-height: 100vh; display: grid; align-items: end; overflow: hidden; background: var(--bg); color: var(--text); isolation: isolate; }
.ngm__canvas { position: absolute; inset: 0; z-index: 0; }
.ngm__canvas canvas { width: 100%; height: 100%; display: block; }
/* Static fallback (no WebGL / reduced motion): the same four colours as layered CSS gradients + SVG grain */
.ngm__fallback {
position: absolute; inset: 0; z-index: 0; display: none;
--c0: var(--knob-field1, #1b2f6e); --c1: #4a1f4e; --c2: var(--knob-field3, #93b04f); --c3: #0a0a0a;
background:
radial-gradient(60% 55% at 28% 36%, var(--c0) 0%, transparent 70%),
radial-gradient(55% 60% at 76% 30%, var(--c1) 0%, transparent 70%),
radial-gradient(50% 45% at 56% 80%, var(--c2) 0%, transparent 70%),
radial-gradient(45% 50% at 18% 82%, var(--c3) 0%, transparent 70%),
var(--c3);
}
.ngm__fallback::before { content: ''; position: absolute; inset: 0; opacity: .28; mix-blend-mode: overlay;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); }
.ngm__fallback::after { content: ''; position: absolute; inset: 0; background: radial-gradient(70% 70% at 50% 50%, transparent 50%, rgba(0,0,0,.55) 100%); }
:root[data-theme="light"] .ngm__fallback { --c0: var(--knob-field1, #c9d4f0); --c1: #e4cfea; --c2: var(--knob-field3, #dce9a8); --c3: #f4f2ee; }
:root[data-theme="light"] .ngm__fallback::after { background: radial-gradient(70% 70% at 50% 50%, transparent 50%, rgba(0,0,0,.12) 100%); }
.ngm.is-static .ngm__fallback { display: block; }
.ngm.is-static .ngm__canvas { display: none; }
.ngm__content { position: relative; z-index: 1; padding: clamp(1.5rem, 6vw, 5rem); max-width: 56rem; }
.ngm__eyebrow { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); margin-bottom: 1.2rem; }
.ngm__title { font-family: var(--font-display); font-weight: 300; font-variation-settings: 'opsz' 144, 'SOFT' 30; font-size: clamp(3rem, 9vw, 9rem); line-height: .9; letter-spacing: -0.04em; }
.ngm__title em { font-style: italic; font-weight: 300; }
.ngm__line { display: block; overflow: hidden; padding-bottom: .08em; margin-bottom: -.08em; }
.ngm__word { display: inline-block; will-change: transform; }
.ngm__copy { margin-top: 1.6rem; max-width: 44ch; color: var(--muted); font-size: clamp(.95rem, 1.2vw, 1.15rem); line-height: 1.5; }
.ngm__cta {
display: inline-flex; align-items: center; gap: .7rem; margin-top: 2.2rem; padding: .85em 1.5em; border-radius: 999px;
background: var(--accent); color: #0a0a0a; text-decoration: none; font-size: .9rem; font-weight: 500; letter-spacing: -0.01em;
transition: transform .35s cubic-bezier(.2,.7,.2,1), background .25s;
}
.ngm__cta span { transition: transform .35s cubic-bezier(.2,.7,.2,1); }
.ngm__cta:hover { transform: translateY(-2px); }
.ngm__cta:hover span { transform: translateX(4px); }
.ngm__cta:focus-visible { outline: 2px solid var(--text); outline-offset: 3px; }
:root[data-theme="light"] .ngm__cta { color: #f4f2ee; }
.ngm__meta { position: absolute; right: clamp(1.5rem, 6vw, 5rem); bottom: clamp(1.5rem, 6vw, 5rem); z-index: 1; font-family: var(--font-mono); font-size: 11px; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); }
@media (max-width: 767px) {
.ngm__meta { display: none; }
.ngm__copy { max-width: 32ch; }
}
@media (prefers-reduced-motion: reduce) {
.ngm__cta, .ngm__cta span { transition: none; }
}
```
```js
// Noise gradient mesh — one full-screen quad, four drifting colour fields blended through domain-warped simplex fbm.
import * as THREE from 'three';
const PALETTE_DARK = ['#1b2f6e', '#4a1f4e', '#93b04f', '#0a0a0a']; // deep navy, plum, lime-ish, ink
const PALETTE_LIGHT = ['#c9d4f0', '#e4cfea', '#dce9a8', '#f4f2ee'];
const DPR_CAP = 1.5; // full-screen fragment work: 1.5 is plenty
const POINTER_LERP = 0.04; // how quickly the warp centre follows the pointer
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const section = document.querySelector('.ngm');
const mount = section.querySelector('.ngm__canvas');
const speedAttr = parseFloat(section.dataset.speed || '1');
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { speed: speedAttr, warp: 0.55, grain: 0.07, vignette: 0.85, field1: PALETTE_DARK[0], field3: PALETTE_DARK[2] };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
function webglAvailable() {
try { const c = document.createElement('canvas'); return !!(c.getContext('webgl2') || c.getContext('webgl')); } catch { return false; }
}
// ---------------------------------------------------------------- shader
const vertexShader = /* glsl */ `
varying vec2 vUv;
void main() { vUv = uv; gl_Position = vec4(position, 1.0); }
`;
const fragmentShader = /* glsl */ `
precision highp float;
uniform float uTime;
uniform vec2 uRes;
uniform vec2 uPointer; // 0..1, lerped, y up
uniform float uSpeed;
uniform vec3 uColors[4]; // navy, plum, lime, ink (sRGB 0..1)
uniform float uGrain;
uniform float uVignette;
uniform float uWarp;
varying vec2 vUv;
// --- 2D simplex noise (Ashima Arts / Ian McEwan, MIT)
vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec2 mod289(vec2 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec3 permute(vec3 x) { return mod289(((x * 34.0) + 1.0) * x); }
float snoise(vec2 v) {
const vec4 C = vec4(0.211324865405187, 0.366025403784439, -0.577350269189626, 0.024390243902439);
vec2 i = floor(v + dot(v, C.yy));
vec2 x0 = v - i + dot(i, C.xx);
vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
vec4 x12 = x0.xyxy + C.xxzz;
x12.xy -= i1;
i = mod289(i);
vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0));
vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0);
m = m * m; m = m * m;
vec3 x = 2.0 * fract(p * C.www) - 1.0;
vec3 h = abs(x) - 0.5;
vec3 ox = floor(x + 0.5);
vec3 a0 = x - ox;
m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h);
vec3 g;
g.x = a0.x * x0.x + h.x * x0.y;
g.yz = a0.yz * x12.xz + h.yz * x12.yw;
return 130.0 * dot(m, g);
}
float fbm(vec2 p) {
float v = 0.0, a = 0.5;
mat2 rot = mat2(0.8, 0.6, -0.6, 0.8);
for (int i = 0; i < 4; i++) { v += a * snoise(p); p = rot * p * 2.0 + 10.0; a *= 0.5; }
return v;
}
float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
void main() {
float aspect = uRes.x / uRes.y;
vec2 uv = vUv;
vec2 p = vec2(uv.x * aspect, uv.y);
float t = uTime * uSpeed;
// pointer pulls the warp centre toward itself (soft gaussian influence)
vec2 pc = vec2(uPointer.x * aspect, uPointer.y);
vec2 toP = p - pc;
float pull = exp(-dot(toP, toP) * 1.6);
// domain warp: q warps r warps the lookup position
vec2 q = vec2(fbm(p * 1.4 + t * 0.15), fbm(p * 1.4 - t * 0.12 + 5.2));
vec2 r = vec2(fbm(p * 1.4 + q * 1.6 + vec2(1.7, 9.2) + t * 0.10),
fbm(p * 1.4 + q * 1.6 + vec2(8.3, 2.8) - t * 0.08));
vec2 w = p + r * uWarp - toP * pull * 0.25;
// four drifting colour centres (Lissajous paths)
vec2 c0 = vec2(0.30 + 0.22 * sin(t * 0.31), 0.35 + 0.20 * cos(t * 0.23));
vec2 c1 = vec2(0.75 + 0.20 * cos(t * 0.27 + 1.3), 0.30 + 0.24 * sin(t * 0.19 + 2.1));
vec2 c2 = vec2(0.55 + 0.26 * sin(t * 0.17 + 4.2), 0.78 + 0.16 * cos(t * 0.29 + 0.7));
vec2 c3 = vec2(0.20 + 0.18 * cos(t * 0.21 + 2.6), 0.80 + 0.18 * sin(t * 0.33 + 1.1));
c0.x *= aspect; c1.x *= aspect; c2.x *= aspect; c3.x *= aspect;
float w0 = exp(-dot(w - c0, w - c0) * 2.6); // navy gets the widest field
float w1 = exp(-dot(w - c1, w - c1) * 3.0);
float w2 = exp(-dot(w - c2, w - c2) * 3.4); // lime is kept a little tighter
float w3 = exp(-dot(w - c3, w - c3) * 3.0);
float sum = w0 + w1 + w2 + w3 + 1e-4;
vec3 col = (uColors[0] * w0 + uColors[1] * w1 + uColors[2] * w2 + uColors[3] * w3) / sum;
float cover = clamp(sum * 0.9, 0.0, 1.0);
col = mix(uColors[3], col, cover); // ink where no field reaches
col += fbm(w * 2.5 + t * 0.05) * 0.12; // tonal shading in the folds
col += (hash(uv * uRes + fract(uTime) * 100.0) - 0.5) * uGrain; // animated film grain
vec2 vc = uv - 0.5;
float vig = 1.0 - smoothstep(0.35, 0.95, dot(vc, vc) * 2.2);
col *= mix(1.0, vig, uVignette);
gl_FragColor = vec4(col, 1.0);
}
`;
const toVec3 = (hex) => { const n = parseInt(hex.slice(1), 16); return new THREE.Vector3(((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255); };
// Current palette = theme palette with any overridden colour knobs applied to both themes.
function palette() {
const light = document.documentElement.getAttribute('data-theme') === 'light';
const base = [...(light ? PALETTE_LIGHT : PALETTE_DARK)];
const f1 = K('field1'), f3 = K('field3');
if (f1 !== DEFAULTS.field1) base[0] = f1;
if (f3 !== DEFAULTS.field3) base[2] = f3;
return base;
}
// ---------------------------------------------------------------- renderer (created once; knobs only update uniforms)
let gl = null; // { uniforms, applyTheme }
function run() {
const dprCap = window.MotionLab?.preview ? DPR_CAP / 2 : DPR_CAP;
const renderer = new THREE.WebGLRenderer({ antialias: false, alpha: false, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(devicePixelRatio, dprCap));
mount.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const uniforms = {
uTime: { value: 0 },
uRes: { value: new THREE.Vector2(1, 1) },
uPointer: { value: new THREE.Vector2(0.5, 0.5) },
uSpeed: { value: DEFAULTS.speed },
uColors: { value: PALETTE_DARK.map(toVec3) },
uGrain: { value: DEFAULTS.grain },
uVignette: { value: DEFAULTS.vignette },
uWarp: { value: DEFAULTS.warp },
};
const material = new THREE.ShaderMaterial({ vertexShader, fragmentShader, uniforms, depthTest: false, depthWrite: false });
const geometry = new THREE.PlaneGeometry(2, 2);
scene.add(new THREE.Mesh(geometry, material));
// theme (+ colour knobs) → palette
const applyTheme = () => palette().forEach((hex, i) => uniforms.uColors.value[i].copy(toVec3(hex)));
const themeObserver = new MutationObserver(applyTheme);
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
applyTheme();
// pointer (target) → uniform (lerped in the loop)
const target = new THREE.Vector2(0.5, 0.5);
const onPointer = (e) => {
const r = mount.getBoundingClientRect();
target.set((e.clientX - r.left) / r.width, 1 - (e.clientY - r.top) / r.height);
};
const onLeave = () => target.set(0.5, 0.5);
section.addEventListener('pointermove', onPointer);
section.addEventListener('pointerleave', onLeave);
const resize = () => {
const w = mount.clientWidth, h = mount.clientHeight;
if (!w || !h) return;
renderer.setSize(w, h, false);
uniforms.uRes.value.set(w * renderer.getPixelRatio(), h * renderer.getPixelRatio());
};
const ro = new ResizeObserver(resize);
ro.observe(mount);
resize();
const clock = new THREE.Clock();
let raf = 0, running = false;
const frame = () => {
raf = requestAnimationFrame(frame);
const dt = Math.min(clock.getDelta(), 0.05);
uniforms.uTime.value += dt;
uniforms.uPointer.value.lerp(target, POINTER_LERP);
renderer.render(scene, camera);
};
const start = () => { if (running) return; running = true; clock.getDelta(); raf = requestAnimationFrame(frame); };
const stop = () => { running = false; cancelAnimationFrame(raf); };
const onVisibility = () => (document.hidden ? stop() : start());
window.addEventListener('ml:pause', stop);
window.addEventListener('ml:resume', start);
document.addEventListener('visibilitychange', onVisibility);
start();
window.MotionLab?.onCleanup(() => {
stop();
ro.disconnect();
themeObserver.disconnect();
section.removeEventListener('pointermove', onPointer);
section.removeEventListener('pointerleave', onLeave);
window.removeEventListener('ml:pause', stop);
window.removeEventListener('ml:resume', start);
document.removeEventListener('visibilitychange', onVisibility);
geometry.dispose();
material.dispose();
renderer.dispose();
renderer.domElement.remove();
});
gl = { uniforms, applyTheme };
}
// ---------------------------------------------------------------- static fallback path
if (reduced || !webglAvailable()) {
section.classList.add('is-static');
} else {
run();
}
// ---------------------------------------------------------------- build: read knobs → uniforms + CSS fallback colours (+ DOM intro once)
let ctx;
let introPlayed = false;
function build() {
ctx?.revert();
// colour knobs → CSS custom properties read by the static fallback (unset when at default so the light theme keeps its own)
for (const id of ['field1', 'field3']) {
const v = K(id);
if (v !== DEFAULTS[id]) section.style.setProperty(`--knob-${id}`, v); else section.style.removeProperty(`--knob-${id}`);
}
if (gl) {
gl.uniforms.uSpeed.value = K('speed');
gl.uniforms.uWarp.value = K('warp');
gl.uniforms.uGrain.value = K('grain');
gl.uniforms.uVignette.value = K('vignette');
gl.applyTheme();
}
ctx = gsap.context(() => {
if (reduced || introPlayed) return;
introPlayed = true;
gsap.from('.ngm__word', { yPercent: 110, duration: 1.2, ease: 'power4.out', stagger: 0.1, delay: 0.2 });
gsap.from(['.ngm__eyebrow', '.ngm__copy', '.ngm__cta', '.ngm__meta'], { y: 18, opacity: 0, duration: 1, ease: 'power3.out', stagger: 0.08, delay: 0.6 });
});
}
build();
// Playground: re-read knobs (renderer is kept; only uniforms / CSS change)
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => { window.removeEventListener('ml:knobs', build); ctx?.revert(); });
```
### EFFECT (single HTML file): Noise Gradient Mesh Background
Produce **one self-contained `index.html`** (inline `<style>` and `<script type="module">`, no build step) that implements **Noise Gradient Mesh Background** 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>Noise Gradient Mesh Background</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 src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<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: Noise Gradient Mesh Background
**What it does (visual):** The whole viewport is a slowly moving painting: a deep navy field (`#1b2f6e`), a plum field, a muted lime field (`#93b04f`) and an ink-black field drift on gentle looping paths (one full cycle takes 20–35s at speed 1), and where they meet the boundaries are not straight blends but folded, marbled edges — the colours look like ink dispersing in water because the lookup position is warped (amplitude 0.55) by layered simplex noise that itself slowly flows. Fine animated film grain (amplitude 0.07) sits over everything and the corners fall off into a soft vignette (strength 0.85). Moving the pointer gently drags the folds toward it (the warp centre follows with a long lag), so the picture responds without ever feeling like a cursor effect. Over the canvas, bottom-left, a two-line thin serif headline ("Colour that / *breathes*") rises line by line through a mask, followed by a muted paragraph and a lime pill CTA. If WebGL is unavailable or the user prefers reduced motion, the same four colours render as a static layered CSS radial-gradient with SVG grain.
**Tech stack:**
- Three.js r160+ (`import * as THREE from 'three'`) used only for the `WebGLRenderer`, an `OrthographicCamera(-1, 1, 1, -1, 0, 1)` and a `ShaderMaterial` on a `PlaneGeometry(2, 2)`.
- GLSL ES 1.0 fragment shader (below). No textures, no external assets.
- GSAP 3.13+ (core) for the DOM intro only. CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>`.
**HTML structure:**
```html
<section class="ngm" data-speed="1">
<div class="ngm__canvas"></div> <!-- renderer canvas appended here -->
<div class="ngm__fallback"></div> <!-- CSS gradient, shown only with .is-static -->
<div class="ngm__content">
<p class="ngm__eyebrow">Background · Fragment shader</p>
<h1 class="ngm__title"><span class="ngm__line"><span class="ngm__word">Colour that</span></span><span class="ngm__line"><em class="ngm__word">breathes</em></span></h1>
<p class="ngm__copy">Four slow colour fields, folded through domain-warped simplex noise…</p>
<a class="ngm__cta" href="#collection">Explore the collection <span>→</span></a>
</div>
<p class="ngm__meta">Simplex · fbm · domain warp · grain</p>
</section>
```
**CSS requirements:**
- `.ngm`: `position: relative; min-height: 100vh; display: grid; align-items: end; overflow: hidden; isolation: isolate; background: var(--bg, #0a0a0a); color: var(--text, #ededed);`
- `.ngm__canvas`, `.ngm__fallback`: `position: absolute; inset: 0; z-index: 0;` canvas element `width: 100%; height: 100%; display: block`.
- `.ngm__fallback`: `display: none;` background = `radial-gradient(60% 55% at 28% 36%, #1b2f6e, transparent 70%), radial-gradient(55% 60% at 76% 30%, #4a1f4e, transparent 70%), radial-gradient(50% 45% at 56% 80%, #93b04f, transparent 70%), radial-gradient(45% 50% at 18% 82%, #0a0a0a, transparent 70%), #0a0a0a`; `::before` = inline SVG `feTurbulence` (`baseFrequency .9`, 2 octaves) tile at `opacity .28; mix-blend-mode: overlay`; `::after` = vignette `radial-gradient(70% 70% at 50% 50%, transparent 50%, rgba(0,0,0,.55))`. `.ngm.is-static .ngm__fallback { display: block } .ngm.is-static .ngm__canvas { display: none }`. Light theme swaps the four colours for `#c9d4f0 #e4cfea #dce9a8 #f4f2ee`.
- `.ngm__content`: `position: relative; z-index: 1; padding: clamp(1.5rem, 6vw, 5rem); max-width: 56rem;`
- `.ngm__title`: display serif `font-weight: 300; font-size: clamp(3rem, 9vw, 9rem); line-height: .9; letter-spacing: -0.04em;` `em` italic. `.ngm__line { display: block; overflow: hidden; padding-bottom: .08em; margin-bottom: -.08em; }` (mask for the rise); `.ngm__word { display: inline-block; will-change: transform }` is what actually translates.
- `.ngm__copy`: `max-width: 44ch; color: var(--muted); font-size: clamp(.95rem, 1.2vw, 1.15rem); line-height: 1.5; margin-top: 1.6rem;`
- `.ngm__cta`: pill, `background: var(--accent, #D4FF3A); color: #0a0a0a; padding: .85em 1.5em; border-radius: 999px; margin-top: 2.2rem; font-size: .9rem; font-weight: 500;` hover `translateY(-2px)` + arrow `translateX(4px)` over `.35s cubic-bezier(.2,.7,.2,1)`.
- `.ngm__meta`: mono 11px uppercase muted, `position: absolute; right/bottom: clamp(1.5rem, 6vw, 5rem)`; hidden under 768px.
**Animation spec (exact values):**
1. **Setup**: `WebGLRenderer({ antialias: false, alpha: false })`, `setPixelRatio(Math.min(devicePixelRatio, 1.5))`; `OrthographicCamera(-1, 1, 1, -1, 0, 1)`; `Mesh(PlaneGeometry(2, 2), ShaderMaterial({ depthTest: false, depthWrite: false }))`.
2. **Uniforms**: `uTime` (s, accumulated from a clamped clock delta ≤ 0.05), `uRes` (drawing-buffer px), `uPointer` (vec2 0..1, y up, lerped `0.04`/frame toward the pointer; returns to `(0.5, 0.5)` on `pointerleave`), `uSpeed` (`data-speed`, default 1), `uColors` (`vec3[4]` raw sRGB: `#1b2f6e`, `#4a1f4e`, `#93b04f`, `#0a0a0a`; light theme `#c9d4f0 #e4cfea #dce9a8 #f4f2ee`, swapped by a `MutationObserver` on `data-theme`), `uGrain 0.07`, `uVignette 0.85`, `uWarp 0.55`.
3. **Vertex shader**: `varying vec2 vUv; void main(){ vUv = uv; gl_Position = vec4(position, 1.0); }`
4. **Fragment shader (complete)**:
```glsl
precision highp float;
uniform float uTime; uniform vec2 uRes; uniform vec2 uPointer; uniform float uSpeed;
uniform vec3 uColors[4]; uniform float uGrain; uniform float uVignette; uniform float uWarp;
varying vec2 vUv;
vec3 mod289(vec3 x){ return x - floor(x * (1.0/289.0)) * 289.0; }
vec2 mod289(vec2 x){ return x - floor(x * (1.0/289.0)) * 289.0; }
vec3 permute(vec3 x){ return mod289(((x*34.0)+1.0)*x); }
float snoise(vec2 v){
const vec4 C = vec4(0.211324865405187, 0.366025403784439, -0.577350269189626, 0.024390243902439);
vec2 i = floor(v + dot(v, C.yy)); vec2 x0 = v - i + dot(i, C.xx);
vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
vec4 x12 = x0.xyxy + C.xxzz; x12.xy -= i1; i = mod289(i);
vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0));
vec3 m = max(0.5 - vec3(dot(x0,x0), dot(x12.xy,x12.xy), dot(x12.zw,x12.zw)), 0.0); m = m*m; m = m*m;
vec3 x = 2.0 * fract(p * C.www) - 1.0; vec3 h = abs(x) - 0.5; vec3 ox = floor(x + 0.5); vec3 a0 = x - ox;
m *= 1.79284291400159 - 0.85373472095314 * (a0*a0 + h*h);
vec3 g; g.x = a0.x * x0.x + h.x * x0.y; g.yz = a0.yz * x12.xz + h.yz * x12.yw;
return 130.0 * dot(m, g);
}
float fbm(vec2 p){ float v = 0.0, a = 0.5; mat2 rot = mat2(0.8, 0.6, -0.6, 0.8);
for (int i = 0; i < 4; i++){ v += a * snoise(p); p = rot * p * 2.0 + 10.0; a *= 0.5; } return v; }
float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
void main(){
float aspect = uRes.x / uRes.y; vec2 uv = vUv; vec2 p = vec2(uv.x * aspect, uv.y); float t = uTime * uSpeed;
vec2 pc = vec2(uPointer.x * aspect, uPointer.y); vec2 toP = p - pc; float pull = exp(-dot(toP, toP) * 1.6);
vec2 q = vec2(fbm(p * 1.4 + t * 0.15), fbm(p * 1.4 - t * 0.12 + 5.2));
vec2 r = vec2(fbm(p * 1.4 + q * 1.6 + vec2(1.7, 9.2) + t * 0.10), fbm(p * 1.4 + q * 1.6 + vec2(8.3, 2.8) - t * 0.08));
vec2 w = p + r * uWarp - toP * pull * 0.25;
vec2 c0 = vec2(0.30 + 0.22 * sin(t * 0.31), 0.35 + 0.20 * cos(t * 0.23));
vec2 c1 = vec2(0.75 + 0.20 * cos(t * 0.27 + 1.3), 0.30 + 0.24 * sin(t * 0.19 + 2.1));
vec2 c2 = vec2(0.55 + 0.26 * sin(t * 0.17 + 4.2), 0.78 + 0.16 * cos(t * 0.29 + 0.7));
vec2 c3 = vec2(0.20 + 0.18 * cos(t * 0.21 + 2.6), 0.80 + 0.18 * sin(t * 0.33 + 1.1));
c0.x *= aspect; c1.x *= aspect; c2.x *= aspect; c3.x *= aspect;
float w0 = exp(-dot(w - c0, w - c0) * 2.6); float w1 = exp(-dot(w - c1, w - c1) * 3.0);
float w2 = exp(-dot(w - c2, w - c2) * 3.4); float w3 = exp(-dot(w - c3, w - c3) * 3.0);
float sum = w0 + w1 + w2 + w3 + 1e-4;
vec3 col = (uColors[0]*w0 + uColors[1]*w1 + uColors[2]*w2 + uColors[3]*w3) / sum;
col = mix(uColors[3], col, clamp(sum * 0.9, 0.0, 1.0));
col += fbm(w * 2.5 + t * 0.05) * 0.12;
col += (hash(uv * uRes + fract(uTime) * 100.0) - 0.5) * uGrain;
vec2 vc = uv - 0.5; float vig = 1.0 - smoothstep(0.35, 0.95, dot(vc, vc) * 2.2);
col *= mix(1.0, vig, uVignette);
gl_FragColor = vec4(col, 1.0);
}
```
5. **Loop**: every frame `uTime += dt; uPointer.lerp(target, 0.04); renderer.render(scene, camera)`.
6. **DOM intro** (GSAP, skipped under reduced motion): `gsap.from('.ngm__word', { yPercent: 110, duration: 1.2, ease: 'power4.out', stagger: 0.1, delay: 0.2 })` — the lines rise through their `overflow: hidden` mask; then `gsap.from(['.ngm__eyebrow', '.ngm__copy', '.ngm__cta', '.ngm__meta'], { y: 18, opacity: 0, duration: 1, ease: 'power3.out', stagger: 0.08, delay: 0.6 })`.
**Interaction:** Pointer position over the section sets the warp target; the uniform follows with `lerp 0.04` (≈ 1.2s to settle) and the folds bend toward it with a Gaussian falloff (`exp(-d² × 1.6)`) and a maximum displacement of `0.25` units. Leaving the section returns the centre to the middle. CTA hover lifts 2px and nudges the arrow 4px over 0.35s.
**Responsive behavior:** The shader is resolution-independent (aspect-corrected in `p`), so it simply fills any viewport; the renderer resizes from a `ResizeObserver` on the wrapper and updates `uRes`. Under 768px the mono meta line is hidden and the copy caps at 32ch; the headline scales with `clamp(3rem, 9vw, 9rem)`. On touch devices the pointer pull only happens during a touch-move (no hover), which is fine — the field still drifts on its own.
**Accessibility:** `prefers-reduced-motion: reduce` → no renderer is created; `.ngm` gets `.is-static`, showing the CSS gradient fallback (same palette, SVG grain, vignette) with the content fully visible and no intro. The same fallback is used when `canvas.getContext('webgl2'|'webgl')` returns null. Canvas and fallback are `aria-hidden`; the headline is a real `<h1>`; the CTA is an `<a>` with a visible `outline: 2px solid var(--text)` focus ring and dark-on-lime text (contrast > 10:1).
**Performance rules:** DPR capped at `Math.min(devicePixelRatio, 1.5)` (0.75 in the library hover preview). One draw call, one quad, zero textures. `antialias: false` (the grain hides aliasing anyway). Stop the rAF loop on `ml:pause` and `visibilitychange`, resume on `ml:resume`; `dt` is clamped to 0.05s. Cleanup disposes the geometry, material and renderer, disconnects the `ResizeObserver` and `MutationObserver`, removes listeners and the canvas. The DOM intro lives in `gsap.context()` and is reverted on unmount.
**Customization knobs:**
- `data-speed` on the section → `uSpeed` (1 = demo; 0.5 is meditative, 2 is lively).
- `PALETTE_DARK` / `PALETTE_LIGHT` — four hex colours; order = navy (#1b2f6e), plum, lime (#93b04f), ink (the fourth also fills empty space).
- `GRAIN` (0.07), `VIGNETTE` (0.85), `WARP` (0.55).
- Field tightness: the `2.6`/`3.0`/`3.4` in `exp(-d² × k)` — larger = smaller, crisper blobs.
- Pointer pull: `1.6` (radius) and `0.25` (strength) in the shader; `POINTER_LERP` (0.04).
- Octaves in `fbm` (4) — drop to 3 for low-end devices.
**Acceptance criteria:**
- [ ] The background is in motion from the first frame with marbled, non-linear colour boundaries (not simple radial blends).
- [ ] All four palette colours are visible somewhere on screen at 1200×750 and at 375×812.
- [ ] Moving the pointer bends the field toward it with visible lag; nothing snaps.
- [ ] Grain is fine and animated; corners are darker than the centre.
- [ ] Headline lines rise through a mask over 1.2s with a 0.1s stagger; copy and CTA follow.
- [ ] With reduced motion or no WebGL, the CSS gradient fallback shows the same palette and the content is immediately visible.
- [ ] No console errors; renderer DPR never exceeds 1.5.
---
**Reference implementation (use these exact values and class names):**
```html
<section class="ngm" data-speed="1">
<div class="ngm__canvas" aria-hidden="true"></div>
<div class="ngm__fallback" aria-hidden="true"></div>
<div class="ngm__content">
<p class="ngm__eyebrow">Background · Fragment shader</p>
<h1 class="ngm__title"><span class="ngm__line"><span class="ngm__word">Colour that</span></span><span class="ngm__line"><em class="ngm__word">breathes</em></span></h1>
<p class="ngm__copy">Four slow colour fields, folded through domain-warped simplex noise, finished with film grain and a soft vignette — one full-screen pass, no textures.</p>
<a class="ngm__cta" href="#collection">Explore the collection <span aria-hidden="true">→</span></a>
</div>
<p class="ngm__meta">Simplex · fbm · domain warp · grain</p>
</section>
```
```css
.ngm { position: relative; min-height: 100vh; display: grid; align-items: end; overflow: hidden; background: var(--bg); color: var(--text); isolation: isolate; }
.ngm__canvas { position: absolute; inset: 0; z-index: 0; }
.ngm__canvas canvas { width: 100%; height: 100%; display: block; }
/* Static fallback (no WebGL / reduced motion): the same four colours as layered CSS gradients + SVG grain */
.ngm__fallback {
position: absolute; inset: 0; z-index: 0; display: none;
--c0: var(--knob-field1, #1b2f6e); --c1: #4a1f4e; --c2: var(--knob-field3, #93b04f); --c3: #0a0a0a;
background:
radial-gradient(60% 55% at 28% 36%, var(--c0) 0%, transparent 70%),
radial-gradient(55% 60% at 76% 30%, var(--c1) 0%, transparent 70%),
radial-gradient(50% 45% at 56% 80%, var(--c2) 0%, transparent 70%),
radial-gradient(45% 50% at 18% 82%, var(--c3) 0%, transparent 70%),
var(--c3);
}
.ngm__fallback::before { content: ''; position: absolute; inset: 0; opacity: .28; mix-blend-mode: overlay;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); }
.ngm__fallback::after { content: ''; position: absolute; inset: 0; background: radial-gradient(70% 70% at 50% 50%, transparent 50%, rgba(0,0,0,.55) 100%); }
:root[data-theme="light"] .ngm__fallback { --c0: var(--knob-field1, #c9d4f0); --c1: #e4cfea; --c2: var(--knob-field3, #dce9a8); --c3: #f4f2ee; }
:root[data-theme="light"] .ngm__fallback::after { background: radial-gradient(70% 70% at 50% 50%, transparent 50%, rgba(0,0,0,.12) 100%); }
.ngm.is-static .ngm__fallback { display: block; }
.ngm.is-static .ngm__canvas { display: none; }
.ngm__content { position: relative; z-index: 1; padding: clamp(1.5rem, 6vw, 5rem); max-width: 56rem; }
.ngm__eyebrow { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); margin-bottom: 1.2rem; }
.ngm__title { font-family: var(--font-display); font-weight: 300; font-variation-settings: 'opsz' 144, 'SOFT' 30; font-size: clamp(3rem, 9vw, 9rem); line-height: .9; letter-spacing: -0.04em; }
.ngm__title em { font-style: italic; font-weight: 300; }
.ngm__line { display: block; overflow: hidden; padding-bottom: .08em; margin-bottom: -.08em; }
.ngm__word { display: inline-block; will-change: transform; }
.ngm__copy { margin-top: 1.6rem; max-width: 44ch; color: var(--muted); font-size: clamp(.95rem, 1.2vw, 1.15rem); line-height: 1.5; }
.ngm__cta {
display: inline-flex; align-items: center; gap: .7rem; margin-top: 2.2rem; padding: .85em 1.5em; border-radius: 999px;
background: var(--accent); color: #0a0a0a; text-decoration: none; font-size: .9rem; font-weight: 500; letter-spacing: -0.01em;
transition: transform .35s cubic-bezier(.2,.7,.2,1), background .25s;
}
.ngm__cta span { transition: transform .35s cubic-bezier(.2,.7,.2,1); }
.ngm__cta:hover { transform: translateY(-2px); }
.ngm__cta:hover span { transform: translateX(4px); }
.ngm__cta:focus-visible { outline: 2px solid var(--text); outline-offset: 3px; }
:root[data-theme="light"] .ngm__cta { color: #f4f2ee; }
.ngm__meta { position: absolute; right: clamp(1.5rem, 6vw, 5rem); bottom: clamp(1.5rem, 6vw, 5rem); z-index: 1; font-family: var(--font-mono); font-size: 11px; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); }
@media (max-width: 767px) {
.ngm__meta { display: none; }
.ngm__copy { max-width: 32ch; }
}
@media (prefers-reduced-motion: reduce) {
.ngm__cta, .ngm__cta span { transition: none; }
}
```
```js
// Noise gradient mesh — one full-screen quad, four drifting colour fields blended through domain-warped simplex fbm.
import * as THREE from 'three';
const PALETTE_DARK = ['#1b2f6e', '#4a1f4e', '#93b04f', '#0a0a0a']; // deep navy, plum, lime-ish, ink
const PALETTE_LIGHT = ['#c9d4f0', '#e4cfea', '#dce9a8', '#f4f2ee'];
const DPR_CAP = 1.5; // full-screen fragment work: 1.5 is plenty
const POINTER_LERP = 0.04; // how quickly the warp centre follows the pointer
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const section = document.querySelector('.ngm');
const mount = section.querySelector('.ngm__canvas');
const speedAttr = parseFloat(section.dataset.speed || '1');
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { speed: speedAttr, warp: 0.55, grain: 0.07, vignette: 0.85, field1: PALETTE_DARK[0], field3: PALETTE_DARK[2] };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
function webglAvailable() {
try { const c = document.createElement('canvas'); return !!(c.getContext('webgl2') || c.getContext('webgl')); } catch { return false; }
}
// ---------------------------------------------------------------- shader
const vertexShader = /* glsl */ `
varying vec2 vUv;
void main() { vUv = uv; gl_Position = vec4(position, 1.0); }
`;
const fragmentShader = /* glsl */ `
precision highp float;
uniform float uTime;
uniform vec2 uRes;
uniform vec2 uPointer; // 0..1, lerped, y up
uniform float uSpeed;
uniform vec3 uColors[4]; // navy, plum, lime, ink (sRGB 0..1)
uniform float uGrain;
uniform float uVignette;
uniform float uWarp;
varying vec2 vUv;
// --- 2D simplex noise (Ashima Arts / Ian McEwan, MIT)
vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec2 mod289(vec2 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec3 permute(vec3 x) { return mod289(((x * 34.0) + 1.0) * x); }
float snoise(vec2 v) {
const vec4 C = vec4(0.211324865405187, 0.366025403784439, -0.577350269189626, 0.024390243902439);
vec2 i = floor(v + dot(v, C.yy));
vec2 x0 = v - i + dot(i, C.xx);
vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
vec4 x12 = x0.xyxy + C.xxzz;
x12.xy -= i1;
i = mod289(i);
vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0));
vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0);
m = m * m; m = m * m;
vec3 x = 2.0 * fract(p * C.www) - 1.0;
vec3 h = abs(x) - 0.5;
vec3 ox = floor(x + 0.5);
vec3 a0 = x - ox;
m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h);
vec3 g;
g.x = a0.x * x0.x + h.x * x0.y;
g.yz = a0.yz * x12.xz + h.yz * x12.yw;
return 130.0 * dot(m, g);
}
float fbm(vec2 p) {
float v = 0.0, a = 0.5;
mat2 rot = mat2(0.8, 0.6, -0.6, 0.8);
for (int i = 0; i < 4; i++) { v += a * snoise(p); p = rot * p * 2.0 + 10.0; a *= 0.5; }
return v;
}
float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
void main() {
float aspect = uRes.x / uRes.y;
vec2 uv = vUv;
vec2 p = vec2(uv.x * aspect, uv.y);
float t = uTime * uSpeed;
// pointer pulls the warp centre toward itself (soft gaussian influence)
vec2 pc = vec2(uPointer.x * aspect, uPointer.y);
vec2 toP = p - pc;
float pull = exp(-dot(toP, toP) * 1.6);
// domain warp: q warps r warps the lookup position
vec2 q = vec2(fbm(p * 1.4 + t * 0.15), fbm(p * 1.4 - t * 0.12 + 5.2));
vec2 r = vec2(fbm(p * 1.4 + q * 1.6 + vec2(1.7, 9.2) + t * 0.10),
fbm(p * 1.4 + q * 1.6 + vec2(8.3, 2.8) - t * 0.08));
vec2 w = p + r * uWarp - toP * pull * 0.25;
// four drifting colour centres (Lissajous paths)
vec2 c0 = vec2(0.30 + 0.22 * sin(t * 0.31), 0.35 + 0.20 * cos(t * 0.23));
vec2 c1 = vec2(0.75 + 0.20 * cos(t * 0.27 + 1.3), 0.30 + 0.24 * sin(t * 0.19 + 2.1));
vec2 c2 = vec2(0.55 + 0.26 * sin(t * 0.17 + 4.2), 0.78 + 0.16 * cos(t * 0.29 + 0.7));
vec2 c3 = vec2(0.20 + 0.18 * cos(t * 0.21 + 2.6), 0.80 + 0.18 * sin(t * 0.33 + 1.1));
c0.x *= aspect; c1.x *= aspect; c2.x *= aspect; c3.x *= aspect;
float w0 = exp(-dot(w - c0, w - c0) * 2.6); // navy gets the widest field
float w1 = exp(-dot(w - c1, w - c1) * 3.0);
float w2 = exp(-dot(w - c2, w - c2) * 3.4); // lime is kept a little tighter
float w3 = exp(-dot(w - c3, w - c3) * 3.0);
float sum = w0 + w1 + w2 + w3 + 1e-4;
vec3 col = (uColors[0] * w0 + uColors[1] * w1 + uColors[2] * w2 + uColors[3] * w3) / sum;
float cover = clamp(sum * 0.9, 0.0, 1.0);
col = mix(uColors[3], col, cover); // ink where no field reaches
col += fbm(w * 2.5 + t * 0.05) * 0.12; // tonal shading in the folds
col += (hash(uv * uRes + fract(uTime) * 100.0) - 0.5) * uGrain; // animated film grain
vec2 vc = uv - 0.5;
float vig = 1.0 - smoothstep(0.35, 0.95, dot(vc, vc) * 2.2);
col *= mix(1.0, vig, uVignette);
gl_FragColor = vec4(col, 1.0);
}
`;
const toVec3 = (hex) => { const n = parseInt(hex.slice(1), 16); return new THREE.Vector3(((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255); };
// Current palette = theme palette with any overridden colour knobs applied to both themes.
function palette() {
const light = document.documentElement.getAttribute('data-theme') === 'light';
const base = [...(light ? PALETTE_LIGHT : PALETTE_DARK)];
const f1 = K('field1'), f3 = K('field3');
if (f1 !== DEFAULTS.field1) base[0] = f1;
if (f3 !== DEFAULTS.field3) base[2] = f3;
return base;
}
// ---------------------------------------------------------------- renderer (created once; knobs only update uniforms)
let gl = null; // { uniforms, applyTheme }
function run() {
const dprCap = window.MotionLab?.preview ? DPR_CAP / 2 : DPR_CAP;
const renderer = new THREE.WebGLRenderer({ antialias: false, alpha: false, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(devicePixelRatio, dprCap));
mount.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const uniforms = {
uTime: { value: 0 },
uRes: { value: new THREE.Vector2(1, 1) },
uPointer: { value: new THREE.Vector2(0.5, 0.5) },
uSpeed: { value: DEFAULTS.speed },
uColors: { value: PALETTE_DARK.map(toVec3) },
uGrain: { value: DEFAULTS.grain },
uVignette: { value: DEFAULTS.vignette },
uWarp: { value: DEFAULTS.warp },
};
const material = new THREE.ShaderMaterial({ vertexShader, fragmentShader, uniforms, depthTest: false, depthWrite: false });
const geometry = new THREE.PlaneGeometry(2, 2);
scene.add(new THREE.Mesh(geometry, material));
// theme (+ colour knobs) → palette
const applyTheme = () => palette().forEach((hex, i) => uniforms.uColors.value[i].copy(toVec3(hex)));
const themeObserver = new MutationObserver(applyTheme);
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
applyTheme();
// pointer (target) → uniform (lerped in the loop)
const target = new THREE.Vector2(0.5, 0.5);
const onPointer = (e) => {
const r = mount.getBoundingClientRect();
target.set((e.clientX - r.left) / r.width, 1 - (e.clientY - r.top) / r.height);
};
const onLeave = () => target.set(0.5, 0.5);
section.addEventListener('pointermove', onPointer);
section.addEventListener('pointerleave', onLeave);
const resize = () => {
const w = mount.clientWidth, h = mount.clientHeight;
if (!w || !h) return;
renderer.setSize(w, h, false);
uniforms.uRes.value.set(w * renderer.getPixelRatio(), h * renderer.getPixelRatio());
};
const ro = new ResizeObserver(resize);
ro.observe(mount);
resize();
const clock = new THREE.Clock();
let raf = 0, running = false;
const frame = () => {
raf = requestAnimationFrame(frame);
const dt = Math.min(clock.getDelta(), 0.05);
uniforms.uTime.value += dt;
uniforms.uPointer.value.lerp(target, POINTER_LERP);
renderer.render(scene, camera);
};
const start = () => { if (running) return; running = true; clock.getDelta(); raf = requestAnimationFrame(frame); };
const stop = () => { running = false; cancelAnimationFrame(raf); };
const onVisibility = () => (document.hidden ? stop() : start());
window.addEventListener('ml:pause', stop);
window.addEventListener('ml:resume', start);
document.addEventListener('visibilitychange', onVisibility);
start();
window.MotionLab?.onCleanup(() => {
stop();
ro.disconnect();
themeObserver.disconnect();
section.removeEventListener('pointermove', onPointer);
section.removeEventListener('pointerleave', onLeave);
window.removeEventListener('ml:pause', stop);
window.removeEventListener('ml:resume', start);
document.removeEventListener('visibilitychange', onVisibility);
geometry.dispose();
material.dispose();
renderer.dispose();
renderer.domElement.remove();
});
gl = { uniforms, applyTheme };
}
// ---------------------------------------------------------------- static fallback path
if (reduced || !webglAvailable()) {
section.classList.add('is-static');
} else {
run();
}
// ---------------------------------------------------------------- build: read knobs → uniforms + CSS fallback colours (+ DOM intro once)
let ctx;
let introPlayed = false;
function build() {
ctx?.revert();
// colour knobs → CSS custom properties read by the static fallback (unset when at default so the light theme keeps its own)
for (const id of ['field1', 'field3']) {
const v = K(id);
if (v !== DEFAULTS[id]) section.style.setProperty(`--knob-${id}`, v); else section.style.removeProperty(`--knob-${id}`);
}
if (gl) {
gl.uniforms.uSpeed.value = K('speed');
gl.uniforms.uWarp.value = K('warp');
gl.uniforms.uGrain.value = K('grain');
gl.uniforms.uVignette.value = K('vignette');
gl.applyTheme();
}
ctx = gsap.context(() => {
if (reduced || introPlayed) return;
introPlayed = true;
gsap.from('.ngm__word', { yPercent: 110, duration: 1.2, ease: 'power4.out', stagger: 0.1, delay: 0.2 });
gsap.from(['.ngm__eyebrow', '.ngm__copy', '.ngm__cta', '.ngm__meta'], { y: 18, opacity: 0, duration: 1, ease: 'power3.out', stagger: 0.08, delay: 0.6 });
});
}
build();
// Playground: re-read knobs (renderer is kept; only uniforms / CSS change)
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => { window.removeEventListener('ml:knobs', build); ctx?.revert(); });
```
### EFFECT (Framer & Webflow notes): Noise Gradient Mesh Background
**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 withNoiseGradientMeshBackground(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 `ngm`), 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 src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<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: Noise Gradient Mesh Background
**What it does (visual):** The whole viewport is a slowly moving painting: a deep navy field (`#1b2f6e`), a plum field, a muted lime field (`#93b04f`) and an ink-black field drift on gentle looping paths (one full cycle takes 20–35s at speed 1), and where they meet the boundaries are not straight blends but folded, marbled edges — the colours look like ink dispersing in water because the lookup position is warped (amplitude 0.55) by layered simplex noise that itself slowly flows. Fine animated film grain (amplitude 0.07) sits over everything and the corners fall off into a soft vignette (strength 0.85). Moving the pointer gently drags the folds toward it (the warp centre follows with a long lag), so the picture responds without ever feeling like a cursor effect. Over the canvas, bottom-left, a two-line thin serif headline ("Colour that / *breathes*") rises line by line through a mask, followed by a muted paragraph and a lime pill CTA. If WebGL is unavailable or the user prefers reduced motion, the same four colours render as a static layered CSS radial-gradient with SVG grain.
**Tech stack:**
- Three.js r160+ (`import * as THREE from 'three'`) used only for the `WebGLRenderer`, an `OrthographicCamera(-1, 1, 1, -1, 0, 1)` and a `ShaderMaterial` on a `PlaneGeometry(2, 2)`.
- GLSL ES 1.0 fragment shader (below). No textures, no external assets.
- GSAP 3.13+ (core) for the DOM intro only. CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>`.
**HTML structure:**
```html
<section class="ngm" data-speed="1">
<div class="ngm__canvas"></div> <!-- renderer canvas appended here -->
<div class="ngm__fallback"></div> <!-- CSS gradient, shown only with .is-static -->
<div class="ngm__content">
<p class="ngm__eyebrow">Background · Fragment shader</p>
<h1 class="ngm__title"><span class="ngm__line"><span class="ngm__word">Colour that</span></span><span class="ngm__line"><em class="ngm__word">breathes</em></span></h1>
<p class="ngm__copy">Four slow colour fields, folded through domain-warped simplex noise…</p>
<a class="ngm__cta" href="#collection">Explore the collection <span>→</span></a>
</div>
<p class="ngm__meta">Simplex · fbm · domain warp · grain</p>
</section>
```
**CSS requirements:**
- `.ngm`: `position: relative; min-height: 100vh; display: grid; align-items: end; overflow: hidden; isolation: isolate; background: var(--bg, #0a0a0a); color: var(--text, #ededed);`
- `.ngm__canvas`, `.ngm__fallback`: `position: absolute; inset: 0; z-index: 0;` canvas element `width: 100%; height: 100%; display: block`.
- `.ngm__fallback`: `display: none;` background = `radial-gradient(60% 55% at 28% 36%, #1b2f6e, transparent 70%), radial-gradient(55% 60% at 76% 30%, #4a1f4e, transparent 70%), radial-gradient(50% 45% at 56% 80%, #93b04f, transparent 70%), radial-gradient(45% 50% at 18% 82%, #0a0a0a, transparent 70%), #0a0a0a`; `::before` = inline SVG `feTurbulence` (`baseFrequency .9`, 2 octaves) tile at `opacity .28; mix-blend-mode: overlay`; `::after` = vignette `radial-gradient(70% 70% at 50% 50%, transparent 50%, rgba(0,0,0,.55))`. `.ngm.is-static .ngm__fallback { display: block } .ngm.is-static .ngm__canvas { display: none }`. Light theme swaps the four colours for `#c9d4f0 #e4cfea #dce9a8 #f4f2ee`.
- `.ngm__content`: `position: relative; z-index: 1; padding: clamp(1.5rem, 6vw, 5rem); max-width: 56rem;`
- `.ngm__title`: display serif `font-weight: 300; font-size: clamp(3rem, 9vw, 9rem); line-height: .9; letter-spacing: -0.04em;` `em` italic. `.ngm__line { display: block; overflow: hidden; padding-bottom: .08em; margin-bottom: -.08em; }` (mask for the rise); `.ngm__word { display: inline-block; will-change: transform }` is what actually translates.
- `.ngm__copy`: `max-width: 44ch; color: var(--muted); font-size: clamp(.95rem, 1.2vw, 1.15rem); line-height: 1.5; margin-top: 1.6rem;`
- `.ngm__cta`: pill, `background: var(--accent, #D4FF3A); color: #0a0a0a; padding: .85em 1.5em; border-radius: 999px; margin-top: 2.2rem; font-size: .9rem; font-weight: 500;` hover `translateY(-2px)` + arrow `translateX(4px)` over `.35s cubic-bezier(.2,.7,.2,1)`.
- `.ngm__meta`: mono 11px uppercase muted, `position: absolute; right/bottom: clamp(1.5rem, 6vw, 5rem)`; hidden under 768px.
**Animation spec (exact values):**
1. **Setup**: `WebGLRenderer({ antialias: false, alpha: false })`, `setPixelRatio(Math.min(devicePixelRatio, 1.5))`; `OrthographicCamera(-1, 1, 1, -1, 0, 1)`; `Mesh(PlaneGeometry(2, 2), ShaderMaterial({ depthTest: false, depthWrite: false }))`.
2. **Uniforms**: `uTime` (s, accumulated from a clamped clock delta ≤ 0.05), `uRes` (drawing-buffer px), `uPointer` (vec2 0..1, y up, lerped `0.04`/frame toward the pointer; returns to `(0.5, 0.5)` on `pointerleave`), `uSpeed` (`data-speed`, default 1), `uColors` (`vec3[4]` raw sRGB: `#1b2f6e`, `#4a1f4e`, `#93b04f`, `#0a0a0a`; light theme `#c9d4f0 #e4cfea #dce9a8 #f4f2ee`, swapped by a `MutationObserver` on `data-theme`), `uGrain 0.07`, `uVignette 0.85`, `uWarp 0.55`.
3. **Vertex shader**: `varying vec2 vUv; void main(){ vUv = uv; gl_Position = vec4(position, 1.0); }`
4. **Fragment shader (complete)**:
```glsl
precision highp float;
uniform float uTime; uniform vec2 uRes; uniform vec2 uPointer; uniform float uSpeed;
uniform vec3 uColors[4]; uniform float uGrain; uniform float uVignette; uniform float uWarp;
varying vec2 vUv;
vec3 mod289(vec3 x){ return x - floor(x * (1.0/289.0)) * 289.0; }
vec2 mod289(vec2 x){ return x - floor(x * (1.0/289.0)) * 289.0; }
vec3 permute(vec3 x){ return mod289(((x*34.0)+1.0)*x); }
float snoise(vec2 v){
const vec4 C = vec4(0.211324865405187, 0.366025403784439, -0.577350269189626, 0.024390243902439);
vec2 i = floor(v + dot(v, C.yy)); vec2 x0 = v - i + dot(i, C.xx);
vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
vec4 x12 = x0.xyxy + C.xxzz; x12.xy -= i1; i = mod289(i);
vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0));
vec3 m = max(0.5 - vec3(dot(x0,x0), dot(x12.xy,x12.xy), dot(x12.zw,x12.zw)), 0.0); m = m*m; m = m*m;
vec3 x = 2.0 * fract(p * C.www) - 1.0; vec3 h = abs(x) - 0.5; vec3 ox = floor(x + 0.5); vec3 a0 = x - ox;
m *= 1.79284291400159 - 0.85373472095314 * (a0*a0 + h*h);
vec3 g; g.x = a0.x * x0.x + h.x * x0.y; g.yz = a0.yz * x12.xz + h.yz * x12.yw;
return 130.0 * dot(m, g);
}
float fbm(vec2 p){ float v = 0.0, a = 0.5; mat2 rot = mat2(0.8, 0.6, -0.6, 0.8);
for (int i = 0; i < 4; i++){ v += a * snoise(p); p = rot * p * 2.0 + 10.0; a *= 0.5; } return v; }
float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
void main(){
float aspect = uRes.x / uRes.y; vec2 uv = vUv; vec2 p = vec2(uv.x * aspect, uv.y); float t = uTime * uSpeed;
vec2 pc = vec2(uPointer.x * aspect, uPointer.y); vec2 toP = p - pc; float pull = exp(-dot(toP, toP) * 1.6);
vec2 q = vec2(fbm(p * 1.4 + t * 0.15), fbm(p * 1.4 - t * 0.12 + 5.2));
vec2 r = vec2(fbm(p * 1.4 + q * 1.6 + vec2(1.7, 9.2) + t * 0.10), fbm(p * 1.4 + q * 1.6 + vec2(8.3, 2.8) - t * 0.08));
vec2 w = p + r * uWarp - toP * pull * 0.25;
vec2 c0 = vec2(0.30 + 0.22 * sin(t * 0.31), 0.35 + 0.20 * cos(t * 0.23));
vec2 c1 = vec2(0.75 + 0.20 * cos(t * 0.27 + 1.3), 0.30 + 0.24 * sin(t * 0.19 + 2.1));
vec2 c2 = vec2(0.55 + 0.26 * sin(t * 0.17 + 4.2), 0.78 + 0.16 * cos(t * 0.29 + 0.7));
vec2 c3 = vec2(0.20 + 0.18 * cos(t * 0.21 + 2.6), 0.80 + 0.18 * sin(t * 0.33 + 1.1));
c0.x *= aspect; c1.x *= aspect; c2.x *= aspect; c3.x *= aspect;
float w0 = exp(-dot(w - c0, w - c0) * 2.6); float w1 = exp(-dot(w - c1, w - c1) * 3.0);
float w2 = exp(-dot(w - c2, w - c2) * 3.4); float w3 = exp(-dot(w - c3, w - c3) * 3.0);
float sum = w0 + w1 + w2 + w3 + 1e-4;
vec3 col = (uColors[0]*w0 + uColors[1]*w1 + uColors[2]*w2 + uColors[3]*w3) / sum;
col = mix(uColors[3], col, clamp(sum * 0.9, 0.0, 1.0));
col += fbm(w * 2.5 + t * 0.05) * 0.12;
col += (hash(uv * uRes + fract(uTime) * 100.0) - 0.5) * uGrain;
vec2 vc = uv - 0.5; float vig = 1.0 - smoothstep(0.35, 0.95, dot(vc, vc) * 2.2);
col *= mix(1.0, vig, uVignette);
gl_FragColor = vec4(col, 1.0);
}
```
5. **Loop**: every frame `uTime += dt; uPointer.lerp(target, 0.04); renderer.render(scene, camera)`.
6. **DOM intro** (GSAP, skipped under reduced motion): `gsap.from('.ngm__word', { yPercent: 110, duration: 1.2, ease: 'power4.out', stagger: 0.1, delay: 0.2 })` — the lines rise through their `overflow: hidden` mask; then `gsap.from(['.ngm__eyebrow', '.ngm__copy', '.ngm__cta', '.ngm__meta'], { y: 18, opacity: 0, duration: 1, ease: 'power3.out', stagger: 0.08, delay: 0.6 })`.
**Interaction:** Pointer position over the section sets the warp target; the uniform follows with `lerp 0.04` (≈ 1.2s to settle) and the folds bend toward it with a Gaussian falloff (`exp(-d² × 1.6)`) and a maximum displacement of `0.25` units. Leaving the section returns the centre to the middle. CTA hover lifts 2px and nudges the arrow 4px over 0.35s.
**Responsive behavior:** The shader is resolution-independent (aspect-corrected in `p`), so it simply fills any viewport; the renderer resizes from a `ResizeObserver` on the wrapper and updates `uRes`. Under 768px the mono meta line is hidden and the copy caps at 32ch; the headline scales with `clamp(3rem, 9vw, 9rem)`. On touch devices the pointer pull only happens during a touch-move (no hover), which is fine — the field still drifts on its own.
**Accessibility:** `prefers-reduced-motion: reduce` → no renderer is created; `.ngm` gets `.is-static`, showing the CSS gradient fallback (same palette, SVG grain, vignette) with the content fully visible and no intro. The same fallback is used when `canvas.getContext('webgl2'|'webgl')` returns null. Canvas and fallback are `aria-hidden`; the headline is a real `<h1>`; the CTA is an `<a>` with a visible `outline: 2px solid var(--text)` focus ring and dark-on-lime text (contrast > 10:1).
**Performance rules:** DPR capped at `Math.min(devicePixelRatio, 1.5)` (0.75 in the library hover preview). One draw call, one quad, zero textures. `antialias: false` (the grain hides aliasing anyway). Stop the rAF loop on `ml:pause` and `visibilitychange`, resume on `ml:resume`; `dt` is clamped to 0.05s. Cleanup disposes the geometry, material and renderer, disconnects the `ResizeObserver` and `MutationObserver`, removes listeners and the canvas. The DOM intro lives in `gsap.context()` and is reverted on unmount.
**Customization knobs:**
- `data-speed` on the section → `uSpeed` (1 = demo; 0.5 is meditative, 2 is lively).
- `PALETTE_DARK` / `PALETTE_LIGHT` — four hex colours; order = navy (#1b2f6e), plum, lime (#93b04f), ink (the fourth also fills empty space).
- `GRAIN` (0.07), `VIGNETTE` (0.85), `WARP` (0.55).
- Field tightness: the `2.6`/`3.0`/`3.4` in `exp(-d² × k)` — larger = smaller, crisper blobs.
- Pointer pull: `1.6` (radius) and `0.25` (strength) in the shader; `POINTER_LERP` (0.04).
- Octaves in `fbm` (4) — drop to 3 for low-end devices.
**Acceptance criteria:**
- [ ] The background is in motion from the first frame with marbled, non-linear colour boundaries (not simple radial blends).
- [ ] All four palette colours are visible somewhere on screen at 1200×750 and at 375×812.
- [ ] Moving the pointer bends the field toward it with visible lag; nothing snaps.
- [ ] Grain is fine and animated; corners are darker than the centre.
- [ ] Headline lines rise through a mask over 1.2s with a 0.1s stagger; copy and CTA follow.
- [ ] With reduced motion or no WebGL, the CSS gradient fallback shows the same palette and the content is immediately visible.
- [ ] No console errors; renderer DPR never exceeds 1.5.
Source
Full source code
<section class="ngm" data-speed="1">
<div class="ngm__canvas" aria-hidden="true"></div>
<div class="ngm__fallback" aria-hidden="true"></div>
<div class="ngm__content">
<p class="ngm__eyebrow">Background · Fragment shader</p>
<h1 class="ngm__title"><span class="ngm__line"><span class="ngm__word">Colour that</span></span><span class="ngm__line"><em class="ngm__word">breathes</em></span></h1>
<p class="ngm__copy">Four slow colour fields, folded through domain-warped simplex noise, finished with film grain and a soft vignette — one full-screen pass, no textures.</p>
<a class="ngm__cta" href="#collection">Explore the collection <span aria-hidden="true">→</span></a>
</div>
<p class="ngm__meta">Simplex · fbm · domain warp · grain</p>
</section>
.ngm { position: relative; min-height: 100vh; display: grid; align-items: end; overflow: hidden; background: var(--bg); color: var(--text); isolation: isolate; }
.ngm__canvas { position: absolute; inset: 0; z-index: 0; }
.ngm__canvas canvas { width: 100%; height: 100%; display: block; }
/* Static fallback (no WebGL / reduced motion): the same four colours as layered CSS gradients + SVG grain */
.ngm__fallback {
position: absolute; inset: 0; z-index: 0; display: none;
--c0: var(--knob-field1, #1b2f6e); --c1: #4a1f4e; --c2: var(--knob-field3, #93b04f); --c3: #0a0a0a;
background:
radial-gradient(60% 55% at 28% 36%, var(--c0) 0%, transparent 70%),
radial-gradient(55% 60% at 76% 30%, var(--c1) 0%, transparent 70%),
radial-gradient(50% 45% at 56% 80%, var(--c2) 0%, transparent 70%),
radial-gradient(45% 50% at 18% 82%, var(--c3) 0%, transparent 70%),
var(--c3);
}
.ngm__fallback::before { content: ''; position: absolute; inset: 0; opacity: .28; mix-blend-mode: overlay;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); }
.ngm__fallback::after { content: ''; position: absolute; inset: 0; background: radial-gradient(70% 70% at 50% 50%, transparent 50%, rgba(0,0,0,.55) 100%); }
:root[data-theme="light"] .ngm__fallback { --c0: var(--knob-field1, #c9d4f0); --c1: #e4cfea; --c2: var(--knob-field3, #dce9a8); --c3: #f4f2ee; }
:root[data-theme="light"] .ngm__fallback::after { background: radial-gradient(70% 70% at 50% 50%, transparent 50%, rgba(0,0,0,.12) 100%); }
.ngm.is-static .ngm__fallback { display: block; }
.ngm.is-static .ngm__canvas { display: none; }
.ngm__content { position: relative; z-index: 1; padding: clamp(1.5rem, 6vw, 5rem); max-width: 56rem; }
.ngm__eyebrow { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); margin-bottom: 1.2rem; }
.ngm__title { font-family: var(--font-display); font-weight: 300; font-variation-settings: 'opsz' 144, 'SOFT' 30; font-size: clamp(3rem, 9vw, 9rem); line-height: .9; letter-spacing: -0.04em; }
.ngm__title em { font-style: italic; font-weight: 300; }
.ngm__line { display: block; overflow: hidden; padding-bottom: .08em; margin-bottom: -.08em; }
.ngm__word { display: inline-block; will-change: transform; }
.ngm__copy { margin-top: 1.6rem; max-width: 44ch; color: var(--muted); font-size: clamp(.95rem, 1.2vw, 1.15rem); line-height: 1.5; }
.ngm__cta {
display: inline-flex; align-items: center; gap: .7rem; margin-top: 2.2rem; padding: .85em 1.5em; border-radius: 999px;
background: var(--accent); color: #0a0a0a; text-decoration: none; font-size: .9rem; font-weight: 500; letter-spacing: -0.01em;
transition: transform .35s cubic-bezier(.2,.7,.2,1), background .25s;
}
.ngm__cta span { transition: transform .35s cubic-bezier(.2,.7,.2,1); }
.ngm__cta:hover { transform: translateY(-2px); }
.ngm__cta:hover span { transform: translateX(4px); }
.ngm__cta:focus-visible { outline: 2px solid var(--text); outline-offset: 3px; }
:root[data-theme="light"] .ngm__cta { color: #f4f2ee; }
.ngm__meta { position: absolute; right: clamp(1.5rem, 6vw, 5rem); bottom: clamp(1.5rem, 6vw, 5rem); z-index: 1; font-family: var(--font-mono); font-size: 11px; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); }
@media (max-width: 767px) {
.ngm__meta { display: none; }
.ngm__copy { max-width: 32ch; }
}
@media (prefers-reduced-motion: reduce) {
.ngm__cta, .ngm__cta span { transition: none; }
}
// Noise gradient mesh — one full-screen quad, four drifting colour fields blended through domain-warped simplex fbm.
import * as THREE from 'three';
const PALETTE_DARK = ['#1b2f6e', '#4a1f4e', '#93b04f', '#0a0a0a']; // deep navy, plum, lime-ish, ink
const PALETTE_LIGHT = ['#c9d4f0', '#e4cfea', '#dce9a8', '#f4f2ee'];
const DPR_CAP = 1.5; // full-screen fragment work: 1.5 is plenty
const POINTER_LERP = 0.04; // how quickly the warp centre follows the pointer
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const section = document.querySelector('.ngm');
const mount = section.querySelector('.ngm__canvas');
const speedAttr = parseFloat(section.dataset.speed || '1');
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { speed: speedAttr, warp: 0.55, grain: 0.07, vignette: 0.85, field1: PALETTE_DARK[0], field3: PALETTE_DARK[2] };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
function webglAvailable() {
try { const c = document.createElement('canvas'); return !!(c.getContext('webgl2') || c.getContext('webgl')); } catch { return false; }
}
// ---------------------------------------------------------------- shader
const vertexShader = /* glsl */ `
varying vec2 vUv;
void main() { vUv = uv; gl_Position = vec4(position, 1.0); }
`;
const fragmentShader = /* glsl */ `
precision highp float;
uniform float uTime;
uniform vec2 uRes;
uniform vec2 uPointer; // 0..1, lerped, y up
uniform float uSpeed;
uniform vec3 uColors[4]; // navy, plum, lime, ink (sRGB 0..1)
uniform float uGrain;
uniform float uVignette;
uniform float uWarp;
varying vec2 vUv;
// --- 2D simplex noise (Ashima Arts / Ian McEwan, MIT)
vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec2 mod289(vec2 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec3 permute(vec3 x) { return mod289(((x * 34.0) + 1.0) * x); }
float snoise(vec2 v) {
const vec4 C = vec4(0.211324865405187, 0.366025403784439, -0.577350269189626, 0.024390243902439);
vec2 i = floor(v + dot(v, C.yy));
vec2 x0 = v - i + dot(i, C.xx);
vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
vec4 x12 = x0.xyxy + C.xxzz;
x12.xy -= i1;
i = mod289(i);
vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0));
vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0);
m = m * m; m = m * m;
vec3 x = 2.0 * fract(p * C.www) - 1.0;
vec3 h = abs(x) - 0.5;
vec3 ox = floor(x + 0.5);
vec3 a0 = x - ox;
m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h);
vec3 g;
g.x = a0.x * x0.x + h.x * x0.y;
g.yz = a0.yz * x12.xz + h.yz * x12.yw;
return 130.0 * dot(m, g);
}
float fbm(vec2 p) {
float v = 0.0, a = 0.5;
mat2 rot = mat2(0.8, 0.6, -0.6, 0.8);
for (int i = 0; i < 4; i++) { v += a * snoise(p); p = rot * p * 2.0 + 10.0; a *= 0.5; }
return v;
}
float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
void main() {
float aspect = uRes.x / uRes.y;
vec2 uv = vUv;
vec2 p = vec2(uv.x * aspect, uv.y);
float t = uTime * uSpeed;
// pointer pulls the warp centre toward itself (soft gaussian influence)
vec2 pc = vec2(uPointer.x * aspect, uPointer.y);
vec2 toP = p - pc;
float pull = exp(-dot(toP, toP) * 1.6);
// domain warp: q warps r warps the lookup position
vec2 q = vec2(fbm(p * 1.4 + t * 0.15), fbm(p * 1.4 - t * 0.12 + 5.2));
vec2 r = vec2(fbm(p * 1.4 + q * 1.6 + vec2(1.7, 9.2) + t * 0.10),
fbm(p * 1.4 + q * 1.6 + vec2(8.3, 2.8) - t * 0.08));
vec2 w = p + r * uWarp - toP * pull * 0.25;
// four drifting colour centres (Lissajous paths)
vec2 c0 = vec2(0.30 + 0.22 * sin(t * 0.31), 0.35 + 0.20 * cos(t * 0.23));
vec2 c1 = vec2(0.75 + 0.20 * cos(t * 0.27 + 1.3), 0.30 + 0.24 * sin(t * 0.19 + 2.1));
vec2 c2 = vec2(0.55 + 0.26 * sin(t * 0.17 + 4.2), 0.78 + 0.16 * cos(t * 0.29 + 0.7));
vec2 c3 = vec2(0.20 + 0.18 * cos(t * 0.21 + 2.6), 0.80 + 0.18 * sin(t * 0.33 + 1.1));
c0.x *= aspect; c1.x *= aspect; c2.x *= aspect; c3.x *= aspect;
float w0 = exp(-dot(w - c0, w - c0) * 2.6); // navy gets the widest field
float w1 = exp(-dot(w - c1, w - c1) * 3.0);
float w2 = exp(-dot(w - c2, w - c2) * 3.4); // lime is kept a little tighter
float w3 = exp(-dot(w - c3, w - c3) * 3.0);
float sum = w0 + w1 + w2 + w3 + 1e-4;
vec3 col = (uColors[0] * w0 + uColors[1] * w1 + uColors[2] * w2 + uColors[3] * w3) / sum;
float cover = clamp(sum * 0.9, 0.0, 1.0);
col = mix(uColors[3], col, cover); // ink where no field reaches
col += fbm(w * 2.5 + t * 0.05) * 0.12; // tonal shading in the folds
col += (hash(uv * uRes + fract(uTime) * 100.0) - 0.5) * uGrain; // animated film grain
vec2 vc = uv - 0.5;
float vig = 1.0 - smoothstep(0.35, 0.95, dot(vc, vc) * 2.2);
col *= mix(1.0, vig, uVignette);
gl_FragColor = vec4(col, 1.0);
}
`;
const toVec3 = (hex) => { const n = parseInt(hex.slice(1), 16); return new THREE.Vector3(((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255); };
// Current palette = theme palette with any overridden colour knobs applied to both themes.
function palette() {
const light = document.documentElement.getAttribute('data-theme') === 'light';
const base = [...(light ? PALETTE_LIGHT : PALETTE_DARK)];
const f1 = K('field1'), f3 = K('field3');
if (f1 !== DEFAULTS.field1) base[0] = f1;
if (f3 !== DEFAULTS.field3) base[2] = f3;
return base;
}
// ---------------------------------------------------------------- renderer (created once; knobs only update uniforms)
let gl = null; // { uniforms, applyTheme }
function run() {
const dprCap = window.MotionLab?.preview ? DPR_CAP / 2 : DPR_CAP;
const renderer = new THREE.WebGLRenderer({ antialias: false, alpha: false, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(devicePixelRatio, dprCap));
mount.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const uniforms = {
uTime: { value: 0 },
uRes: { value: new THREE.Vector2(1, 1) },
uPointer: { value: new THREE.Vector2(0.5, 0.5) },
uSpeed: { value: DEFAULTS.speed },
uColors: { value: PALETTE_DARK.map(toVec3) },
uGrain: { value: DEFAULTS.grain },
uVignette: { value: DEFAULTS.vignette },
uWarp: { value: DEFAULTS.warp },
};
const material = new THREE.ShaderMaterial({ vertexShader, fragmentShader, uniforms, depthTest: false, depthWrite: false });
const geometry = new THREE.PlaneGeometry(2, 2);
scene.add(new THREE.Mesh(geometry, material));
// theme (+ colour knobs) → palette
const applyTheme = () => palette().forEach((hex, i) => uniforms.uColors.value[i].copy(toVec3(hex)));
const themeObserver = new MutationObserver(applyTheme);
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
applyTheme();
// pointer (target) → uniform (lerped in the loop)
const target = new THREE.Vector2(0.5, 0.5);
const onPointer = (e) => {
const r = mount.getBoundingClientRect();
target.set((e.clientX - r.left) / r.width, 1 - (e.clientY - r.top) / r.height);
};
const onLeave = () => target.set(0.5, 0.5);
section.addEventListener('pointermove', onPointer);
section.addEventListener('pointerleave', onLeave);
const resize = () => {
const w = mount.clientWidth, h = mount.clientHeight;
if (!w || !h) return;
renderer.setSize(w, h, false);
uniforms.uRes.value.set(w * renderer.getPixelRatio(), h * renderer.getPixelRatio());
};
const ro = new ResizeObserver(resize);
ro.observe(mount);
resize();
const clock = new THREE.Clock();
let raf = 0, running = false;
const frame = () => {
raf = requestAnimationFrame(frame);
const dt = Math.min(clock.getDelta(), 0.05);
uniforms.uTime.value += dt;
uniforms.uPointer.value.lerp(target, POINTER_LERP);
renderer.render(scene, camera);
};
const start = () => { if (running) return; running = true; clock.getDelta(); raf = requestAnimationFrame(frame); };
const stop = () => { running = false; cancelAnimationFrame(raf); };
const onVisibility = () => (document.hidden ? stop() : start());
window.addEventListener('ml:pause', stop);
window.addEventListener('ml:resume', start);
document.addEventListener('visibilitychange', onVisibility);
start();
window.MotionLab?.onCleanup(() => {
stop();
ro.disconnect();
themeObserver.disconnect();
section.removeEventListener('pointermove', onPointer);
section.removeEventListener('pointerleave', onLeave);
window.removeEventListener('ml:pause', stop);
window.removeEventListener('ml:resume', start);
document.removeEventListener('visibilitychange', onVisibility);
geometry.dispose();
material.dispose();
renderer.dispose();
renderer.domElement.remove();
});
gl = { uniforms, applyTheme };
}
// ---------------------------------------------------------------- static fallback path
if (reduced || !webglAvailable()) {
section.classList.add('is-static');
} else {
run();
}
// ---------------------------------------------------------------- build: read knobs → uniforms + CSS fallback colours (+ DOM intro once)
let ctx;
let introPlayed = false;
function build() {
ctx?.revert();
// colour knobs → CSS custom properties read by the static fallback (unset when at default so the light theme keeps its own)
for (const id of ['field1', 'field3']) {
const v = K(id);
if (v !== DEFAULTS[id]) section.style.setProperty(`--knob-${id}`, v); else section.style.removeProperty(`--knob-${id}`);
}
if (gl) {
gl.uniforms.uSpeed.value = K('speed');
gl.uniforms.uWarp.value = K('warp');
gl.uniforms.uGrain.value = K('grain');
gl.uniforms.uVignette.value = K('vignette');
gl.applyTheme();
}
ctx = gsap.context(() => {
if (reduced || introPlayed) return;
introPlayed = true;
gsap.from('.ngm__word', { yPercent: 110, duration: 1.2, ease: 'power4.out', stagger: 0.1, delay: 0.2 });
gsap.from(['.ngm__eyebrow', '.ngm__copy', '.ngm__cta', '.ngm__meta'], { y: 18, opacity: 0, duration: 1, ease: 'power3.out', stagger: 0.08, delay: 0.6 });
});
}
build();
// Playground: re-read knobs (renderer is kept; only uniforms / CSS change)
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => { window.removeEventListener('ml:knobs', build); ctx?.revert(); });