Mesh Gradient
A full-bleed animated WebGL mesh gradient: a three.js fragment shader domain-warps simplex noise to flow three token colours (primary/accent/secondary) across the surface. Token-driven, DPR-aware, reduced-motion safe.
Preview
<x-ui.mesh-gradient class="h-64 w-full max-w-xl" />
Installation
php artisan ui:add mesh-gradient
Note
This component ships an Alpine behavior module at
resources/js/ui/mesh-gradient.js. Import it once from your bundle so it registers on alpine:init:
import './mesh-gradient.js';
Registry contract
php artisan ui:add mesh-gradient
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/mesh-gradient.blade.php -
resources/js/ui/mesh-gradient.js
- Registry dependencies
- None — installs on its own.
- Packages
-
composer: jml/brok:^0.2npm: alpinejsnpm: three
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: Mesh Gradient (`mesh-gradient`)
A full-bleed animated WebGL mesh gradient: a three.js fragment shader domain-warps simplex noise to flow three token colours (primary/accent/secondary) across the surface. Token-driven, DPR-aware, reduced-motion safe.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add mesh-gradient
```
## Usage
```blade
<x-ui.mesh-gradient class="h-64 w-full max-w-xl" />
```
## Props
- `speed` (float, default `0.5`) — Animation speed of the noise flow.
- `warp` (float, default `0.9`) — How strongly the domain-warp distorts the colour field.
## Use when
- Use only when visual expression supports storytelling, brand differentiation, or a clear showcase moment.
- Adding a smooth, flowing animated colour wash behind a hero or section.
- Wanting a background that blends token colours (primary/accent/secondary) without the retro dithered texture of dither-gradient.
## Avoid when
- Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
- Wanting a lighter-weight effect without three.js and WebGL; use dither-gradient (canvas-2D) or a plain CSS gradient instead.
## Anti-patterns
- Heavy effects in forms or task-dense workflows
- Motion that interferes with reading or focus
## Rules
- Use the `<brok:mesh-gradient>` tag (or `<x-ui.mesh-gradient>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/mesh-gradient
- Registry JSON (files, props, contract): https://brokui.dev/r/open/mesh-gradient.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| speed | float | 0.5 | Animation speed of the noise flow. |
| warp | float | 0.9 | How strongly the domain-warp distorts the colour field. |
Slots
default— Not used by this component; present for attribute-bag compatibility.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Domain-warps 3D simplex noise in a three.js fragment shader to flow the primary/accent/secondary tokens across a full-screen plane; three.js is lazy-loaded as its own chunk so pages without a mesh gradient pay nothing.
- Colours re-resolve on a theme (class/style) flip via a MutationObserver, so the wash follows light/dark without a reload.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, responsive, rtl, darkMode, localized, interactive, reducedMotion.
Guidance
Support brand expression or a showcase moment.
Use when
- Use only when visual expression supports storytelling, brand differentiation, or a clear showcase moment.
- Adding a smooth, flowing animated colour wash behind a hero or section.
- Wanting a background that blends token colours (primary/accent/secondary) without the retro dithered texture of dither-gradient.
Avoid when
- Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
- Wanting a lighter-weight effect without three.js and WebGL; use dither-gradient (canvas-2D) or a plain CSS gradient instead.
Use instead
- Static content-first presentation
Anti-patterns
- Heavy effects in forms or task-dense workflows
- Motion that interferes with reading or focus
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- managed
- Focus
none
- The host is decorative. Reduced motion paints a single static frame and never starts the render loop.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add wire:ignore to the component root because its behavior owns rendered DOM.
<div wire:ignore>
<x-ui.mesh-gradient class="h-64 w-full max-w-xl" />
</div>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
@props([
'speed' => 0.5,
'warp' => 0.9,
])
@php
// Mesh Gradient — a full-bleed animated WebGL mesh gradient: a fragment
// shader domain-warps 3D simplex noise to flow three token colours across the
// surface. Rendered with three.js (lazy-loaded as its own chunk, like the
// map's maplibre). Colours are token-driven — `--primary` / `--accent` /
// `--secondary` resolved at runtime (re-read on theme flips) — so nothing
// raw-coloured ships here. DPR-aware, reduced-motion safe (one static frame,
// no rAF loop), disposes the renderer/geometry/material in destroy().
//
// - `speed` : flow speed of the gradient.
// - `warp` : domain-warp strength (how much the colours swirl).
$config = [
'speed' => (float) $speed,
'warp' => (float) $warp,
];
@endphp
<div
data-slot="mesh-gradient"
x-data="uiMeshGradient(@js($config))"
x-ref="root"
aria-hidden="true"
{{ $attributes->merge(['class' => 'relative block h-64 w-full overflow-hidden rounded-lg bg-muted text-primary']) }}
>
<canvas x-ref="canvas" class="block size-full"></canvas>
</div>
/**
* Mesh Gradient behaviour — a full-bleed animated WebGL mesh gradient.
*
* A full-screen plane whose fragment shader domain-warps 3D simplex noise to
* flow three token colours across the surface. Rendered with three.js, which is
* lazy-loaded (`await import('three')`) as its own chunk so pages without a mesh
* gradient pay nothing — same approach as the map's maplibre.
*
* Colour is token-driven: `--primary` / `--accent` / `--secondary` resolved from
* full-color aliases or legacy HSL tokens at runtime, re-read on theme (class/style)
* flips via a MutationObserver. Nothing raw-coloured ships in the source.
* DPR-aware (capped at 2) with a ResizeObserver; reduced-motion paints one
* static frame and never starts the rAF loop. destroy() cancels the loop,
* disconnects observers and disposes the geometry, material and renderer.
*
* Self-registers on `alpine:init`. Plain JS — no Blade syntax.
*/
document.addEventListener('alpine:init', () => {
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const hslToRgb = (h, s, l) => {
h = (((h % 360) + 360) % 360) / 360;
s = clamp(s, 0, 100) / 100;
l = clamp(l, 0, 100) / 100;
if (s === 0) return [l, l, l];
const hue = (p, q, t) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
return [hue(p, q, h + 1 / 3), hue(p, q, h), hue(p, q, h - 1 / 3)];
};
// 3D simplex noise (Ashima / Stefan Gustavson, public domain).
const SNOISE = `
vec3 mod289(vec3 x){return x-floor(x*(1.0/289.0))*289.0;}
vec4 mod289(vec4 x){return x-floor(x*(1.0/289.0))*289.0;}
vec4 permute(vec4 x){return mod289(((x*34.0)+1.0)*x);}
vec4 taylorInvSqrt(vec4 r){return 1.79284291400159-0.85373472095314*r;}
float snoise(vec3 v){
const vec2 C=vec2(1.0/6.0,1.0/3.0);
const vec4 D=vec4(0.0,0.5,1.0,2.0);
vec3 i=floor(v+dot(v,C.yyy));
vec3 x0=v-i+dot(i,C.xxx);
vec3 g=step(x0.yzx,x0.xyz);
vec3 l=1.0-g;
vec3 i1=min(g.xyz,l.zxy);
vec3 i2=max(g.xyz,l.zxy);
vec3 x1=x0-i1+C.xxx;
vec3 x2=x0-i2+C.yyy;
vec3 x3=x0-D.yyy;
i=mod289(i);
vec4 p=permute(permute(permute(i.z+vec4(0.0,i1.z,i2.z,1.0))+i.y+vec4(0.0,i1.y,i2.y,1.0))+i.x+vec4(0.0,i1.x,i2.x,1.0));
float n_=0.142857142857;
vec3 ns=n_*D.wyz-D.xzx;
vec4 j=p-49.0*floor(p*ns.z*ns.z);
vec4 x_=floor(j*ns.z);
vec4 y_=floor(j-7.0*x_);
vec4 x=x_*ns.x+ns.yyyy;
vec4 y=y_*ns.x+ns.yyyy;
vec4 h=1.0-abs(x)-abs(y);
vec4 b0=vec4(x.xy,y.xy);
vec4 b1=vec4(x.zw,y.zw);
vec4 s0=floor(b0)*2.0+1.0;
vec4 s1=floor(b1)*2.0+1.0;
vec4 sh=-step(h,vec4(0.0));
vec4 a0=b0.xzyw+s0.xzyw*sh.xxyy;
vec4 a1=b1.xzyw+s1.xzyw*sh.zzww;
vec3 p0=vec3(a0.xy,h.x);
vec3 p1=vec3(a0.zw,h.y);
vec3 p2=vec3(a1.xy,h.z);
vec3 p3=vec3(a1.zw,h.w);
vec4 norm=taylorInvSqrt(vec4(dot(p0,p0),dot(p1,p1),dot(p2,p2),dot(p3,p3)));
p0*=norm.x;p1*=norm.y;p2*=norm.z;p3*=norm.w;
vec4 m=max(0.6-vec4(dot(x0,x0),dot(x1,x1),dot(x2,x2),dot(x3,x3)),0.0);
m=m*m;
return 42.0*dot(m*m,vec4(dot(p0,x0),dot(p1,x1),dot(p2,x2),dot(p3,x3)));
}
`;
const VERT = `
varying vec2 vUv;
void main(){
vUv = uv;
gl_Position = vec4(position.xy, 0.0, 1.0);
}
`;
const FRAG = `
precision highp float;
varying vec2 vUv;
uniform float u_time;
uniform float u_warp;
uniform float u_aspect;
uniform vec3 u_c1;
uniform vec3 u_c2;
uniform vec3 u_c3;
${SNOISE}
void main(){
vec2 uv = vUv;
uv.x *= u_aspect;
float t = u_time;
vec2 q = vec2(snoise(vec3(uv * 1.3, t * 0.10)), snoise(vec3(uv * 1.3 + 5.2, t * 0.11)));
vec2 r = vec2(
snoise(vec3(uv * 1.3 + q * u_warp + 1.7, t * 0.15)),
snoise(vec3(uv * 1.3 + q * u_warp + 9.2, t * 0.126))
);
float f = snoise(vec3(uv * 1.3 + r * u_warp, t * 0.2)) * 0.5 + 0.5;
float g = clamp(0.5 + 0.5 * r.x, 0.0, 1.0);
vec3 col = mix(u_c1, u_c2, f);
col = mix(col, u_c3, g * 0.55);
gl_FragColor = vec4(col, 1.0);
}
`;
// three.js objects must NOT live on the Alpine component (`this`): Alpine
// wraps `this` in a reactive Proxy, and a proxied three.js renderer/scene
// breaks (matrices are non-configurable), so render() would throw every
// frame. Keep the graphics context in a module-level WeakMap keyed by the
// root element, fully outside Alpine reactivity.
const gfx = new WeakMap();
window.Alpine.data('uiMeshGradient', (config = {}) => ({
speed: config.speed ?? 0.5,
warp: config.warp ?? 0.9,
_reduced: false,
_raf: null,
_lastTime: 0,
_viewObserver: null,
_offscreen: false,
async init() {
this._reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let THREE;
try {
THREE = await import('three');
} catch (e) {
return;
}
const renderer = new THREE.WebGLRenderer({ canvas: this.$refs.canvas, antialias: true });
const scene = new THREE.Scene();
const camera = new THREE.Camera();
const geo = new THREE.PlaneGeometry(2, 2);
const mat = new THREE.ShaderMaterial({
uniforms: {
u_time: { value: 0 },
u_warp: { value: this.warp },
u_aspect: { value: 1 },
u_c1: { value: new THREE.Color(1, 1, 1) },
u_c2: { value: new THREE.Color(1, 1, 1) },
u_c3: { value: new THREE.Color(1, 1, 1) },
},
vertexShader: VERT,
fragmentShader: FRAG,
});
const mesh = new THREE.Mesh(geo, mat);
scene.add(mesh);
gfx.set(this.$refs.root, { renderer, scene, camera, geo, mat, mesh });
this.resolveColors();
const g = gfx.get(this.$refs.root);
g.mo = new MutationObserver(() => this.resolveColors());
g.mo.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class', 'style', 'data-theme'],
});
g.ro = new ResizeObserver(() => this.resize());
g.ro.observe(this.$refs.root);
this.resize();
this.renderFrame(0);
if (!this._reduced) this.loop();
// Pause the rAF loop while fully scrolled out of view (same-tab;
// backgrounded tabs are already throttled by the browser) so
// off-screen instances don't burn GPU/CPU. Resume on re-entry.
// Static/reduced-motion renders never set _raf, so the `&& this._raf`
// guard leaves them untouched.
if (typeof IntersectionObserver !== 'undefined') {
this._viewObserver = new IntersectionObserver((entries) => {
const visible = entries.some((e) => e.isIntersecting);
if (!visible && this._raf) {
cancelAnimationFrame(this._raf);
this._raf = null;
this._offscreen = true;
} else if (visible && this._offscreen) {
this._offscreen = false;
this.loop();
}
});
this._viewObserver.observe(this.$refs.root);
}
},
resolveColors() {
const g = gfx.get(this.$refs.root);
if (!g) return;
const primary = window.UI?.resolveColorRgb?.('primary', this.$refs.root);
const accent = window.UI?.resolveColorRgb?.('accent', this.$refs.root);
const secondary = window.UI?.resolveColorRgb?.('secondary', this.$refs.root);
const c1 = primary ? [primary.r / 255, primary.g / 255, primary.b / 255] : hslToRgb(240, 80, 52);
const c2 = accent ? [accent.r / 255, accent.g / 255, accent.b / 255] : hslToRgb(285, 70, 64);
const c3 = secondary ? [secondary.r / 255, secondary.g / 255, secondary.b / 255] : hslToRgb(210, 40, 72);
g.mat.uniforms.u_c1.value.setRGB(c1[0], c1[1], c1[2]);
g.mat.uniforms.u_c2.value.setRGB(c2[0], c2[1], c2[2]);
g.mat.uniforms.u_c3.value.setRGB(c3[0], c3[1], c3[2]);
},
resize() {
const g = gfx.get(this.$refs.root);
if (!g) return;
const el = this.$refs.root;
const w = Math.max(1, el.clientWidth || 1);
const h = Math.max(1, el.clientHeight || 1);
const dpr = Math.min(window.devicePixelRatio || 1, 2);
g.renderer.setPixelRatio(dpr);
g.renderer.setSize(w, h, false);
g.mat.uniforms.u_aspect.value = w / h;
if (this._reduced) this.renderFrame(this._lastTime);
},
renderFrame(timeMs) {
const g = gfx.get(this.$refs.root);
if (!g) return;
this._lastTime = timeMs;
g.mat.uniforms.u_time.value = (timeMs / 1000) * this.speed;
g.renderer.render(g.scene, g.camera);
},
loop() {
const tick = (t) => {
this.renderFrame(t);
this._raf = requestAnimationFrame(tick);
};
this._raf = requestAnimationFrame(tick);
},
destroy() {
if (this._viewObserver) { this._viewObserver.disconnect(); this._viewObserver = null; }
if (this._raf) cancelAnimationFrame(this._raf);
const g = gfx.get(this.$refs.root);
if (g) {
if (g.mo) g.mo.disconnect();
if (g.ro) g.ro.disconnect();
if (g.geo) g.geo.dispose();
if (g.mat) g.mat.dispose();
if (g.renderer) {
g.renderer.dispose();
if (g.renderer.forceContextLoss) g.renderer.forceContextLoss();
}
gfx.delete(this.$refs.root);
}
this._raf = null;
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.0.5 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0