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.

Loading

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.

prompt.md · Noise Gradient Mesh Background
### 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>