Blob
A decorative 3D organic blob: an icosahedron sphere displaced by animated 3D simplex noise in a custom three.js vertex shader, with a fresnel rim. Token-driven colour (primary core, accent rim), DPR-aware and reduced-motion safe.
Preview
<div class="w-full flex justify-center py-4">
<x-ui.blob class="w-56" />
</div>
Installation
php artisan ui:add blob
Note
This component ships an Alpine behavior module at
resources/js/ui/blob.js. Import it once from your bundle so it registers on alpine:init:
import './blob.js';
Registry contract
php artisan ui:add blob
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/blob.blade.php -
resources/js/ui/blob.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: Blob (`blob`)
A decorative 3D organic blob: an icosahedron sphere displaced by animated 3D simplex noise in a custom three.js vertex shader, with a fresnel rim. Token-driven colour (primary core, accent rim), DPR-aware and 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 blob
```
## Usage
```blade
<div class="w-full flex justify-center py-4">
<x-ui.blob class="w-56" />
</div>
```
## Props
- `speed` (float, default `0.6`) — Flow speed of the animated surface noise.
- `intensity` (float, default `0.35`) — Displacement amount controlling how wobbly the silhouette looks.
- `label` (mixed|null, default `null`) — Accessible label for the shape; defaults to 'Decorative 3D blob'.
## Use when
- Use only when visual expression supports storytelling, brand differentiation, or a clear showcase moment.
- Adding a decorative, ambient 3D shape, such as a hero background accent, that reacts subtly without competing with content.
## Avoid when
- Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
- The page needs to stay light on GPU and JS cost, such as a low-power or print context; use a static image or mesh-gradient instead.
## Anti-patterns
- Heavy effects in forms or task-dense workflows
- Motion that interferes with reading or focus
## Rules
- Use the `<brok:blob>` tag (or `<x-ui.blob>`) 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/blob
- Registry JSON (files, props, contract): https://brokui.dev/r/open/blob.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.6 | Flow speed of the animated surface noise. |
| intensity | float | 0.35 | Displacement amount controlling how wobbly the silhouette looks. |
| label | mixed | null | null | Accessible label for the shape; defaults to 'Decorative 3D blob'. |
Slots
default—
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Renders an icosahedron sphere in three.js whose surface is displaced by animated simplex noise and lit with a fresnel rim; speed and intensity control the flow and wobble.
- Colors are resolved from the primary and accent design tokens at runtime and re-read on theme flips, so no raw color ships.
- Under reduced motion it renders one static frame instead of looping; the renderer, geometry, and material are disposed on destroy.
- 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 decorative, ambient 3D shape, such as a hero background accent, that reacts subtly without competing with content.
Avoid when
- Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
- The page needs to stay light on GPU and JS cost, such as a low-power or print context; use a static image or mesh-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 root carries role=img and an aria-label, defaulting to 'Decorative 3D blob', since the canvas itself is aria-hidden.
- 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>
<div class="w-full flex justify-center py-4">
<x-ui.blob class="w-56" />
</div>
</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.6,
'intensity' => 0.35,
'label' => null,
])
@php
// Blob — a decorative 3D organic blob: an icosahedron sphere whose surface is
// displaced by animated 3D simplex noise in a custom vertex shader, lit with a
// fresnel rim. Rendered with three.js (lazy-loaded as its own chunk, like the
// map's maplibre). Colour is token-driven: the core hue comes from --primary
// and the rim from --accent, resolved from the design tokens at runtime in JS
// (re-read on theme flips) — nothing raw-coloured ships here. DPR-aware,
// reduced-motion safe (one static frame, no rAF loop), and disposes the
// renderer/geometry/material in destroy().
//
// - `speed` : flow speed of the surface noise.
// - `intensity` : displacement amount (how "wobbly" the silhouette is).
$config = [
'speed' => (float) $speed,
'intensity' => (float) $intensity,
];
$ariaLabel = $label ?? __('Decorative 3D blob');
@endphp
<div
data-slot="blob"
x-data="uiBlob(@js($config))"
x-ref="root"
role="img"
aria-label="{{ $ariaLabel }}"
{{ $attributes->merge(['class' => 'relative block aspect-square w-48 text-primary']) }}
>
<canvas x-ref="canvas" class="block size-full" aria-hidden="true"></canvas>
</div>
/**
* Blob behaviour — a decorative 3D organic blob.
*
* An icosahedron sphere whose surface is displaced by animated 3D simplex noise
* in a custom vertex shader, lit with a fresnel rim. Rendered with three.js,
* which is lazy-loaded (`await import('three')`) as its own chunk so pages
* without a blob pay nothing — same approach as the map's maplibre.
*
* Colour is token-driven: the core comes from `--primary` and the rim from
* `--accent`, resolved from full-color aliases or legacy HSL tokens at runtime and
* 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 (with a forced context loss).
*
* 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));
// HSL (h 0..360, s/l 0..100) → [r, g, b] in 0..1.
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 float vDisp;
varying vec3 vNormal;
varying vec3 vView;
uniform float u_time;
uniform float u_intensity;
${SNOISE}
void main(){
float n = snoise(position * 1.4 + vec3(0.0, 0.0, u_time));
float n2 = snoise(position * 2.9 - vec3(0.0, 0.0, u_time * 0.6)) * 0.5;
float disp = (n + n2) * u_intensity;
vDisp = disp;
vec3 newPos = position + normal * disp;
vNormal = normalize(normalMatrix * normal);
vec4 mv = modelViewMatrix * vec4(newPos, 1.0);
vView = normalize(-mv.xyz);
gl_Position = projectionMatrix * mv;
}
`;
const FRAG = `
precision highp float;
varying float vDisp;
varying vec3 vNormal;
varying vec3 vView;
uniform vec3 u_c1;
uniform vec3 u_c2;
void main(){
float fres = pow(1.0 - max(dot(vNormal, vView), 0.0), 2.2);
vec3 base = mix(u_c1, u_c2, smoothstep(-0.35, 0.45, vDisp));
vec3 col = base + fres * 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 Object3D/renderer
// breaks (its matrices are non-configurable), so render() would throw every
// frame. Keep the whole graphics context in a module-level WeakMap keyed by
// the root element, fully outside Alpine reactivity.
const gfx = new WeakMap();
window.Alpine.data('uiBlob', (config = {}) => ({
speed: config.speed ?? 0.6,
intensity: config.intensity ?? 0.35,
_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; // three unavailable — leave the empty canvas, no error
}
const renderer = new THREE.WebGLRenderer({ canvas: this.$refs.canvas, alpha: true, antialias: true });
renderer.setClearAlpha(0);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
camera.position.z = 3.2;
const geo = new THREE.IcosahedronGeometry(1, 48);
const mat = new THREE.ShaderMaterial({
uniforms: {
u_time: { value: 0 },
u_intensity: { value: this.intensity },
u_c1: { value: new THREE.Color(1, 1, 1) },
u_c2: { 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 c1 = primary ? [primary.r / 255, primary.g / 255, primary.b / 255] : hslToRgb(240, 80, 54);
const c2 = accent ? [accent.r / 255, accent.g / 255, accent.b / 255] : hslToRgb(285, 70, 66);
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]);
},
resize() {
const g = gfx.get(this.$refs.root);
if (!g) return;
const el = this.$refs.root;
const size = Math.max(1, Math.min(el.clientWidth, el.clientHeight) || el.clientWidth || 1);
const dpr = Math.min(window.devicePixelRatio || 1, 2);
g.renderer.setPixelRatio(dpr);
g.renderer.setSize(size, size, false);
g.camera.aspect = 1;
g.camera.updateProjectionMatrix();
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.mesh.rotation.y = (timeMs / 1000) * this.speed * 0.25;
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.4 - 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