Skip to content
Brok UI

Loading…

No results

Closing Plasma

Open source

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.

Responsive Dark mode RTL Localized SEO

Preview

resources/views/blocks/closing-cta-plasma.blade.php Blade
@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>
resources/js/ui/closing-cta-plasma.js JS
/**
 * 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

terminal
php artisan ui:add blocks/closing-cta-plasma

Installs these dependencies automatically:

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

button section

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.

closing-cta-plasma.md
# 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
root closing-cta-02

Usage

Render the block as a component, passing data where useful:

resources/views/welcome.blade.php Blade
<x-blocks.closing-cta-plasma />

Built from primitives

Composes <brok:*> Semantic tokens Re-themeable RTL-ready

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.

resources/views/blocks/closing-cta-plasma.blade.php Blade
@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>
resources/js/ui/closing-cta-plasma.js JS
/**
 * 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