Skip to content
Brok UI

Loading…

No results

Blob

Open source

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.

Version
v1.0.4
Stability
stable
License
MIT
Related
Mesh Gradient
Dither Gradient
Gradient Bars

Preview

previews.components.blob.default.blade.php Blade
<div class="w-full flex justify-center py-4">
    <x-ui.blob class="w-56" />
</div>

Installation

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

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

  • blade resources/views/components/ui/blob.blade.php
  • js resources/js/ui/blob.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.

blob.md
# 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

manifest knowledge + registry-derived coverage

Props

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

blob

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

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 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
root
Theming hooks
blob

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-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>
    <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.

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