Skip to content
Brok UI

Loading…

No results

Mesh Gradient

Open source

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.

Version
v1.0.5
Stability
stable
License
MIT
Related
Dither Gradient
Gradient Bars
Dot Pattern

Preview

previews.components.mesh-gradient.default.blade.php Blade
<x-ui.mesh-gradient class="h-64 w-full max-w-xl" />

Installation

terminal
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:

resources/js/ui/index.js JS
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.

  • blade resources/views/components/ui/mesh-gradient.blade.php
  • js resources/js/ui/mesh-gradient.js
Registry dependencies
None — installs on its own.
Packages
composer: jml/brok:^0.2
npm: alpinejs
npm: 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.

mesh-gradient.md
# 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

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
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.

mesh-gradient

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

Expressive and showcase effect

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
root
Theming hooks
mesh-gradient

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, 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 under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:ignore

Add wire:ignore to the component root because its behavior owns rendered DOM.

livewire-component.blade.php Blade
<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.

resources/views/components/ui/mesh-gradient.blade.php Blade
@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>
resources/js/ui/mesh-gradient.js JS
/**
 * 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