Closing Plasma
A footer-ready closing section sitting above a raw WebGL plasma background: a full-screen quad runs a layered, domain-warped noise plasma with atmospheric grain, optional sparkle and an edge vignette, themed from the design tokens (--background/--foreground/--primary). A smoothed pointer uniform lets the cursor warp the flow when interactive. DPR-aware, ResizeObserver-driven, context-loss safe, and renders a single static frame under reduced motion.
Preview
@props([
// Animation speed multiplier (0 effectively freezes; reduced-motion always
// renders a single static frame regardless of this value).
'speed' => 1,
// Domain-warp strength of the plasma flow (higher = more turbulent swirls).
'turbulence' => 1,
// Atmospheric film-grain amount layered over the plasma (0..1-ish).
'grain' => 0.08,
// Density/brightness of the small sparkle points (0 disables them).
'sparkle' => 0.3,
// Edge darkening that grounds the content for contrast (0..1).
'vignette' => 0.5,
// Pointer-driven flow: the cursor lerps a uniform that warps the plasma.
'interactive' => true,
// Baked sample footer content (it's a block, not a primitive).
'heading' => 'Ready to ship something that glows?',
'subheading' => 'Install the source into your own codebase, theme it with your tokens, and own every pixel.',
'cta' => 'Get started',
'ctaHref' => '#',
'links' => null,
'copyright' => null,
])
@php
// Footer-style link row, token-themed and localisable.
$links ??= [
['label' => 'Components', 'href' => '#'],
['label' => 'Blocks', 'href' => '#'],
['label' => 'Pricing', 'href' => '#'],
['label' => 'Docs', 'href' => '#'],
['label' => 'GitHub', 'href' => '#'],
];
$copyright ??= '© '.date('Y').' '.__('All rights reserved.');
@endphp
{{--
Closing Plasma. A single full-bleed <canvas> sits in an aria-hidden,
pointer-events-none decorative layer behind the footer content. The
uiClosingPlasma Alpine behaviour compiles a WebGL1 vertex+fragment shader,
draws a full-screen quad, and animates a layered domain-warped noise plasma
(atmospheric grain + optional sparkle + edge vignette) via a u_time uniform
on rAF. When `interactive`, a u_pointer uniform is lerped toward the cursor
so the flow leans toward the pointer.
All colour lives in the shader: the JS resolves the host's --background /
--foreground / --primary tokens (via an rgb probe, so oklch resolves to rgb)
and derives base/mid/highlight tones from them — there are deliberately NO
colour utilities or inline colours in this markup. A token-themed scrim keeps
the footer content legible. Reduced-motion users get one static frame, no
rAF and no pointer flow.
--}}
<x-ui.section
x-data="uiClosingPlasma({
speed: {{ (float) $speed }},
turbulence: {{ (float) $turbulence }},
grain: {{ (float) $grain }},
sparkle: {{ (float) $sparkle }},
vignette: {{ (float) $vignette }},
interactive: {{ $interactive ? 'true' : 'false' }},
})"
x-init="init()"
data-slot="closing-cta-02"
space="none" class="relative isolate overflow-hidden" {{ $attributes }}
>
{{-- Decorative WebGL layer: behind content, non-interactive, invisible to AT. --}}
<div aria-hidden="true" class="pointer-events-none absolute inset-0 -z-10">
<canvas x-ref="canvas" class="size-full"></canvas>
</div>
{{-- Token-themed scrim so the plasma never undermines content contrast. --}}
<div aria-hidden="true" class="pointer-events-none absolute inset-0 -z-10 bg-gradient-to-t from-background via-background/60 to-background/20"></div>
{{-- Footer content overlay composing the button primitive. --}}
<div class="relative z-10 mx-auto max-w-5xl px-4 py-24 sm:px-6 sm:py-32 lg:px-8">
<div class="max-w-2xl">
<h2 class="text-balance text-3xl font-bold tracking-tight text-foreground sm:text-4xl lg:text-5xl">
{{ __($heading) }}
</h2>
@if ($subheading)
<p class="mt-6 max-w-xl text-pretty text-lg text-muted-foreground">
{{ __($subheading) }}
</p>
@endif
<div class="mt-8">
<x-ui.button size="lg" :href="$ctaHref" with-arrow>{{ __($cta) }}</x-ui.button>
</div>
</div>
<div class="my-10 border-t border-border"></div>
<div class="flex flex-col gap-6 sm:flex-row sm:items-center sm:justify-between">
<nav aria-label="{{ __('Footer') }}">
<ul class="flex flex-wrap gap-x-6 gap-y-4 text-sm">
@foreach ($links as $link)
<li>
<a href="{{ $link['href'] }}" class="text-muted-foreground transition-colors hover:text-foreground">{{ __($link['label']) }}</a>
</li>
@endforeach
</ul>
</nav>
<p class="text-sm text-muted-foreground">{{ $copyright }}</p>
</div>
</div>
</x-ui.section>
/**
* Closing Plasma — raw WebGL1 plasma background driven by inline 2D simplex noise.
*
* A full-screen quad is rendered into an aria-hidden <canvas> that fills the
* footer-ready section. The fragment shader layers domain-warped fractal noise
* over `u_time` to produce a slow, premium plasma flow, then adds atmospheric
* film grain, optional bright sparkle points and an edge vignette, blending
* three token-derived tones (base / mid / highlight) chosen from the active
* theme so the background always tracks light/dark and brand overrides.
*
* Colours are NOT hard-coded: at init we read the live computed values of
* --background / --foreground / --primary from the host element via
* getComputedStyle + a throwaway "rgb probe" span (so oklch/hsl resolve to
* rgb). The palette tones are derived by mixing those tokens — base sits near
* the background, mid leans on --primary, highlight pulls toward --foreground —
* and the JS detects whether the theme is dark vs light from the background
* luminance to keep contrast pleasant either way.
*
* Pointer-driven flow: when `interactive`, a pointermove listener on the host
* section records the cursor in 0..1 space and a smoothed `u_pointer` uniform is
* lerped toward it every frame, warping the plasma toward the cursor. On leave
* the target eases back to the centre.
*
* Responsiveness: a ResizeObserver + devicePixelRatio drive the canvas backing
* store; the viewport and u_resolution update on every change. Context loss is
* handled (webglcontextlost / webglcontextrestored) by tearing down and
* rebuilding GL state. Reduced-motion users get a single static frame (u_time
* frozen, pointer centred) with no requestAnimationFrame loop and no pointer
* listener.
*
* Config: { speed, turbulence, grain, sparkle, vignette, interactive }.
* Self-registers on `alpine:init` so import order is irrelevant; destroy()
* cancels the rAF, disconnects observers, removes listeners, deletes all GL
* objects and loses the context.
*
* Note: this file is plain JS served by the registry — no Blade syntax allowed.
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiClosingPlasma', (config = {}) => ({
speed: Number.isFinite(+config.speed) ? +config.speed : 1,
turbulence: Number.isFinite(+config.turbulence) ? +config.turbulence : 1,
grain: Number.isFinite(+config.grain) ? +config.grain : 0.08,
sparkle: Number.isFinite(+config.sparkle) ? +config.sparkle : 0.3,
vignette: Number.isFinite(+config.vignette) ? +config.vignette : 0.5,
interactive: config.interactive !== false,
// Non-reactive GL handles kept off Alpine's proxy.
_gl: null,
_canvas: null,
_program: null,
_buffer: null,
_uniforms: null,
_raf: 0,
_start: 0,
_ro: null,
_colors: null,
_onLost: null,
_onRestored: null,
_onMove: null,
_onLeave: null,
_pointerTarget: { x: 0.5, y: 0.5 },
_pointer: { x: 0.5, y: 0.5 },
_reduced: false,
init() {
this._canvas = this.$refs.canvas;
if (!this._canvas) return;
this._reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
this._colors = this._readColors();
this._onLost = (e) => {
e.preventDefault();
cancelAnimationFrame(this._raf);
this._raf = 0;
};
this._onRestored = () => this._setup();
this._canvas.addEventListener('webglcontextlost', this._onLost, false);
this._canvas.addEventListener('webglcontextrestored', this._onRestored, false);
this._ro = new ResizeObserver(() => this._resize());
this._ro.observe(this._canvas);
// Pause the shader while the section is scrolled out of view. A
// full-viewport fragment shader otherwise keeps the GPU hot for a
// hero the reader scrolled past minutes ago (same gate as ui/orb).
// Only a running loop is paused, so the reduced-motion static frame
// and a lost context are left alone; `_resume` re-enters the loop
// without rebuilding the program.
if (typeof IntersectionObserver !== 'undefined') {
this._viewObserver = new IntersectionObserver((entries) => {
const visible = entries.some((entry) => entry.isIntersecting);
if (!visible && this._raf) {
cancelAnimationFrame(this._raf);
this._raf = 0;
this._offscreen = true;
} else if (visible && this._offscreen) {
this._offscreen = false;
if (!this._reduced && this._program) this._resume();
}
});
this._viewObserver.observe(this._canvas);
}
// Pointer flow listens on the host section (the canvas itself is
// pointer-events-none so it never blocks the footer content).
if (this.interactive && !this._reduced) {
const host = this.$el;
this._onMove = (e) => {
const rect = host.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return;
this._pointerTarget.x = (e.clientX - rect.left) / rect.width;
// Flip Y so the uniform matches gl_FragCoord (origin bottom-left).
this._pointerTarget.y = 1 - (e.clientY - rect.top) / rect.height;
};
this._onLeave = () => { this._pointerTarget.x = 0.5; this._pointerTarget.y = 0.5; };
host.addEventListener('pointermove', this._onMove);
host.addEventListener('pointerleave', this._onLeave);
}
this._setup();
},
destroy() {
cancelAnimationFrame(this._raf);
this._raf = 0;
if (this._viewObserver) { this._viewObserver.disconnect(); this._viewObserver = null; }
if (this._ro) { this._ro.disconnect(); this._ro = null; }
if (this._canvas) {
this._canvas.removeEventListener('webglcontextlost', this._onLost, false);
this._canvas.removeEventListener('webglcontextrestored', this._onRestored, false);
}
if (this._onMove) this.$el.removeEventListener('pointermove', this._onMove);
if (this._onLeave) this.$el.removeEventListener('pointerleave', this._onLeave);
this._onMove = this._onLeave = null;
const gl = this._gl;
if (gl) {
if (this._buffer) gl.deleteBuffer(this._buffer);
if (this._program) gl.deleteProgram(this._program);
const lose = gl.getExtension('WEBGL_lose_context');
if (lose) lose.loseContext();
}
this._gl = this._program = this._buffer = this._uniforms = null;
},
/**
* Resolve a CSS custom property to an [r,g,b] triple in 0..1. The value
* may be any CSS colour form (hsl(), oklch(), hex, bare channels), so we
* paint it onto a probe element and let the browser normalise it to rgb.
*/
_readColors() {
const host = this.$el;
const probe = document.createElement('span');
probe.style.cssText = 'position:absolute;width:0;height:0;pointer-events:none;';
host.appendChild(probe);
const read = (token, fallback) => {
probe.style.color = '';
probe.style.color = `var(${token})`;
const c = getComputedStyle(probe).color;
const m = c.match(/-?\d+\.?\d*/g);
if (!m || m.length < 3) return fallback;
return [(+m[0]) / 255, (+m[1]) / 255, (+m[2]) / 255];
};
const background = read('--background', [0.04, 0.04, 0.06]);
const foreground = read('--foreground', [0.95, 0.95, 0.97]);
const primary = read('--primary', [0.4, 0.3, 0.9]);
probe.remove();
// Perceived luminance of the background picks dark vs light tones.
const lum = 0.2126 * background[0] + 0.7152 * background[1] + 0.0722 * background[2];
const isDark = lum < 0.5;
const mix = (a, b, t) => [
a[0] + (b[0] - a[0]) * t,
a[1] + (b[1] - a[1]) * t,
a[2] + (b[2] - a[2]) * t,
];
// base: stays close to the surface so the plasma reads as a wash, not
// a paint-over. mid: the chromatic body, anchored on --primary. On a
// light theme nudge mid toward the background so it doesn't overpower
// the content; on dark, let primary glow. highlight: bright crests
// pulling toward --foreground for the premium "plasma" sheen.
const base = mix(background, primary, isDark ? 0.10 : 0.06);
const mid = mix(background, primary, isDark ? 0.55 : 0.40);
const highlight = mix(primary, foreground, isDark ? 0.45 : 0.30);
return { base, mid, highlight };
},
_setup() {
const gl = this._canvas.getContext('webgl', { antialias: false, premultipliedAlpha: false })
|| this._canvas.getContext('experimental-webgl');
if (!gl) return;
this._gl = gl;
const vert = `
attribute vec2 a_position;
void main() { gl_Position = vec4(a_position, 0.0, 1.0); }
`;
// 2D simplex noise (Ashima / Gustavson, public domain) inlined for
// WebGL1 GLSL ES 1.00 so the block ships with zero JS noise deps.
const frag = `
precision highp float;
uniform float u_time;
uniform vec2 u_resolution;
uniform vec2 u_pointer;
uniform float u_turbulence;
uniform float u_grain;
uniform float u_sparkle;
uniform float u_vignette;
uniform vec3 u_base;
uniform vec3 u_mid;
uniform vec3 u_highlight;
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);
}
// Three octaves of flowing noise for a rich plasma body.
float fbm(vec2 p){
float v = 0.0;
v += 0.55 * snoise(p);
v += 0.30 * snoise(p * 2.07 + 11.0);
v += 0.15 * snoise(p * 4.11 - 5.0);
return v;
}
// Cheap hash for grain + sparkle (no extra texture sampling).
float hash21(vec2 p){
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
void main(){
vec2 uv = gl_FragCoord.xy / u_resolution.xy;
float aspect = u_resolution.x / max(u_resolution.y, 1.0);
vec2 p = vec2((uv.x - 0.5) * aspect, uv.y - 0.5);
p *= 2.0;
float t = u_time * 0.10;
// Pointer pull: bend the sample point toward the cursor so the
// plasma flows in that direction. Strength falls off with
// distance from the pointer for a localised swirl.
vec2 pc = vec2((u_pointer.x - 0.5) * aspect, u_pointer.y - 0.5) * 2.0;
vec2 toP = pc - p;
float pull = exp(-dot(toP, toP) * 0.8);
p += toP * pull * 0.35;
// Domain-warp the sample point so colour fronts curl and swirl.
float warp = u_turbulence;
vec2 q = vec2(fbm(p + vec2(0.0, t)), fbm(p + vec2(5.2, -t)));
vec2 r = vec2(
fbm(p + warp * q + vec2(1.7, 9.2) + 0.30 * t),
fbm(p + warp * q + vec2(8.3, 2.8) - 0.27 * t)
);
float n = fbm(p + warp * r + vec2(t * 0.5, -t * 0.5));
n = n * 0.5 + 0.5;
float band = fbm(p * 0.7 - vec2(t * 0.3, t * 0.2)) * 0.5 + 0.5;
// Layer the three token tones from base → mid → highlight.
vec3 col = mix(u_base, u_mid, smoothstep(0.20, 0.80, n));
col = mix(col, u_highlight, smoothstep(0.55, 0.98, band * n) * 0.85);
// Brighten the crest nearest the pointer for a live feel.
col = mix(col, u_highlight, pull * 0.18);
// Optional sparkle: sparse, twinkling bright points.
if (u_sparkle > 0.0) {
vec2 cell = floor(uv * u_resolution.xy / 3.0);
float s = hash21(cell);
float twinkle = step(0.9985 - u_sparkle * 0.0015, s);
float flicker = 0.5 + 0.5 * sin(u_time * 6.0 + s * 100.0);
col += u_highlight * twinkle * flicker * (0.6 + 0.4 * n);
}
// Atmospheric grain.
float g = hash21(gl_FragCoord.xy + fract(u_time) * 100.0) - 0.5;
col += g * u_grain;
// Edge vignette grounds the content for contrast.
float vig = smoothstep(1.25, 0.20, length(p));
col = mix(u_base, col, mix(1.0, vig, u_vignette));
gl_FragColor = vec4(col, 1.0);
}
`;
const program = this._link(gl, vert, frag);
if (!program) return;
this._program = program;
gl.useProgram(program);
this._buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, this._buffer);
gl.bufferData(gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
const loc = gl.getAttribLocation(program, 'a_position');
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
this._uniforms = {
time: gl.getUniformLocation(program, 'u_time'),
resolution: gl.getUniformLocation(program, 'u_resolution'),
pointer: gl.getUniformLocation(program, 'u_pointer'),
turbulence: gl.getUniformLocation(program, 'u_turbulence'),
grain: gl.getUniformLocation(program, 'u_grain'),
sparkle: gl.getUniformLocation(program, 'u_sparkle'),
vignette: gl.getUniformLocation(program, 'u_vignette'),
base: gl.getUniformLocation(program, 'u_base'),
mid: gl.getUniformLocation(program, 'u_mid'),
highlight: gl.getUniformLocation(program, 'u_highlight'),
};
gl.uniform1f(this._uniforms.turbulence, this.turbulence);
gl.uniform1f(this._uniforms.grain, this.grain);
gl.uniform1f(this._uniforms.sparkle, this.sparkle);
gl.uniform1f(this._uniforms.vignette, this.vignette);
gl.uniform3fv(this._uniforms.base, this._colors.base);
gl.uniform3fv(this._uniforms.mid, this._colors.mid);
gl.uniform3fv(this._uniforms.highlight, this._colors.highlight);
gl.uniform2f(this._uniforms.pointer, this._pointer.x, this._pointer.y);
this._resize();
this._start = performance.now();
if (this._reduced) {
this._render(0);
} else {
cancelAnimationFrame(this._raf);
this._raf = requestAnimationFrame((now) => this._loop(now));
}
},
_link(gl, vsrc, fsrc) {
const compile = (type, src) => {
const s = gl.createShader(type);
gl.shaderSource(s, src);
gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
gl.deleteShader(s);
return null;
}
return s;
};
const vs = compile(gl.VERTEX_SHADER, vsrc);
const fs = compile(gl.FRAGMENT_SHADER, fsrc);
if (!vs || !fs) return null;
const p = gl.createProgram();
gl.attachShader(p, vs);
gl.attachShader(p, fs);
gl.linkProgram(p);
gl.deleteShader(vs);
gl.deleteShader(fs);
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {
gl.deleteProgram(p);
return null;
}
return p;
},
_resize() {
const gl = this._gl;
if (!gl) return;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const w = Math.max(1, Math.round(this._canvas.clientWidth * dpr));
const h = Math.max(1, Math.round(this._canvas.clientHeight * dpr));
if (this._canvas.width !== w || this._canvas.height !== h) {
this._canvas.width = w;
this._canvas.height = h;
}
gl.viewport(0, 0, w, h);
if (this._uniforms) gl.uniform2f(this._uniforms.resolution, w, h);
// Repaint the static frame after a resize when motion is reduced.
if (this._reduced && this._program) this._render(0);
},
_loop(now) {
// Ease the smoothed pointer toward its target for a fluid lag.
this._pointer.x += (this._pointerTarget.x - this._pointer.x) * 0.06;
this._pointer.y += (this._pointerTarget.y - this._pointer.y) * 0.06;
this._render((now - this._start) / 1000 * this.speed);
this._raf = requestAnimationFrame((n) => this._loop(n));
},
_resume() {
cancelAnimationFrame(this._raf);
this._raf = requestAnimationFrame((n) => this._loop(n));
},
_render(time) {
const gl = this._gl;
if (!gl || !this._program) return;
gl.uniform1f(this._uniforms.time, time);
gl.uniform2f(this._uniforms.pointer, this._pointer.x, this._pointer.y);
gl.drawArrays(gl.TRIANGLES, 0, 3);
},
}));
});
Installation
php artisan ui:add blocks/closing-cta-plasma
Registry contract
Install confidence
php artisan ui:add blocks/closing-cta-plasma
writes only the generated targets below. The CLI validates each file hash before writing and
prompts before replacing local changes unless --force is used.
- Version
- 1.1.3
- License
- open
- Stability
- stable
- Contract
- v1
- Foundation
- ≥ 1.0.0
| Type | Generated target |
|---|---|
| blade | resources/views/blocks/closing-cta-plasma.blade.php |
| js | resources/js/ui/closing-cta-plasma.js |
Registry dependencies
Package dependencies
composer: jml/brok:^0.2
npm: alpinejs
Use with AI
A brief for your coding agent: what the block is, the install command, how to render it, its props and the rules. Copy it, or open a prompt about this block in an assistant.
# Brok UI block: Closing Plasma (`closing-cta-plasma`)
A footer-ready closing section sitting above a raw WebGL plasma background: a full-screen quad runs a layered, domain-warped noise plasma with atmospheric grain, optional sparkle and an edge vignette, themed from the design tokens (--background/--foreground/--primary). A smoothed pointer uniform lets the cursor warp the flow when interactive. DPR-aware, ResizeObserver-driven, context-loss safe, and renders a single static frame under reduced motion.
Brok UI is a Laravel Blade registry. `ui:add` copies this block into the app as plain Blade the app owns; it composes installed `<brok:*>` primitives and semantic design tokens.
## Install
```bash
php artisan ui:add blocks/closing-cta-plasma
```
## Render it
```blade
<x-blocks.closing-cta-plasma />
```
## Props
- `speed` (int, default `1`)
- `turbulence` (int, default `1`)
- `grain` (float, default `0.08`)
- `sparkle` (float, default `0.3`)
- `vignette` (float, default `0.5`)
- `interactive` (bool, default `true`)
- `heading` (string, default `Ready to ship something that glows?`)
- `subheading` (string, default `Install the source into your own codebase, theme it with your tokens, and own every pixel.`)
- `cta` (string, default `Get started`)
- `cta-href` (string, default `#`)
- `links` (mixed|null, default `null`)
- `copyright` (mixed|null, default `null`)
## Use when
- Closing a page above a raw WebGL plasma background: a layered, domain-warped noise shader with grain, sparkle and vignette.
- Theming the shader entirely from design tokens (background, foreground, primary) so it matches light and dark mode.
## Avoid when
- Use closing-cta-media, -09 or -10 for a closing section without a WebGL background; the plasma shader has a real GPU and battery cost.
## Rules
- Render the installed block with `<x-blocks.closing-cta-plasma />` and pass data through its props; edit the copied file only for structural changes.
- Keep the semantic design tokens (`bg-background`, `text-muted-foreground`); never swap in raw colour utilities.
- Keep the `data-slot` attributes and the logical (start/end) spacing so the markup still mirrors under `dir="rtl"`.
## Links
- Docs: https://brokui.dev/blocks/closing-cta-plasma
- Registry JSON (files, props, contract): https://brokui.dev/r/open/blocks/closing-cta-plasma.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Guidance
Use when
- Closing a page above a raw WebGL plasma background: a layered, domain-warped noise shader with grain, sparkle and vignette.
- Theming the shader entirely from design tokens (background, foreground, primary) so it matches light and dark mode.
Avoid when
- Use closing-cta-media, -09 or -10 for a closing section without a WebGL background; the plasma shader has a real GPU and battery cost.
Anti-patterns
- Do not replace semantic props with conflicting utility classes.
- Do not remove labels, focus styles, or state attributes.
- Anatomy
Usage
Render the block as a component, passing data where useful:
<x-blocks.closing-cta-plasma />
Built from primitives
This block composes installed <brok:*> primitives and semantic design
tokens only — it does not reimplement any primitive. Re-theme it (light, dark, admin, customer) by
editing CSS variables; flip the preview to RTL to confirm it mirrors.
Source
The exact, editable files ui:add writes
into your app. The preview above renders this same source — there are no preview-only blocks.
@props([
// Animation speed multiplier (0 effectively freezes; reduced-motion always
// renders a single static frame regardless of this value).
'speed' => 1,
// Domain-warp strength of the plasma flow (higher = more turbulent swirls).
'turbulence' => 1,
// Atmospheric film-grain amount layered over the plasma (0..1-ish).
'grain' => 0.08,
// Density/brightness of the small sparkle points (0 disables them).
'sparkle' => 0.3,
// Edge darkening that grounds the content for contrast (0..1).
'vignette' => 0.5,
// Pointer-driven flow: the cursor lerps a uniform that warps the plasma.
'interactive' => true,
// Baked sample footer content (it's a block, not a primitive).
'heading' => 'Ready to ship something that glows?',
'subheading' => 'Install the source into your own codebase, theme it with your tokens, and own every pixel.',
'cta' => 'Get started',
'ctaHref' => '#',
'links' => null,
'copyright' => null,
])
@php
// Footer-style link row, token-themed and localisable.
$links ??= [
['label' => 'Components', 'href' => '#'],
['label' => 'Blocks', 'href' => '#'],
['label' => 'Pricing', 'href' => '#'],
['label' => 'Docs', 'href' => '#'],
['label' => 'GitHub', 'href' => '#'],
];
$copyright ??= '© '.date('Y').' '.__('All rights reserved.');
@endphp
{{--
Closing Plasma. A single full-bleed <canvas> sits in an aria-hidden,
pointer-events-none decorative layer behind the footer content. The
uiClosingPlasma Alpine behaviour compiles a WebGL1 vertex+fragment shader,
draws a full-screen quad, and animates a layered domain-warped noise plasma
(atmospheric grain + optional sparkle + edge vignette) via a u_time uniform
on rAF. When `interactive`, a u_pointer uniform is lerped toward the cursor
so the flow leans toward the pointer.
All colour lives in the shader: the JS resolves the host's --background /
--foreground / --primary tokens (via an rgb probe, so oklch resolves to rgb)
and derives base/mid/highlight tones from them — there are deliberately NO
colour utilities or inline colours in this markup. A token-themed scrim keeps
the footer content legible. Reduced-motion users get one static frame, no
rAF and no pointer flow.
--}}
<x-ui.section
x-data="uiClosingPlasma({
speed: {{ (float) $speed }},
turbulence: {{ (float) $turbulence }},
grain: {{ (float) $grain }},
sparkle: {{ (float) $sparkle }},
vignette: {{ (float) $vignette }},
interactive: {{ $interactive ? 'true' : 'false' }},
})"
x-init="init()"
data-slot="closing-cta-02"
space="none" class="relative isolate overflow-hidden" {{ $attributes }}
>
{{-- Decorative WebGL layer: behind content, non-interactive, invisible to AT. --}}
<div aria-hidden="true" class="pointer-events-none absolute inset-0 -z-10">
<canvas x-ref="canvas" class="size-full"></canvas>
</div>
{{-- Token-themed scrim so the plasma never undermines content contrast. --}}
<div aria-hidden="true" class="pointer-events-none absolute inset-0 -z-10 bg-gradient-to-t from-background via-background/60 to-background/20"></div>
{{-- Footer content overlay composing the button primitive. --}}
<div class="relative z-10 mx-auto max-w-5xl px-4 py-24 sm:px-6 sm:py-32 lg:px-8">
<div class="max-w-2xl">
<h2 class="text-balance text-3xl font-bold tracking-tight text-foreground sm:text-4xl lg:text-5xl">
{{ __($heading) }}
</h2>
@if ($subheading)
<p class="mt-6 max-w-xl text-pretty text-lg text-muted-foreground">
{{ __($subheading) }}
</p>
@endif
<div class="mt-8">
<x-ui.button size="lg" :href="$ctaHref" with-arrow>{{ __($cta) }}</x-ui.button>
</div>
</div>
<div class="my-10 border-t border-border"></div>
<div class="flex flex-col gap-6 sm:flex-row sm:items-center sm:justify-between">
<nav aria-label="{{ __('Footer') }}">
<ul class="flex flex-wrap gap-x-6 gap-y-4 text-sm">
@foreach ($links as $link)
<li>
<a href="{{ $link['href'] }}" class="text-muted-foreground transition-colors hover:text-foreground">{{ __($link['label']) }}</a>
</li>
@endforeach
</ul>
</nav>
<p class="text-sm text-muted-foreground">{{ $copyright }}</p>
</div>
</div>
</x-ui.section>
/**
* Closing Plasma — raw WebGL1 plasma background driven by inline 2D simplex noise.
*
* A full-screen quad is rendered into an aria-hidden <canvas> that fills the
* footer-ready section. The fragment shader layers domain-warped fractal noise
* over `u_time` to produce a slow, premium plasma flow, then adds atmospheric
* film grain, optional bright sparkle points and an edge vignette, blending
* three token-derived tones (base / mid / highlight) chosen from the active
* theme so the background always tracks light/dark and brand overrides.
*
* Colours are NOT hard-coded: at init we read the live computed values of
* --background / --foreground / --primary from the host element via
* getComputedStyle + a throwaway "rgb probe" span (so oklch/hsl resolve to
* rgb). The palette tones are derived by mixing those tokens — base sits near
* the background, mid leans on --primary, highlight pulls toward --foreground —
* and the JS detects whether the theme is dark vs light from the background
* luminance to keep contrast pleasant either way.
*
* Pointer-driven flow: when `interactive`, a pointermove listener on the host
* section records the cursor in 0..1 space and a smoothed `u_pointer` uniform is
* lerped toward it every frame, warping the plasma toward the cursor. On leave
* the target eases back to the centre.
*
* Responsiveness: a ResizeObserver + devicePixelRatio drive the canvas backing
* store; the viewport and u_resolution update on every change. Context loss is
* handled (webglcontextlost / webglcontextrestored) by tearing down and
* rebuilding GL state. Reduced-motion users get a single static frame (u_time
* frozen, pointer centred) with no requestAnimationFrame loop and no pointer
* listener.
*
* Config: { speed, turbulence, grain, sparkle, vignette, interactive }.
* Self-registers on `alpine:init` so import order is irrelevant; destroy()
* cancels the rAF, disconnects observers, removes listeners, deletes all GL
* objects and loses the context.
*
* Note: this file is plain JS served by the registry — no Blade syntax allowed.
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiClosingPlasma', (config = {}) => ({
speed: Number.isFinite(+config.speed) ? +config.speed : 1,
turbulence: Number.isFinite(+config.turbulence) ? +config.turbulence : 1,
grain: Number.isFinite(+config.grain) ? +config.grain : 0.08,
sparkle: Number.isFinite(+config.sparkle) ? +config.sparkle : 0.3,
vignette: Number.isFinite(+config.vignette) ? +config.vignette : 0.5,
interactive: config.interactive !== false,
// Non-reactive GL handles kept off Alpine's proxy.
_gl: null,
_canvas: null,
_program: null,
_buffer: null,
_uniforms: null,
_raf: 0,
_start: 0,
_ro: null,
_colors: null,
_onLost: null,
_onRestored: null,
_onMove: null,
_onLeave: null,
_pointerTarget: { x: 0.5, y: 0.5 },
_pointer: { x: 0.5, y: 0.5 },
_reduced: false,
init() {
this._canvas = this.$refs.canvas;
if (!this._canvas) return;
this._reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
this._colors = this._readColors();
this._onLost = (e) => {
e.preventDefault();
cancelAnimationFrame(this._raf);
this._raf = 0;
};
this._onRestored = () => this._setup();
this._canvas.addEventListener('webglcontextlost', this._onLost, false);
this._canvas.addEventListener('webglcontextrestored', this._onRestored, false);
this._ro = new ResizeObserver(() => this._resize());
this._ro.observe(this._canvas);
// Pause the shader while the section is scrolled out of view. A
// full-viewport fragment shader otherwise keeps the GPU hot for a
// hero the reader scrolled past minutes ago (same gate as ui/orb).
// Only a running loop is paused, so the reduced-motion static frame
// and a lost context are left alone; `_resume` re-enters the loop
// without rebuilding the program.
if (typeof IntersectionObserver !== 'undefined') {
this._viewObserver = new IntersectionObserver((entries) => {
const visible = entries.some((entry) => entry.isIntersecting);
if (!visible && this._raf) {
cancelAnimationFrame(this._raf);
this._raf = 0;
this._offscreen = true;
} else if (visible && this._offscreen) {
this._offscreen = false;
if (!this._reduced && this._program) this._resume();
}
});
this._viewObserver.observe(this._canvas);
}
// Pointer flow listens on the host section (the canvas itself is
// pointer-events-none so it never blocks the footer content).
if (this.interactive && !this._reduced) {
const host = this.$el;
this._onMove = (e) => {
const rect = host.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return;
this._pointerTarget.x = (e.clientX - rect.left) / rect.width;
// Flip Y so the uniform matches gl_FragCoord (origin bottom-left).
this._pointerTarget.y = 1 - (e.clientY - rect.top) / rect.height;
};
this._onLeave = () => { this._pointerTarget.x = 0.5; this._pointerTarget.y = 0.5; };
host.addEventListener('pointermove', this._onMove);
host.addEventListener('pointerleave', this._onLeave);
}
this._setup();
},
destroy() {
cancelAnimationFrame(this._raf);
this._raf = 0;
if (this._viewObserver) { this._viewObserver.disconnect(); this._viewObserver = null; }
if (this._ro) { this._ro.disconnect(); this._ro = null; }
if (this._canvas) {
this._canvas.removeEventListener('webglcontextlost', this._onLost, false);
this._canvas.removeEventListener('webglcontextrestored', this._onRestored, false);
}
if (this._onMove) this.$el.removeEventListener('pointermove', this._onMove);
if (this._onLeave) this.$el.removeEventListener('pointerleave', this._onLeave);
this._onMove = this._onLeave = null;
const gl = this._gl;
if (gl) {
if (this._buffer) gl.deleteBuffer(this._buffer);
if (this._program) gl.deleteProgram(this._program);
const lose = gl.getExtension('WEBGL_lose_context');
if (lose) lose.loseContext();
}
this._gl = this._program = this._buffer = this._uniforms = null;
},
/**
* Resolve a CSS custom property to an [r,g,b] triple in 0..1. The value
* may be any CSS colour form (hsl(), oklch(), hex, bare channels), so we
* paint it onto a probe element and let the browser normalise it to rgb.
*/
_readColors() {
const host = this.$el;
const probe = document.createElement('span');
probe.style.cssText = 'position:absolute;width:0;height:0;pointer-events:none;';
host.appendChild(probe);
const read = (token, fallback) => {
probe.style.color = '';
probe.style.color = `var(${token})`;
const c = getComputedStyle(probe).color;
const m = c.match(/-?\d+\.?\d*/g);
if (!m || m.length < 3) return fallback;
return [(+m[0]) / 255, (+m[1]) / 255, (+m[2]) / 255];
};
const background = read('--background', [0.04, 0.04, 0.06]);
const foreground = read('--foreground', [0.95, 0.95, 0.97]);
const primary = read('--primary', [0.4, 0.3, 0.9]);
probe.remove();
// Perceived luminance of the background picks dark vs light tones.
const lum = 0.2126 * background[0] + 0.7152 * background[1] + 0.0722 * background[2];
const isDark = lum < 0.5;
const mix = (a, b, t) => [
a[0] + (b[0] - a[0]) * t,
a[1] + (b[1] - a[1]) * t,
a[2] + (b[2] - a[2]) * t,
];
// base: stays close to the surface so the plasma reads as a wash, not
// a paint-over. mid: the chromatic body, anchored on --primary. On a
// light theme nudge mid toward the background so it doesn't overpower
// the content; on dark, let primary glow. highlight: bright crests
// pulling toward --foreground for the premium "plasma" sheen.
const base = mix(background, primary, isDark ? 0.10 : 0.06);
const mid = mix(background, primary, isDark ? 0.55 : 0.40);
const highlight = mix(primary, foreground, isDark ? 0.45 : 0.30);
return { base, mid, highlight };
},
_setup() {
const gl = this._canvas.getContext('webgl', { antialias: false, premultipliedAlpha: false })
|| this._canvas.getContext('experimental-webgl');
if (!gl) return;
this._gl = gl;
const vert = `
attribute vec2 a_position;
void main() { gl_Position = vec4(a_position, 0.0, 1.0); }
`;
// 2D simplex noise (Ashima / Gustavson, public domain) inlined for
// WebGL1 GLSL ES 1.00 so the block ships with zero JS noise deps.
const frag = `
precision highp float;
uniform float u_time;
uniform vec2 u_resolution;
uniform vec2 u_pointer;
uniform float u_turbulence;
uniform float u_grain;
uniform float u_sparkle;
uniform float u_vignette;
uniform vec3 u_base;
uniform vec3 u_mid;
uniform vec3 u_highlight;
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);
}
// Three octaves of flowing noise for a rich plasma body.
float fbm(vec2 p){
float v = 0.0;
v += 0.55 * snoise(p);
v += 0.30 * snoise(p * 2.07 + 11.0);
v += 0.15 * snoise(p * 4.11 - 5.0);
return v;
}
// Cheap hash for grain + sparkle (no extra texture sampling).
float hash21(vec2 p){
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
void main(){
vec2 uv = gl_FragCoord.xy / u_resolution.xy;
float aspect = u_resolution.x / max(u_resolution.y, 1.0);
vec2 p = vec2((uv.x - 0.5) * aspect, uv.y - 0.5);
p *= 2.0;
float t = u_time * 0.10;
// Pointer pull: bend the sample point toward the cursor so the
// plasma flows in that direction. Strength falls off with
// distance from the pointer for a localised swirl.
vec2 pc = vec2((u_pointer.x - 0.5) * aspect, u_pointer.y - 0.5) * 2.0;
vec2 toP = pc - p;
float pull = exp(-dot(toP, toP) * 0.8);
p += toP * pull * 0.35;
// Domain-warp the sample point so colour fronts curl and swirl.
float warp = u_turbulence;
vec2 q = vec2(fbm(p + vec2(0.0, t)), fbm(p + vec2(5.2, -t)));
vec2 r = vec2(
fbm(p + warp * q + vec2(1.7, 9.2) + 0.30 * t),
fbm(p + warp * q + vec2(8.3, 2.8) - 0.27 * t)
);
float n = fbm(p + warp * r + vec2(t * 0.5, -t * 0.5));
n = n * 0.5 + 0.5;
float band = fbm(p * 0.7 - vec2(t * 0.3, t * 0.2)) * 0.5 + 0.5;
// Layer the three token tones from base → mid → highlight.
vec3 col = mix(u_base, u_mid, smoothstep(0.20, 0.80, n));
col = mix(col, u_highlight, smoothstep(0.55, 0.98, band * n) * 0.85);
// Brighten the crest nearest the pointer for a live feel.
col = mix(col, u_highlight, pull * 0.18);
// Optional sparkle: sparse, twinkling bright points.
if (u_sparkle > 0.0) {
vec2 cell = floor(uv * u_resolution.xy / 3.0);
float s = hash21(cell);
float twinkle = step(0.9985 - u_sparkle * 0.0015, s);
float flicker = 0.5 + 0.5 * sin(u_time * 6.0 + s * 100.0);
col += u_highlight * twinkle * flicker * (0.6 + 0.4 * n);
}
// Atmospheric grain.
float g = hash21(gl_FragCoord.xy + fract(u_time) * 100.0) - 0.5;
col += g * u_grain;
// Edge vignette grounds the content for contrast.
float vig = smoothstep(1.25, 0.20, length(p));
col = mix(u_base, col, mix(1.0, vig, u_vignette));
gl_FragColor = vec4(col, 1.0);
}
`;
const program = this._link(gl, vert, frag);
if (!program) return;
this._program = program;
gl.useProgram(program);
this._buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, this._buffer);
gl.bufferData(gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
const loc = gl.getAttribLocation(program, 'a_position');
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
this._uniforms = {
time: gl.getUniformLocation(program, 'u_time'),
resolution: gl.getUniformLocation(program, 'u_resolution'),
pointer: gl.getUniformLocation(program, 'u_pointer'),
turbulence: gl.getUniformLocation(program, 'u_turbulence'),
grain: gl.getUniformLocation(program, 'u_grain'),
sparkle: gl.getUniformLocation(program, 'u_sparkle'),
vignette: gl.getUniformLocation(program, 'u_vignette'),
base: gl.getUniformLocation(program, 'u_base'),
mid: gl.getUniformLocation(program, 'u_mid'),
highlight: gl.getUniformLocation(program, 'u_highlight'),
};
gl.uniform1f(this._uniforms.turbulence, this.turbulence);
gl.uniform1f(this._uniforms.grain, this.grain);
gl.uniform1f(this._uniforms.sparkle, this.sparkle);
gl.uniform1f(this._uniforms.vignette, this.vignette);
gl.uniform3fv(this._uniforms.base, this._colors.base);
gl.uniform3fv(this._uniforms.mid, this._colors.mid);
gl.uniform3fv(this._uniforms.highlight, this._colors.highlight);
gl.uniform2f(this._uniforms.pointer, this._pointer.x, this._pointer.y);
this._resize();
this._start = performance.now();
if (this._reduced) {
this._render(0);
} else {
cancelAnimationFrame(this._raf);
this._raf = requestAnimationFrame((now) => this._loop(now));
}
},
_link(gl, vsrc, fsrc) {
const compile = (type, src) => {
const s = gl.createShader(type);
gl.shaderSource(s, src);
gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
gl.deleteShader(s);
return null;
}
return s;
};
const vs = compile(gl.VERTEX_SHADER, vsrc);
const fs = compile(gl.FRAGMENT_SHADER, fsrc);
if (!vs || !fs) return null;
const p = gl.createProgram();
gl.attachShader(p, vs);
gl.attachShader(p, fs);
gl.linkProgram(p);
gl.deleteShader(vs);
gl.deleteShader(fs);
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {
gl.deleteProgram(p);
return null;
}
return p;
},
_resize() {
const gl = this._gl;
if (!gl) return;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const w = Math.max(1, Math.round(this._canvas.clientWidth * dpr));
const h = Math.max(1, Math.round(this._canvas.clientHeight * dpr));
if (this._canvas.width !== w || this._canvas.height !== h) {
this._canvas.width = w;
this._canvas.height = h;
}
gl.viewport(0, 0, w, h);
if (this._uniforms) gl.uniform2f(this._uniforms.resolution, w, h);
// Repaint the static frame after a resize when motion is reduced.
if (this._reduced && this._program) this._render(0);
},
_loop(now) {
// Ease the smoothed pointer toward its target for a fluid lag.
this._pointer.x += (this._pointerTarget.x - this._pointer.x) * 0.06;
this._pointer.y += (this._pointerTarget.y - this._pointer.y) * 0.06;
this._render((now - this._start) / 1000 * this.speed);
this._raf = requestAnimationFrame((n) => this._loop(n));
},
_resume() {
cancelAnimationFrame(this._raf);
this._raf = requestAnimationFrame((n) => this._loop(n));
},
_render(time) {
const gl = this._gl;
if (!gl || !this._program) return;
gl.uniform1f(this._uniforms.time, time);
gl.uniform2f(this._uniforms.pointer, this._pointer.x, this._pointer.y);
gl.drawArrays(gl.TRIANGLES, 0, 3);
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.1.3 - Status
- Stable
- License
-
open - Deprecation
- Not deprecated
- Contract
-
v1 - Foundation
-
≥ 1.0.0