Skip to content
Brok UI

Loading…

No results

Backdrop

Open source

A decorative, aria-hidden background layer with token-coloured presets: grid, glow, aurora, beams, geometric, gradient, prism, liquid, dots. Sits behind any relative section.

Version
v1.1.2
Stability
stable
License
MIT
Related
Dot Pattern
Grid Pattern
Glow

Preview

Backdrop

A decorative background layer with token-coloured presets — aurora, beams, geometric, gradient, prism, liquid, dots.

previews.components.backdrop.default.blade.php Blade
<div class="relative h-64 w-full overflow-hidden rounded-xl border border-border bg-card">
    <x-ui.backdrop variant="aurora" />

    <div class="relative flex h-full flex-col items-center justify-center gap-2 px-6 text-center">
        <h3 class="text-xl font-semibold text-foreground">Backdrop</h3>
        <p class="max-w-sm text-sm text-muted-foreground">
            A decorative background layer with token-coloured presets — aurora, beams, geometric, gradient, prism, liquid, dots.
        </p>
    </div>
</div>

Installation

terminal
php artisan ui:add backdrop

Note

This component ships an Alpine behavior module at resources/js/ui/backdrop.js. Import it once from your bundle so it registers on alpine:init:

resources/js/ui/index.js JS
import './backdrop.js';

Registry contract

php artisan ui:add backdrop writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.

  • blade resources/views/components/ui/backdrop.blade.php
  • blade resources/views/components/ui/backdrop/aurora.blade.php
  • blade resources/views/components/ui/backdrop/beams.blade.php
  • blade resources/views/components/ui/backdrop/geometric.blade.php
  • js resources/js/ui/backdrop-geometric.js
  • blade resources/views/components/ui/backdrop/gradient.blade.php
  • js resources/js/ui/backdrop-gradient.js
  • blade resources/views/components/ui/backdrop/prism.blade.php
  • js resources/js/ui/backdrop-prism.js
  • blade resources/views/components/ui/backdrop/liquid.blade.php
  • js resources/js/ui/backdrop-liquid.js
  • blade resources/views/components/ui/backdrop/dots.blade.php
  • blade resources/views/components/ui/backdrop/grid.blade.php
  • blade resources/views/components/ui/backdrop/glow.blade.php
Registry dependencies
dot-pattern grid-pattern glow
Packages
composer: jml/brok:^0.2
npm: alpinejs

Use with AI

A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.

backdrop.md
# Brok UI: Backdrop (`backdrop`)

A decorative, aria-hidden background layer with token-coloured presets: grid, glow, aurora, beams, geometric, gradient, prism, liquid, dots. Sits behind any relative section.

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add backdrop
```

## Usage

```blade
<div class="relative h-64 w-full overflow-hidden rounded-xl border border-border bg-card">
    <x-ui.backdrop variant="aurora" />

    <div class="relative flex h-full flex-col items-center justify-center gap-2 px-6 text-center">
        <h3 class="text-xl font-semibold text-foreground">Backdrop</h3>
        <p class="max-w-sm text-sm text-muted-foreground">
            A decorative background layer with token-coloured presets — aurora, beams, geometric, gradient, prism, liquid, dots.
        </p>
    </div>
</div>
```

## Props

- `variant` (none|grid|glow|aurora|beams|geometric|gradient|prism|liquid|dots, default `none`) — Selects the decorative layer. 'none' renders nothing. An unknown value throws.
- `speed` (int, default `1`) — Declared by @props in the registry Blade source.
- `intensity` (int, default `1`) — Declared by @props in the registry Blade source.
- `preset` (string, default `aurora`) — Declared by @props in the registry Blade source.
- `grain` (float, default `0.08`) — Declared by @props in the registry Blade source.
- `dither` (float, default `0.5`) — Declared by @props in the registry Blade source.
- `prism` (float, default `0.4`) — Declared by @props in the registry Blade source.
- `flowStrength` (int, default `1`) — Declared by @props in the registry Blade source.
- `delayMs` (int, default `0`) — Declared by @props in the registry Blade source.
- `revealDuration` (int, default `1200`) — Declared by @props in the registry Blade source.

## Use when

- Use only when visual expression supports storytelling, brand differentiation, or a clear showcase moment.
- Adding a decorative, non-content background layer behind a hero, closing CTA or other relative section.
- Switching a section's backdrop between a set of token-themed presets without duplicating the section markup.

## Avoid when

- Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
- The background needs to carry real content or be perceivable to assistive tech; this layer is always aria-hidden.
- A simple static tiled pattern is enough on its own outside a section context; compose dot-pattern or grid-pattern directly.

## Anti-patterns

- Heavy effects in forms or task-dense workflows
- Motion that interferes with reading or focus

## Rules

- Use the `<brok:backdrop>` tag (or `<x-ui.backdrop>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.

## Links

- Docs: https://brokui.dev/docs/components/backdrop
- Registry JSON (files, props, contract): https://brokui.dev/r/open/backdrop.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
variant none | grid | glow | aurora | beams | geometric | gradient | prism | liquid | dots none Selects the decorative layer. 'none' renders nothing. An unknown value throws.
speed int 1 Declared by @props in the registry Blade source.
intensity int 1 Declared by @props in the registry Blade source.
preset string aurora Declared by @props in the registry Blade source.
grain float 0.08 Declared by @props in the registry Blade source.
dither float 0.5 Declared by @props in the registry Blade source.
prism float 0.4 Declared by @props in the registry Blade source.
flowStrength int 1 Declared by @props in the registry Blade source.
delayMs int 0 Declared by @props in the registry Blade source.
revealDuration int 1200 Declared by @props in the registry Blade source.

Slots

  • x-ui.backdrop.aurora — Installed subcomponent from the registry item.
  • x-ui.backdrop.beams — Installed subcomponent from the registry item.
  • x-ui.backdrop.geometric — Installed subcomponent from the registry item.
  • x-ui.backdrop.gradient — Installed subcomponent from the registry item.
  • x-ui.backdrop.prism — Installed subcomponent from the registry item.
  • x-ui.backdrop.liquid — Installed subcomponent from the registry item.
  • x-ui.backdrop.dots — Installed subcomponent from the registry item.
  • x-ui.backdrop.grid — Installed subcomponent from the registry item.
  • x-ui.backdrop.glow — Installed subcomponent from the registry item.

Data slots

Stable hooks for CSS overrides and browser tests.

backdrop

Behavior

  • Always aria-hidden and pointer-events-none; purely decorative.
  • geometric, gradient, prism and liquid render one static frame under prefers-reduced-motion and skip the requestAnimationFrame loop.
  • geometric, gradient, prism and liquid read colour from live CSS custom properties (getComputedStyle) so the layer follows the active theme.
  • Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
  • Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, reducedMotion.

Guidance

Expressive and showcase effect

Support brand expression or a showcase moment.

Use when

  • Use only when visual expression supports storytelling, brand differentiation, or a clear showcase moment.
  • Adding a decorative, non-content background layer behind a hero, closing CTA or other relative section.
  • Switching a section's backdrop between a set of token-themed presets without duplicating the section markup.

Avoid when

  • Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
  • The background needs to carry real content or be perceivable to assistive tech; this layer is always aria-hidden.
  • A simple static tiled pattern is enough on its own outside a section context; compose dot-pattern or grid-pattern directly.

Use instead

  • Static content-first presentation

Anti-patterns

  • Heavy effects in forms or task-dense workflows
  • Motion that interferes with reading or focus
Anatomy
root (absent when variant is none) variant layer (grid | glow | aurora | beams | geometric | gradient | prism | liquid | dots)
Theming hooks
backdrop

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
managed
Focus
none
  • The root is aria-hidden; never place content that needs to be perceivable inside it.
  • Semantic HTML and a stable data-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, so keyboard focus is always visible.
  • Disabled and invalid states are conveyed to assistive tech, not by color alone.
  • Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
  • Labels go through __() and layout uses logical properties (ms-*, text-start), so it mirrors under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:ignore

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

livewire-component.blade.php Blade
<div wire:ignore>
    <div class="relative h-64 w-full overflow-hidden rounded-xl border border-border bg-card">
        <x-ui.backdrop variant="aurora" />
    
        <div class="relative flex h-full flex-col items-center justify-center gap-2 px-6 text-center">
            <h3 class="text-xl font-semibold text-foreground">Backdrop</h3>
            <p class="max-w-sm text-sm text-muted-foreground">
                A decorative background layer with token-coloured presets — aurora, beams, geometric, gradient, prism, liquid, dots.
            </p>
        </div>
    </div>
</div>

Source

The exact, editable files ui:add writes into your app. Previews render this same code; there are no preview-only components.

resources/views/components/ui/backdrop.blade.php Blade
@props([
    'variant' => 'none',
])

@php
    $styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
    $variant = $styles['normalizeVariant']($variant);

    // Keep this list in sync with item.json's `variant` enum and with any
    // sub-file added under ui/backdrop/.
    $backdropVariants = ['none', 'grid', 'glow', 'aurora', 'beams', 'geometric', 'gradient', 'prism', 'liquid', 'dots'];

    if (! in_array($variant, $backdropVariants, true)) {
        throw new \InvalidArgumentException(
            "Unknown backdrop variant [{$variant}]. Expected one of: ".implode(', ', $backdropVariants).'.'
        );
    }
@endphp

@if ($variant !== 'none')
    <div
        data-slot="backdrop"
        data-variant="{{ $variant }}"
        aria-hidden="true"
        {{ $attributes->merge(['class' => 'pointer-events-none absolute inset-0 -z-10 overflow-hidden']) }}
    >
        {{-- Extra attributes (e.g. :speed, :dither, :prism, :intensity, :grain)
             forward to the variant sub-component as its own props; `class` stays
             on this wrapper only, so it is excluded here. --}}
        <x-dynamic-component :component="'ui.backdrop.'.$variant" {{ $attributes->except('class') }} />
    </div>
@endif
resources/views/components/ui/backdrop/aurora.blade.php Blade
{{--
    Aurora backdrop. Four large, blurred, token-coloured radial blobs that
    slowly drift/scale via the motion-safe ui-aurora-drift keyframe (varied
    durations + delays so the field never repeats in lockstep). Reduced-motion
    users keep the blobs as a soft static gradient — no movement.
--}}
<div {{ $attributes->merge(['class' => 'absolute -left-24 -top-32 h-[28rem] w-[28rem] rounded-full bg-primary/30 blur-3xl motion-safe:animate-[ui-aurora-drift_18s_ease-in-out_infinite_alternate]']) }}></div>
<div class="absolute -right-20 -top-16 h-[26rem] w-[26rem] rounded-full bg-accent/30 blur-3xl motion-safe:animate-[ui-aurora-drift_22s_ease-in-out_-4s_infinite_alternate]"></div>
<div class="absolute -bottom-32 left-1/4 h-[30rem] w-[30rem] rounded-full bg-secondary/40 blur-3xl motion-safe:animate-[ui-aurora-drift_26s_ease-in-out_-8s_infinite_alternate]"></div>
<div class="absolute -bottom-24 right-1/4 h-[24rem] w-[24rem] rounded-full bg-primary/20 blur-3xl motion-safe:animate-[ui-aurora-drift_20s_ease-in-out_-12s_infinite_alternate]"></div>
resources/views/components/ui/backdrop/beams.blade.php Blade
{{--
    Beams backdrop. A static radial anchor glow plus a few tall, skewed
    token-coloured gradient bars that sweep horizontally via the motion-safe
    ui-beam-sweep keyframe (staggered delays). Reduced-motion users keep the
    beams as a static, fully styled rendering — no movement.
--}}
{{-- Static radial anchor glow --}}
<div {{ $attributes->merge(['class' => 'absolute left-1/2 top-1/2 h-[36rem] w-[36rem] -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary/10 blur-3xl']) }}></div>

{{-- Sweeping skewed beams --}}
<div class="absolute inset-y-0 left-0 w-32 -skew-x-12 bg-gradient-to-b from-primary/0 via-primary/20 to-primary/0 motion-safe:animate-[ui-beam-sweep_14s_linear_infinite]"></div>
<div class="absolute inset-y-0 left-0 w-24 -skew-x-12 bg-gradient-to-b from-accent/0 via-accent/20 to-accent/0 motion-safe:animate-[ui-beam-sweep_18s_linear_-5s_infinite]"></div>
<div class="absolute inset-y-0 left-0 w-40 -skew-x-12 bg-gradient-to-b from-secondary/0 via-secondary/30 to-secondary/0 motion-safe:animate-[ui-beam-sweep_22s_linear_-11s_infinite]"></div>
resources/views/components/ui/backdrop/geometric.blade.php Blade
@props([
    'speed' => 1,
    'intensity' => 1,
])

{{--
    Geometric backdrop. A raw WebGL1 <canvas> fills the host, aria-hidden and
    pointer-events-none. The fragment shader runs inline 2D simplex noise over
    u_time to slowly warp/blend three token-derived colours (--primary /
    --accent / --background, read in JS via getComputedStyle) into an organic
    fluid wash. DPR-aware + ResizeObserver, context-loss safe. Reduced-motion
    users get one static frame (no rAF).
--}}
<div x-data="uiBackdropGeometric({ speed: {{ (float) $speed }}, intensity: {{ (float) $intensity }} })" {{ $attributes }}>
    <canvas
        x-ref="canvas"
        aria-hidden="true"
        class="pointer-events-none absolute inset-0 -z-10 size-full"
    ></canvas>

    {{-- Subtle wash so foreground content stays legible over the brightest fluid fronts. --}}
    <div aria-hidden="true" class="pointer-events-none absolute inset-0 -z-10 bg-gradient-to-t from-background via-background/40 to-transparent"></div>
</div>
resources/js/ui/backdrop-geometric.js JS
/**
 * Backdrop: Geometric — raw WebGL1 fluid background driven by inline 2D simplex noise.
 *
 * A full-screen quad is rendered into an aria-hidden <canvas> that fills the
 * section. The fragment shader layers two octaves of simplex noise over `u_time`
 * to slowly warp/blend three token-derived colours (primary / accent /
 * background), producing an organic fluid wash behind the headline.
 *
 * Colours are NOT hard-coded: at init we read the live computed values of
 * --primary / --accent / --background / --foreground from the host element via
 * getComputedStyle + a throwaway "rgb probe" span, so the background always
 * tracks the active theme (light/dark, brand overrides) without touching markup.
 *
 * Responsiveness: a ResizeObserver + devicePixelRatio drive the canvas backing
 * store; the viewport is resized and u_resolution updated 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) and no requestAnimationFrame loop.
 *
 * Config: { speed, intensity } — speed scales time advance, intensity scales the
 * noise frequency/contrast. Self-registers on `alpine:init` so import order is
 * irrelevant; destroy() cancels the rAF, disconnects observers, deletes all GL
 * objects and loses the context.
 */
document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiBackdropGeometric', (config = {}) => ({
        speed: Number.isFinite(+config.speed) ? +config.speed : 1,
        intensity: Number.isFinite(+config.intensity) ? +config.intensity : 1,

        // 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,
        _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);
            }

            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);
            }
            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 colors = {
                primary: read('--primary', [0.4, 0.3, 0.9]),
                accent: read('--accent', [0.9, 0.4, 0.6]),
                background: read('--background', [0.04, 0.04, 0.06]),
                foreground: read('--foreground', [0.95, 0.95, 0.97]),
            };
            probe.remove();
            return colors;
        },

        _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 float u_intensity;
                uniform vec3  u_primary;
                uniform vec3  u_accent;
                uniform vec3  u_background;

                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);
                }

                // Two octaves of flowing noise for an organic fluid look.
                float fbm(vec2 p){
                    float v = 0.0;
                    v += 0.65 * snoise(p);
                    v += 0.35 * snoise(p * 2.13 + 7.0);
                    return v;
                }

                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 *= 1.6 * u_intensity;

                    float t = u_time * 0.12;

                    // Domain-warp the sample point so colour fronts curl and swirl.
                    vec2 q = vec2(fbm(p + vec2(0.0, t)), fbm(p + vec2(5.2, -t)));
                    float n = fbm(p + q * 1.5 + 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;

                    vec3 col = mix(u_background, u_primary, smoothstep(0.25, 0.85, n));
                    col = mix(col, u_accent, smoothstep(0.4, 0.95, band) * 0.6);

                    // Keep edges grounded in the background so content stays legible.
                    float vignette = smoothstep(1.15, 0.25, length(p));
                    col = mix(u_background, col, 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'),
                intensity: gl.getUniformLocation(program, 'u_intensity'),
                primary: gl.getUniformLocation(program, 'u_primary'),
                accent: gl.getUniformLocation(program, 'u_accent'),
                background: gl.getUniformLocation(program, 'u_background'),
            };
            gl.uniform1f(this._uniforms.intensity, this.intensity);
            gl.uniform3fv(this._uniforms.primary, this._colors.primary);
            gl.uniform3fv(this._uniforms.accent, this._colors.accent);
            gl.uniform3fv(this._uniforms.background, this._colors.background);

            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) {
            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.drawArrays(gl.TRIANGLES, 0, 3);
        },
    }));
});
resources/views/components/ui/backdrop/gradient.blade.php Blade
@props([
    // Palette preset for the WebGL gradient. 'token' (the default behaviour
    // when no preset matches) derives colours from the live design tokens so
    // the gradient themes with the rest of the UI; aurora|oceanic|amber pick a
    // fixed palette inside the shader.
    'preset' => 'aurora',
    // Animation speed multiplier (0 effectively freezes; reduced-motion always
    // renders a single static frame regardless of this value).
    'speed' => 1,
    // Film-grain / noise amount layered over the gradient (0..1-ish).
    'grain' => 0.08,
])

{{--
    Gradient backdrop. A single full-bleed <canvas> compiles a WebGL1
    vertex+fragment shader, draws a full-screen quad, and animates a flowing
    gradient + grain via a u_time uniform on rAF. All colour lives in the
    shader: the JS resolves the page's --primary/--accent/--foreground/
    --background tokens (via a probe element, so oklch resolves to rgb) and
    feeds them as uniforms. Reduced-motion users get one static frame.
--}}
<div
    x-data="uiBackdropGradient({
        preset: @js($preset),
        speed: {{ (float) $speed }},
        grain: {{ (float) $grain }},
    })"
    x-init="init()"
    aria-hidden="true"
    {{ $attributes->merge(['class' => 'pointer-events-none absolute inset-0 -z-10']) }}
>
    <canvas x-ref="canvas" class="size-full"></canvas>
</div>
resources/js/ui/backdrop-gradient.js JS
/**
 * Animated gradient hero background — raw WebGL1, no dependencies.
 *
 * Draws a single full-screen quad and runs a fragment shader that flows a
 * multi-colour gradient with layered value-noise and film grain, animated via a
 * `u_time` uniform on requestAnimationFrame.
 *
 * Colours come from the page's design tokens by default: init() reads
 * --primary / --accent / --foreground / --background off the document root and
 * resolves each to rgb through a throwaway probe element (so oklch / hsl tokens
 * collapse to concrete rgb that WebGL can use), then feeds them as vec3
 * uniforms. The block therefore themes itself. A `preset` of aurora|oceanic|amber
 * overrides the token colours with a fixed palette; anything else stays
 * token-derived.
 *
 * Robustness:
 *   - DPR-aware sizing, re-measured by a ResizeObserver on the canvas.
 *   - `webglcontextlost` is prevented-default and `webglcontextrestored`
 *     rebuilds the program so the gradient survives GPU resets.
 *   - destroy() cancels rAF, disconnects the observer, removes listeners and
 *     loses the context so nothing leaks when Alpine tears the node down.
 *
 * Accessibility: when the user prefers reduced motion we render exactly ONE
 * frame at a fixed time and never start the rAF loop.
 *
 * Self-registers on `alpine:init` so import order does not matter.
 */
document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiBackdropGradient', (config = {}) => ({
        preset: config.preset ?? 'aurora',
        speed: typeof config.speed === 'number' ? config.speed : 1,
        grain: typeof config.grain === 'number' ? config.grain : 0.08,

        // Internal WebGL state (not reactive).
        _gl: null,
        _program: null,
        _buffer: null,
        _locs: null,
        _colors: null,
        _raf: 0,
        _start: 0,
        _observer: null,
        _onLost: null,
        _onRestored: null,

        init() {
            const canvas = this.$refs.canvas;
            if (!canvas) return;

            this._reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
            this._colors = this._resolveColors();

            // Keep the rendered surface in sync with the element's CSS box.
            this._observer = new ResizeObserver(() => {
                this._resize();
                // A static / reduced-motion frame must be redrawn on resize.
                if (this._reduced && this._gl) this._render(0);
            });
            this._observer.observe(canvas);

            this._onLost = (e) => {
                e.preventDefault();
                cancelAnimationFrame(this._raf);
                this._raf = 0;
            };
            this._onRestored = () => this._setup();
            canvas.addEventListener('webglcontextlost', this._onLost, false);
            canvas.addEventListener('webglcontextrestored', this._onRestored, false);

            // 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(canvas);
            }

            this._setup();
        },

        destroy() {
            cancelAnimationFrame(this._raf);
            this._raf = 0;
            if (this._viewObserver) this._viewObserver.disconnect();
            if (this._observer) this._observer.disconnect();

            const canvas = this.$refs.canvas;
            if (canvas) {
                if (this._onLost) canvas.removeEventListener('webglcontextlost', this._onLost);
                if (this._onRestored) canvas.removeEventListener('webglcontextrestored', this._onRestored);
            }

            const gl = this._gl;
            if (gl) {
                if (this._program) gl.deleteProgram(this._program);
                if (this._buffer) gl.deleteBuffer(this._buffer);
                const ext = gl.getExtension('WEBGL_lose_context');
                if (ext) ext.loseContext();
            }
            this._gl = null;
            this._program = null;
            this._buffer = null;
        },

        /**
         * Read a CSS custom property off the document root and resolve it to a
         * normalised [r,g,b] (0..1) triple via a probe element. Going through a
         * real element + getComputedStyle lets the browser collapse oklch/hsl/
         * named colours down to rgb for us.
         */
        _resolveColors() {
            const presets = {
                aurora: [[0.36, 0.78, 0.62], [0.30, 0.52, 0.96], [0.62, 0.36, 0.92]],
                oceanic: [[0.06, 0.55, 0.74], [0.10, 0.32, 0.62], [0.02, 0.20, 0.36]],
                amber: [[0.98, 0.74, 0.30], [0.95, 0.45, 0.20], [0.55, 0.16, 0.34]],
            };
            if (presets[this.preset]) return presets[this.preset];

            // Token-derived palette (default).
            const probe = document.createElement('span');
            probe.style.display = 'none';
            document.body.appendChild(probe);
            const cs = getComputedStyle(document.documentElement);
            const read = (name, fallback) => {
                const raw = cs.getPropertyValue(name).trim();
                if (!raw) return fallback;
                probe.style.color = '';
                probe.style.color = raw.includes('(') || raw.startsWith('#') || /[a-z]/i.test(raw)
                    ? raw
                    : `oklch(${raw})`; // bare token components, e.g. "0.7 0.1 250"
                const resolved = getComputedStyle(probe).color; // "rgb(r, g, b)"
                const m = resolved.match(/-?\d*\.?\d+/g);
                if (!m || m.length < 3) return fallback;
                return [m[0] / 255, m[1] / 255, m[2] / 255];
            };
            const colors = [
                read('--primary', [0.36, 0.78, 0.62]),
                read('--accent', [0.30, 0.52, 0.96]),
                read('--foreground', [0.62, 0.36, 0.92]),
            ];
            probe.remove();
            return colors;
        },

        _resize() {
            const canvas = this.$refs.canvas;
            const gl = this._gl;
            if (!canvas) return;
            const dpr = Math.min(window.devicePixelRatio || 1, 2);
            const w = Math.max(1, Math.round(canvas.clientWidth * dpr));
            const h = Math.max(1, Math.round(canvas.clientHeight * dpr));
            if (canvas.width !== w || canvas.height !== h) {
                canvas.width = w;
                canvas.height = h;
            }
            if (gl) gl.viewport(0, 0, canvas.width, canvas.height);
        },

        _setup() {
            const canvas = this.$refs.canvas;
            const gl = canvas.getContext('webgl', { antialias: false, premultipliedAlpha: false })
                || canvas.getContext('experimental-webgl');
            if (!gl) return; // headless / no-WebGL: leave the token bg-background showing.
            this._gl = gl;

            const vert = `
                attribute vec2 a_position;
                void main() { gl_Position = vec4(a_position, 0.0, 1.0); }
            `;
            const frag = `
                precision highp float;
                uniform float u_time;
                uniform vec2 u_resolution;
                uniform float u_grain;
                uniform vec3 u_c1;
                uniform vec3 u_c2;
                uniform vec3 u_c3;

                // Cheap hash + value noise.
                float hash(vec2 p) {
                    return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
                }
                float noise(vec2 p) {
                    vec2 i = floor(p);
                    vec2 f = fract(p);
                    vec2 u = f * f * (3.0 - 2.0 * f);
                    return mix(
                        mix(hash(i + vec2(0.0, 0.0)), hash(i + vec2(1.0, 0.0)), u.x),
                        mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x),
                        u.y
                    );
                }
                float fbm(vec2 p) {
                    float v = 0.0;
                    float a = 0.5;
                    for (int i = 0; i < 4; i++) {
                        v += a * noise(p);
                        p *= 2.0;
                        a *= 0.5;
                    }
                    return v;
                }

                void main() {
                    vec2 uv = gl_FragCoord.xy / u_resolution.xy;
                    vec2 p = uv;
                    p.x *= u_resolution.x / u_resolution.y;

                    float t = u_time * 0.12;
                    // Two drifting noise fields mixed into a flowing gradient.
                    float n1 = fbm(p * 2.2 + vec2(t, -t * 0.7));
                    float n2 = fbm(p * 1.3 - vec2(t * 0.6, t * 0.9) + n1);

                    float m1 = smoothstep(0.2, 0.85, n1);
                    float m2 = smoothstep(0.15, 0.9, n2);

                    vec3 col = mix(u_c1, u_c2, m1);
                    col = mix(col, u_c3, m2 * 0.65);

                    // Soft vignette toward the lower edge for depth.
                    col *= 0.85 + 0.25 * (1.0 - uv.y);

                    // Film grain.
                    float g = hash(gl_FragCoord.xy + u_time) - 0.5;
                    col += g * u_grain;

                    gl_FragColor = vec4(clamp(col, 0.0, 1.0), 1.0);
                }
            `;

            const program = this._buildProgram(gl, vert, frag);
            if (!program) return;
            this._program = program;

            // Full-screen quad (two triangles).
            const buffer = gl.createBuffer();
            gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
            gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
                -1, -1, 1, -1, -1, 1,
                -1, 1, 1, -1, 1, 1,
            ]), gl.STATIC_DRAW);
            this._buffer = buffer;

            const aPos = gl.getAttribLocation(program, 'a_position');
            gl.enableVertexAttribArray(aPos);
            gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);

            this._locs = {
                time: gl.getUniformLocation(program, 'u_time'),
                resolution: gl.getUniformLocation(program, 'u_resolution'),
                grain: gl.getUniformLocation(program, 'u_grain'),
                c1: gl.getUniformLocation(program, 'u_c1'),
                c2: gl.getUniformLocation(program, 'u_c2'),
                c3: gl.getUniformLocation(program, 'u_c3'),
            };

            this._resize();

            if (this._reduced) {
                // One static, representative frame — no animation loop.
                this._render(6.0);
            } else {
                this._start = performance.now();
                this._loop();
            }
        },

        _buildProgram(gl, vsrc, fsrc) {
            const compile = (type, src) => {
                const sh = gl.createShader(type);
                gl.shaderSource(sh, src);
                gl.compileShader(sh);
                if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
                    gl.deleteShader(sh);
                    return null;
                }
                return sh;
            };
            const vs = compile(gl.VERTEX_SHADER, vsrc);
            const fs = compile(gl.FRAGMENT_SHADER, fsrc);
            if (!vs || !fs) return null;
            const program = gl.createProgram();
            gl.attachShader(program, vs);
            gl.attachShader(program, fs);
            gl.linkProgram(program);
            gl.deleteShader(vs);
            gl.deleteShader(fs);
            if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
                gl.deleteProgram(program);
                return null;
            }
            return program;
        },

        _render(time) {
            const gl = this._gl;
            const canvas = this.$refs.canvas;
            if (!gl || !this._program || !canvas) return;
            gl.useProgram(this._program);
            const l = this._locs;
            gl.uniform1f(l.time, time * this.speed);
            gl.uniform2f(l.resolution, canvas.width, canvas.height);
            gl.uniform1f(l.grain, this.grain);
            gl.uniform3fv(l.c1, this._colors[0]);
            gl.uniform3fv(l.c2, this._colors[1]);
            gl.uniform3fv(l.c3, this._colors[2]);
            gl.drawArrays(gl.TRIANGLES, 0, 6);
        },

        _loop() {
            const tick = (now) => {
                this._render((now - this._start) / 1000);
                this._raf = requestAnimationFrame(tick);
            };
            cancelAnimationFrame(this._raf);
            this._raf = requestAnimationFrame(tick);
        },

        _resume() {
            this._loop();
        },
    }));
});
resources/views/components/ui/backdrop/prism.blade.php Blade
@props([
    'speed' => 1,
    // Ordered-dither strength (0 = smooth, 1 = heavy retro grain).
    'dither' => 0.5,
    // Prismatic refraction strength (0 = no split, 1 = wide rainbow edges).
    'prism' => 0.4,
])

@php
    $config = [
        'speed' => (float) $speed,
        // Clamp the two effect knobs to a sane 0..1 range for the shader uniforms.
        'dither' => max(0.0, min(1.0, (float) $dither)),
        'prism' => max(0.0, min(1.0, (float) $prism)),
    ];
@endphp

{{--
    Prism backdrop. A full-bleed <canvas> renders a raw WebGL1 fragment shader:
    an animated base flow built from three semantic token colours (--primary,
    --accent, --secondary, read off the root at init), prismatic refraction
    that samples the colour channels at slightly offset positions to split them
    into rainbow edges, holographic iridescence that shifts hue by screen
    angle/time, and a final ordered (Bayer) dither that quantizes the field for
    a retro grain look. Reduced-motion users get a single static frame (no rAF).
--}}
<div
    x-data="uiBackdropPrism(@js($config))"
    x-ref="root"
    aria-hidden="true"
    {{ $attributes->merge(['class' => 'pointer-events-none absolute inset-0 -z-10 text-primary']) }}
>
    <canvas x-ref="canvas" class="block h-full w-full"></canvas>
</div>

{{-- Scrim: a soft token-coloured wash so the vivid shader never drowns
     foreground content. Sits above the canvas but below the content. --}}
<div aria-hidden="true" class="pointer-events-none absolute inset-0 -z-10 bg-background/40"></div>
resources/js/ui/backdrop-prism.js JS
/**
 * Backdrop: Prism behavior (raw WebGL background).
 *
 * Renders a full-screen quad through a WebGL1 fragment shader (GLSL ES 1.00) to
 * produce the "dither prism" look in three stacked stages:
 *
 *   1. Base flow   — three token colours (primary, accent, secondary) mixed by a
 *                    slow animated domain-warped gradient driven by u_time.
 *   2. Prism       — the colour channels are sampled at slightly offset UVs
 *                    (chromatic aberration), splitting the gradient into rainbow
 *                    edges. Strength = u_prism.
 *   3. Holographic — an iridescent hue band added from view angle + time, then
 *   + Dither          the whole field is quantized through a 4x4 ordered (Bayer)
 *                    threshold matrix for the retro grain. Strength = u_dither.
 *
 * Colour is SEMANTIC: the three vec3 uniforms are derived at init from the root
 * element's `--primary`, `--accent` and `--secondary` tokens (falling back to
 * the computed `color`), so the effect follows the active theme. No colours are
 * hard-coded in markup; they live here as uniforms.
 *
 * Sizing is DPR-aware and a ResizeObserver resizes the backing store + viewport
 * so the field stays crisp. webglcontextlost/restored are handled: the loop is
 * suspended on loss and the program/buffers are rebuilt on restore. destroy()
 * cancels the rAF, disconnects the observer, removes listeners and frees GL
 * resources.
 *
 * a11y: the host is aria-hidden + pointer-events-none (set in Blade); purely
 * decorative. Reduced motion: a single static frame is drawn at t=0 with no rAF.
 *
 * Self-registers on `alpine:init` so import order does not matter.
 *
 * Note: this file is plain JS served by the registry — no Blade syntax allowed.
 */
document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiBackdropPrism', (config = {}) => ({
        speed: typeof config.speed === 'number' ? config.speed : 1,
        dither: typeof config.dither === 'number' ? Math.max(0, Math.min(1, config.dither)) : 0.5,
        prism: typeof config.prism === 'number' ? Math.max(0, Math.min(1, config.prism)) : 0.4,

        _gl: null,
        _program: null,
        _buffer: null,
        _uniforms: null,
        _dpr: 1,
        _raf: null,
        _ro: null,
        _start: 0,
        _reduced: false,
        _colors: [[0.4, 0.5, 0.95], [0.85, 0.3, 0.7], [0.2, 0.7, 0.85]],
        _onLost: null,
        _onRestored: null,

        init() {
            this._reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
            this._dpr = Math.max(1, window.devicePixelRatio || 1);
            this._colors = this.resolveColors();

            const canvas = this.$refs.canvas;
            this._onLost = (e) => {
                // Prevent the default so the context becomes restorable.
                e.preventDefault();
                if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
            };
            this._onRestored = () => {
                this._program = null;
                this._buffer = null;
                if (this.setup()) this.start();
            };
            canvas.addEventListener('webglcontextlost', this._onLost, false);
            canvas.addEventListener('webglcontextrestored', this._onRestored, false);

            if (!this.setup()) return;

            // Rebuild backing store on resize (debounced to a frame) so the field
            // stays crisp; repaint a static frame for reduced-motion users.
            let pending = false;
            this._ro = new ResizeObserver(() => {
                if (pending) return;
                pending = true;
                requestAnimationFrame(() => {
                    pending = false;
                    this.resize();
                    if (this._reduced) this.render(0);
                });
            });
            this._ro.observe(this.$refs.root);

            // 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 = null;
                        this._offscreen = true;
                    } else if (visible && this._offscreen) {
                        this._offscreen = false;
                        if (!this._reduced && this._program) this._resume();
                    }
                });
                this._viewObserver.observe(this.$refs.root);
            }

            this.start();
        },

        start() {
            if (this._reduced) {
                // One static frame, no animation loop.
                this.render(0);
                return;
            }
            this._start = performance.now();
            this._resume();
        },

        // (Re)enter the frame loop without touching the clock, so a section
        // scrolled back into view continues where it left off.
        _resume() {
            const loop = (now) => {
                this.render((now - this._start) / 1000);
                this._raf = requestAnimationFrame(loop);
            };
            if (this._raf) cancelAnimationFrame(this._raf);
            this._raf = requestAnimationFrame(loop);
        },

        // Build GL context, compile/link the program, create the full-screen quad
        // and cache uniform locations. Returns false when WebGL is unavailable.
        setup() {
            const canvas = this.$refs.canvas;
            const gl = this._gl || canvas.getContext('webgl', { antialias: false, premultipliedAlpha: false })
                || canvas.getContext('experimental-webgl');
            if (!gl) return false;
            this._gl = gl;

            const vs = this.compile(gl.VERTEX_SHADER, this.vertexSrc());
            const fs = this.compile(gl.FRAGMENT_SHADER, this.fragmentSrc());
            if (!vs || !fs) return false;

            const program = gl.createProgram();
            gl.attachShader(program, vs);
            gl.attachShader(program, fs);
            gl.linkProgram(program);
            if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return false;
            this._program = program;
            gl.deleteShader(vs);
            gl.deleteShader(fs);

            // Full-screen quad as two triangles in clip space.
            const buffer = gl.createBuffer();
            gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
            gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
                -1, -1, 1, -1, -1, 1,
                -1, 1, 1, -1, 1, 1,
            ]), gl.STATIC_DRAW);
            this._buffer = buffer;

            gl.useProgram(program);
            const aPos = gl.getAttribLocation(program, 'a_position');
            gl.enableVertexAttribArray(aPos);
            gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);

            this._uniforms = {
                time: gl.getUniformLocation(program, 'u_time'),
                resolution: gl.getUniformLocation(program, 'u_resolution'),
                c0: gl.getUniformLocation(program, 'u_color0'),
                c1: gl.getUniformLocation(program, 'u_color1'),
                c2: gl.getUniformLocation(program, 'u_color2'),
                dither: gl.getUniformLocation(program, 'u_dither'),
                prism: gl.getUniformLocation(program, 'u_prism'),
            };

            this.resize();
            return true;
        },

        compile(type, src) {
            const gl = this._gl;
            const shader = gl.createShader(type);
            gl.shaderSource(shader, src);
            gl.compileShader(shader);
            if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
                gl.deleteShader(shader);
                return null;
            }
            return shader;
        },

        resize() {
            const gl = this._gl;
            const canvas = this.$refs.canvas;
            const root = this.$refs.root;
            if (!gl) return;
            this._dpr = Math.max(1, window.devicePixelRatio || 1);
            const w = Math.max(1, Math.round((root.clientWidth || 1) * this._dpr));
            const h = Math.max(1, Math.round((root.clientHeight || 1) * this._dpr));
            if (canvas.width !== w || canvas.height !== h) {
                canvas.width = w;
                canvas.height = h;
            }
            gl.viewport(0, 0, canvas.width, canvas.height);
        },

        render(t) {
            const gl = this._gl;
            const u = this._uniforms;
            if (!gl || !this._program || !u) return;
            const canvas = this.$refs.canvas;

            gl.useProgram(this._program);
            gl.uniform1f(u.time, t * this.speed);
            gl.uniform2f(u.resolution, canvas.width, canvas.height);
            gl.uniform3fv(u.c0, this._colors[0]);
            gl.uniform3fv(u.c1, this._colors[1]);
            gl.uniform3fv(u.c2, this._colors[2]);
            gl.uniform1f(u.dither, this.dither);
            gl.uniform1f(u.prism, this.prism);
            gl.drawArrays(gl.TRIANGLES, 0, 6);
        },

        // ---- token colour resolution -------------------------------------

        // Resolve the three base colours as [r,g,b] floats in 0..1 from the
        // root's semantic tokens, falling back to its computed color so the
        // canvas still follows the active theme when a token is missing.
        resolveColors() {
            const root = this.$refs.root;
            const fallback = getComputedStyle(root).color || 'currentColor';
            const fallbackRgb = this.toRgbFloat(fallback) || [0, 0, 0];
            const names = ['--primary', '--accent', '--secondary'];
            return names.map((name) => {
                const css = this.resolveToken(name, root) || fallback;
                return this.toRgbFloat(css) || fallbackRgb;
            });
        },

        // Resolve a CSS color string from a custom property; wraps bare channel
        // lists in oklch()/rgb() so token storage formats all work.
        resolveToken(name, root) {
            const raw = getComputedStyle(root).getPropertyValue(name).trim();
            if (raw === '') return null;
            const probe = document.createElement('span');
            probe.style.color = raw;
            if (probe.style.color === '') {
                probe.style.color = 'oklch(' + raw + ')';
                if (probe.style.color === '') probe.style.color = 'rgb(' + raw + ')';
            }
            if (probe.style.color === '') return null;
            document.body.appendChild(probe);
            const resolved = getComputedStyle(probe).color;
            document.body.removeChild(probe);
            return resolved && resolved !== '' ? resolved : null;
        },

        // Normalize any CSS color (oklch/hsl/named/hex/rgb) to [r,g,b] in 0..1
        // via the computed color of a scratch element.
        toRgbFloat(color) {
            const probe = document.createElement('span');
            probe.style.color = color;
            document.body.appendChild(probe);
            const computed = getComputedStyle(probe).color;
            document.body.removeChild(probe);
            const m = computed.match(/(\d+(?:\.\d+)?)/g);
            if (!m || m.length < 3) return null;
            return [Number(m[0]) / 255, Number(m[1]) / 255, Number(m[2]) / 255];
        },

        // ---- shaders ------------------------------------------------------

        vertexSrc() {
            return [
                'attribute vec2 a_position;',
                'void main() {',
                '  gl_Position = vec4(a_position, 0.0, 1.0);',
                '}',
            ].join('\n');
        },

        fragmentSrc() {
            return [
                'precision highp float;',
                'uniform float u_time;',
                'uniform vec2 u_resolution;',
                'uniform vec3 u_color0;',
                'uniform vec3 u_color1;',
                'uniform vec3 u_color2;',
                'uniform float u_dither;',
                'uniform float u_prism;',
                '',
                '// Cheap value noise + fbm for the flowing base gradient.',
                'float hash(vec2 p) {',
                '  return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);',
                '}',
                'float noise(vec2 p) {',
                '  vec2 i = floor(p);',
                '  vec2 f = fract(p);',
                '  vec2 u = f * f * (3.0 - 2.0 * f);',
                '  float a = hash(i);',
                '  float b = hash(i + vec2(1.0, 0.0));',
                '  float c = hash(i + vec2(0.0, 1.0));',
                '  float d = hash(i + vec2(1.0, 1.0));',
                '  return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);',
                '}',
                'float fbm(vec2 p) {',
                '  float v = 0.0;',
                '  float amp = 0.5;',
                '  for (int i = 0; i < 5; i++) {',
                '    v += amp * noise(p);',
                '    p *= 2.0;',
                '    amp *= 0.5;',
                '  }',
                '  return v;',
                '}',
                '',
                '// Holographic hue band (HSV->RGB) added by angle/position/time.',
                'vec3 hue(float h) {',
                '  vec3 k = vec3(1.0, 2.0 / 3.0, 1.0 / 3.0);',
                '  vec3 p = abs(fract(vec3(h) + k) * 6.0 - 3.0);',
                '  return clamp(p - 1.0, 0.0, 1.0);',
                '}',
                '',
                '// 4x4 ordered (Bayer) threshold in 0..1 for the retro dither.',
                'float bayer(vec2 p) {',
                '  int x = int(mod(p.x, 4.0));',
                '  int y = int(mod(p.y, 4.0));',
                '  int i = x + y * 4;',
                '  float m = 0.0;',
                '  if (i == 0)  m = 0.0;  else if (i == 1)  m = 8.0;  else if (i == 2)  m = 2.0;  else if (i == 3)  m = 10.0;',
                '  else if (i == 4)  m = 12.0; else if (i == 5)  m = 4.0;  else if (i == 6)  m = 14.0; else if (i == 7)  m = 6.0;',
                '  else if (i == 8)  m = 3.0;  else if (i == 9)  m = 11.0; else if (i == 10) m = 1.0;  else if (i == 11) m = 9.0;',
                '  else if (i == 12) m = 15.0; else if (i == 13) m = 7.0;  else if (i == 14) m = 13.0; else m = 5.0;',
                '  return (m + 0.5) / 16.0;',
                '}',
                '',
                '// Base flow colour at a given UV: domain-warped fbm selecting',
                '// across the three token colours, with a soft vertical fade.',
                'vec3 flow(vec2 uv) {',
                '  float t = u_time * 0.15;',
                '  vec2 q = vec2(fbm(uv * 2.0 + t), fbm(uv * 2.0 - t + 5.2));',
                '  float n = fbm(uv * 3.0 + q * 1.5 + vec2(t * 0.6, -t * 0.4));',
                '  vec3 col = mix(u_color0, u_color1, smoothstep(0.2, 0.8, n));',
                '  col = mix(col, u_color2, smoothstep(0.4, 1.0, q.x));',
                '  col *= 0.85 + 0.25 * n;',
                '  return col;',
                '}',
                '',
                'void main() {',
                '  vec2 frag = gl_FragCoord.xy;',
                '  vec2 uv = frag / u_resolution;',
                '  float aspect = u_resolution.x / max(u_resolution.y, 1.0);',
                '  vec2 p = (uv - 0.5) * vec2(aspect, 1.0);',
                '',
                '  // Prismatic refraction: sample the flow at slightly offset UVs',
                '  // per channel so the gradient splits into rainbow edges. Offset',
                '  // grows toward the edges and is modulated by u_prism.',
                '  float amt = u_prism * (0.04 + 0.10 * length(p));',
                '  vec2 dir = normalize(p + 1e-4);',
                '  float r = flow(uv + dir * amt).r;',
                '  float g = flow(uv).g;',
                '  float b = flow(uv - dir * amt).b;',
                '  vec3 col = vec3(r, g, b);',
                '',
                '  // Holographic iridescence: a thin hue band by angle + time,',
                '  // screened over the base so it reads as a sheen, not a wash.',
                '  float ang = atan(p.y, p.x) / 6.2831853 + 0.5;',
                '  float band = sin((ang * 3.0 + length(p) * 4.0 - u_time * 0.2) * 6.2831853);',
                '  vec3 holo = hue(fract(ang + u_time * 0.05 + length(p)));',
                '  col += holo * smoothstep(0.4, 1.0, band) * 0.18 * (0.5 + u_prism);',
                '',
                '  // Ordered (Bayer) dither: quantize to a few levels using the',
                '  // threshold pattern. u_dither blends between smooth and grainy.',
                '  float threshold = bayer(frag) - 0.5;',
                '  float levels = 6.0;',
                '  vec3 dithered = floor(col * levels + threshold) / (levels - 1.0);',
                '  col = mix(col, clamp(dithered, 0.0, 1.0), u_dither);',
                '',
                '  gl_FragColor = vec4(clamp(col, 0.0, 1.0), 1.0);',
                '}',
            ].join('\n');
        },

        destroy() {
            if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
            if (this._viewObserver) { this._viewObserver.disconnect(); this._viewObserver = null; }
            if (this._ro) { this._ro.disconnect(); this._ro = null; }
            const canvas = this.$refs.canvas;
            if (canvas) {
                if (this._onLost) canvas.removeEventListener('webglcontextlost', this._onLost);
                if (this._onRestored) canvas.removeEventListener('webglcontextrestored', this._onRestored);
            }
            const gl = this._gl;
            if (gl) {
                if (this._buffer) gl.deleteBuffer(this._buffer);
                if (this._program) gl.deleteProgram(this._program);
                const ext = gl.getExtension('WEBGL_lose_context');
                if (ext) ext.loseContext();
            }
            this._onLost = null;
            this._onRestored = null;
            this._buffer = null;
            this._program = null;
            this._uniforms = null;
            this._gl = null;
        },
    }));
});
resources/views/components/ui/backdrop/liquid.blade.php Blade
@props([
    // speed scales u_time; flowStrength scales the domain-warp displacement;
    // grain is the dither amount; the reveal wipes left→right over
    // revealDuration ms, starting delayMs after init.
    'speed' => 1,
    'flowStrength' => 1,
    'grain' => 0.08,
    'delayMs' => 0,
    'revealDuration' => 1200,
])

@php
    $config = [
        'speed' => (float) $speed,
        'flowStrength' => (float) $flowStrength,
        'grain' => (float) $grain,
        'delayMs' => max(0, (int) $delayMs),
        'revealDuration' => max(1, (int) $revealDuration),
    ];
@endphp

{{--
    Liquid backdrop. A single <canvas> is driven by a raw WebGL1 program (no
    Three.js): a full-screen quad whose fragment shader layers domain-warped
    value noise to produce slow liquid motion, blending three SEMANTIC colour
    layers — deep (--background), mid (--primary) and highlight (--accent) —
    read at runtime from the host's computed tokens, then applies a dithered
    grain. A u_reveal uniform animates 0→1 over revealDuration ms (after
    delayMs) to gate a left-to-right wipe of the effect on load. Reduced-motion
    users get a single static, fully-revealed frame with no rAF and no wipe.
--}}
<div x-data="uiBackdropLiquid(@js($config))" x-ref="root" {{ $attributes->merge(['class' => 'absolute inset-0']) }}>
    <canvas
        x-ref="canvas"
        aria-hidden="true"
        class="pointer-events-none absolute inset-0 -z-10 block h-full w-full"
    ></canvas>

    {{-- Token scrim: a soft gradient from the background keeps foreground
         content legible over the brightest parts of the liquid without hiding
         the effect. --}}
    <div aria-hidden="true" class="pointer-events-none absolute inset-0 -z-10 bg-gradient-to-t from-background/80 via-background/30 to-background/10"></div>
</div>
resources/js/ui/backdrop-liquid.js JS
/**
 * Backdrop: Liquid behavior (raw WebGL1 animated liquid hero background).
 *
 * A single <canvas> fills the host section. A full-screen quad is rendered with
 * a hand-written WebGL1 / GLSL ES 1.00 program — no Three.js. The fragment
 * shader builds slow "liquid" motion by domain-warping layered value noise: the
 * sample coordinate is displaced by lower-frequency noise (scaled by u_flow), so
 * the field folds and flows over itself. The warped noise then blends THREE
 * SEMANTIC color layers — deep / mid / highlight — and a dithered grain
 * (u_grain) is added to break up banding.
 *
 * Reveal: a u_reveal uniform animates 0 -> 1 over `revealDuration` ms, starting
 * `delayMs` after init. The shader uses it as a soft left-to-right wipe: columns
 * to the left of the advancing edge show the full effect, columns to the right
 * fade to the deep/background color, so the liquid sweeps in on load.
 *
 * Color is SEMANTIC and lives only here (the markup uses tokens, never hex): the
 * deep/mid/highlight vec3 uniforms are derived at runtime from the host's
 * computed tokens — --background (deep), --primary (mid), --accent (highlight),
 * each probed through a scratch element to normalize to rgb and divided to 0..1.
 *
 * Sizing: the backing store is scaled for devicePixelRatio and a ResizeObserver
 * updates the viewport + u_resolution on resize. WebGL context loss is handled:
 * the rAF stops on `webglcontextlost` and the program is rebuilt + restarted on
 * `webglcontextrestored`.
 *
 * a11y / reduced motion: the canvas is aria-hidden + pointer-events-none (set in
 * Blade). Under prefers-reduced-motion a single STATIC, fully-revealed frame is
 * drawn (u_reveal = 1, fixed u_time) with no animation loop and no wipe.
 *
 * Self-registers on `alpine:init` so import order does not matter.
 *
 * Note: this file is plain JS served by the registry — no Blade syntax allowed.
 */
document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiBackdropLiquid', (config = {}) => ({
        speed: Number.isFinite(config.speed) ? config.speed : 1,
        flowStrength: Number.isFinite(config.flowStrength) ? config.flowStrength : 1,
        grain: Number.isFinite(config.grain) ? config.grain : 0.08,
        delayMs: Math.max(0, config.delayMs ?? 0),
        revealDuration: Math.max(1, config.revealDuration ?? 1200),

        _gl: null,
        _program: null,
        _buffer: null,
        _loc: null,
        _dpr: 1,
        _cssW: 0,
        _cssH: 0,
        _raf: null,
        _ro: null,
        _reduced: false,
        _start: 0,
        _revealStart: 0,
        _colors: { deep: [0, 0, 0], mid: [0.4, 0.4, 0.6], highlight: [0.8, 0.8, 1] },
        _onLost: null,
        _onRestored: null,

        init() {
            const canvas = this.$refs.canvas;
            this._reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

            // Context-loss handling: stop drawing on loss, rebuild on restore.
            this._onLost = (e) => {
                e.preventDefault();
                if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
            };
            this._onRestored = () => { this.boot(); };
            canvas.addEventListener('webglcontextlost', this._onLost, false);
            canvas.addEventListener('webglcontextrestored', this._onRestored, false);

            // 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 = null;
                        this._offscreen = true;
                    } else if (visible && this._offscreen) {
                        this._offscreen = false;
                        if (!this._reduced && this._program) this._resume();
                    }
                });
                this._viewObserver.observe(this.$refs.root);
            }

            this.boot();
        },

        // (Re)create the GL context + program and start rendering. Safe to call
        // again after a context restore.
        boot() {
            const canvas = this.$refs.canvas;
            const gl = canvas.getContext('webgl', { premultipliedAlpha: false, antialias: false })
                || canvas.getContext('experimental-webgl');
            if (!gl) return; // No WebGL: leave the (token-styled) section as-is.
            this._gl = gl;

            this._program = this.buildProgram(gl);
            if (!this._program) return;

            // Full-screen quad as two triangles in clip space.
            this._buffer = gl.createBuffer();
            gl.bindBuffer(gl.ARRAY_BUFFER, this._buffer);
            gl.bufferData(
                gl.ARRAY_BUFFER,
                new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),
                gl.STATIC_DRAW,
            );

            const aPos = gl.getAttribLocation(this._program, 'a_position');
            gl.enableVertexAttribArray(aPos);
            gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);

            this._loc = {
                time: gl.getUniformLocation(this._program, 'u_time'),
                resolution: gl.getUniformLocation(this._program, 'u_resolution'),
                reveal: gl.getUniformLocation(this._program, 'u_reveal'),
                flow: gl.getUniformLocation(this._program, 'u_flow'),
                grain: gl.getUniformLocation(this._program, 'u_grain'),
                deep: gl.getUniformLocation(this._program, 'u_deep'),
                mid: gl.getUniformLocation(this._program, 'u_mid'),
                highlight: gl.getUniformLocation(this._program, 'u_highlight'),
            };

            gl.useProgram(this._program);

            this.resolveColors();
            this.resize();

            if (this._reduced) {
                // Single static, fully-revealed frame; no rAF, no wipe.
                this.render(0, 1);
                return;
            }

            let pending = false;
            this._ro = new ResizeObserver(() => {
                if (pending) return;
                pending = true;
                requestAnimationFrame(() => { pending = false; this.resize(); });
            });
            this._ro.observe(this.$refs.root);

            this._start = performance.now();
            this._revealStart = this._start + this.delayMs;
            this._raf = requestAnimationFrame((t) => this.loop(t));
        },

        buildProgram(gl) {
            const vsSource = [
                'attribute vec2 a_position;',
                'void main() {',
                '  gl_Position = vec4(a_position, 0.0, 1.0);',
                '}',
            ].join('\n');

            const fsSource = [
                'precision highp float;',
                'uniform vec2 u_resolution;',
                'uniform float u_time;',
                'uniform float u_reveal;',
                'uniform float u_flow;',
                'uniform float u_grain;',
                'uniform vec3 u_deep;',
                'uniform vec3 u_mid;',
                'uniform vec3 u_highlight;',
                '',
                '// Hash + value noise (cheap, dependency-free).',
                'float hash(vec2 p) {',
                '  p = fract(p * vec2(123.34, 345.45));',
                '  p += dot(p, p + 34.345);',
                '  return fract(p.x * p.y);',
                '}',
                'float noise(vec2 p) {',
                '  vec2 i = floor(p);',
                '  vec2 f = fract(p);',
                '  vec2 u = f * f * (3.0 - 2.0 * f);',
                '  float a = hash(i);',
                '  float b = hash(i + vec2(1.0, 0.0));',
                '  float c = hash(i + vec2(0.0, 1.0));',
                '  float d = hash(i + vec2(1.0, 1.0));',
                '  return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);',
                '}',
                'float fbm(vec2 p) {',
                '  float v = 0.0;',
                '  float amp = 0.5;',
                '  for (int i = 0; i < 5; i++) {',
                '    v += amp * noise(p);',
                '    p *= 2.0;',
                '    amp *= 0.5;',
                '  }',
                '  return v;',
                '}',
                '',
                '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 * aspect, uv.y) * 3.0;',
                '  float t = u_time * 0.15;',
                '',
                '  // Domain warp: displace the sample coords by lower-freq noise,',
                '  // scaled by the flow strength, so the field folds and flows.',
                '  vec2 q = vec2(fbm(p + vec2(0.0, t)), fbm(p + vec2(5.2, 1.3 - t)));',
                '  vec2 r = vec2(',
                '    fbm(p + u_flow * q + vec2(1.7, 9.2) + 0.15 * t),',
                '    fbm(p + u_flow * q + vec2(8.3, 2.8) - 0.12 * t)',
                '  );',
                '  float f = fbm(p + u_flow * r);',
                '',
                '  // Blend the three semantic color layers across the field.',
                '  vec3 col = mix(u_deep, u_mid, smoothstep(0.0, 0.6, f));',
                '  col = mix(col, u_highlight, smoothstep(0.55, 1.0, f + 0.25 * length(r)));',
                '',
                '  // Dithered grain to break up banding.',
                '  float g = (hash(gl_FragCoord.xy + fract(u_time)) - 0.5) * u_grain;',
                '  col += g;',
                '',
                '  // Left-to-right reveal wipe: a soft edge sweeps across as',
                '  // u_reveal goes 0 -> 1; right of the edge fades to deep.',
                '  float edge = u_reveal * 1.15;',
                '  float wipe = smoothstep(edge, edge - 0.15, uv.x);',
                '  col = mix(u_deep, col, wipe);',
                '',
                '  gl_FragColor = vec4(clamp(col, 0.0, 1.0), 1.0);',
                '}',
            ].join('\n');

            const vs = this.compile(gl, gl.VERTEX_SHADER, vsSource);
            const fs = this.compile(gl, gl.FRAGMENT_SHADER, fsSource);
            if (!vs || !fs) return null;

            const program = gl.createProgram();
            gl.attachShader(program, vs);
            gl.attachShader(program, fs);
            gl.linkProgram(program);
            if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return null;
            return program;
        },

        compile(gl, type, source) {
            const shader = gl.createShader(type);
            gl.shaderSource(shader, source);
            gl.compileShader(shader);
            if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
                gl.deleteShader(shader);
                return null;
            }
            return shader;
        },

        // Probe a token to a normalized [r,g,b] in 0..1. Tries the raw value,
        // then common color-function wrappers, so oklch(...)/bare-channel/hex/rgb
        // all resolve. Falls back to `fallback`.
        tokenRgb(name, fallback) {
            const root = this.$refs.root;
            const raw = getComputedStyle(root).getPropertyValue(name).trim();
            if (raw === '') return fallback;
            const probe = document.createElement('span');
            probe.style.color = raw;
            if (probe.style.color === '') {
                probe.style.color = 'oklch(' + raw + ')';
                if (probe.style.color === '') probe.style.color = 'rgb(' + raw + ')';
            }
            if (probe.style.color === '') return fallback;
            document.body.appendChild(probe);
            const resolved = getComputedStyle(probe).color;
            document.body.removeChild(probe);
            const m = String(resolved).match(/(\d+(?:\.\d+)?)/g);
            if (!m) return fallback;
            return [Number(m[0]) / 255, Number(m[1]) / 255, Number(m[2]) / 255];
        },

        // deep = background, mid = primary, highlight = accent. Sensible
        // fallbacks keep the effect visible if a token is missing.
        resolveColors() {
            this._colors = {
                deep: this.tokenRgb('--background', [0.04, 0.05, 0.09]),
                mid: this.tokenRgb('--primary', [0.30, 0.36, 0.78]),
                highlight: this.tokenRgb('--accent', [0.62, 0.78, 0.96]),
            };
        },

        resize() {
            const gl = this._gl;
            const canvas = this.$refs.canvas;
            const root = this.$refs.root;
            if (!gl) return;
            this._dpr = Math.max(1, window.devicePixelRatio || 1);
            this._cssW = Math.max(1, root.clientWidth || 1);
            this._cssH = Math.max(1, root.clientHeight || 1);
            canvas.width = Math.round(this._cssW * this._dpr);
            canvas.height = Math.round(this._cssH * this._dpr);
            gl.viewport(0, 0, canvas.width, canvas.height);
            if (this._reduced) this.render(0, 1);
        },

        _resume() {
            if (this._raf) cancelAnimationFrame(this._raf);
            this._raf = requestAnimationFrame((t) => this.loop(t));
        },

        loop(now) {
            const elapsed = (now - this._start) / 1000;
            // Reveal eases 0 -> 1 over revealDuration after the delay.
            const reveal = Math.min(1, Math.max(0, (now - this._revealStart) / this.revealDuration));
            const eased = reveal * reveal * (3 - 2 * reveal);
            this.render(elapsed * this.speed, eased);
            this._raf = requestAnimationFrame((t) => this.loop(t));
        },

        render(time, reveal) {
            const gl = this._gl;
            if (!gl || !this._program || gl.isContextLost()) return;
            const c = this._colors;
            gl.useProgram(this._program);
            gl.uniform1f(this._loc.time, time);
            gl.uniform2f(this._loc.resolution, gl.drawingBufferWidth, gl.drawingBufferHeight);
            gl.uniform1f(this._loc.reveal, reveal);
            gl.uniform1f(this._loc.flow, this.flowStrength);
            gl.uniform1f(this._loc.grain, this.grain);
            gl.uniform3f(this._loc.deep, c.deep[0], c.deep[1], c.deep[2]);
            gl.uniform3f(this._loc.mid, c.mid[0], c.mid[1], c.mid[2]);
            gl.uniform3f(this._loc.highlight, c.highlight[0], c.highlight[1], c.highlight[2]);
            gl.drawArrays(gl.TRIANGLES, 0, 6);
        },

        destroy() {
            if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
            if (this._viewObserver) { this._viewObserver.disconnect(); this._viewObserver = null; }
            if (this._ro) { this._ro.disconnect(); this._ro = null; }
            const canvas = this.$refs.canvas;
            if (canvas) {
                if (this._onLost) canvas.removeEventListener('webglcontextlost', this._onLost);
                if (this._onRestored) canvas.removeEventListener('webglcontextrestored', this._onRestored);
            }
            const gl = this._gl;
            if (gl) {
                if (this._buffer) gl.deleteBuffer(this._buffer);
                if (this._program) gl.deleteProgram(this._program);
                const ext = gl.getExtension('WEBGL_lose_context');
                if (ext) ext.loseContext();
            }
            this._gl = null;
            this._program = null;
            this._buffer = null;
        },
    }));
});
resources/views/components/ui/backdrop/dots.blade.php Blade
{{-- Dots backdrop. A faded dot-pattern field — composes <x-ui.dot-pattern>. --}}
<x-ui.dot-pattern :gap="22" :radius="1" :fade="true" class="text-border/60" {{ $attributes }} />
resources/views/components/ui/backdrop/grid.blade.php Blade
{{-- Grid backdrop. A faded, wide-gap grid-pattern field — composes <x-ui.grid-pattern>. --}}
<x-ui.grid-pattern :gap="40" fade class="opacity-50" {{ $attributes }} />
resources/views/components/ui/backdrop/glow.blade.php Blade
{{-- Glow backdrop. A rising brand bloom anchored to the bottom edge — composes <x-ui.glow>. --}}
<x-ui.glow variant="bottom" {{ $attributes }} />

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.1.2
Status
Stable
License
open
Accessibility reviewed
No review date recorded
Last breaking change
No date recorded
Deprecation
Not deprecated
Contract
v6
Foundation
≥ 1.0.0