Shaders
forgeui.in-style WebGL shader backdrops — full-bleed animated gradient and fluid fields rendered on a canvas.
Shader — forgeui.in-style WebGL shader backdrop with seven presets (auralis, chromatic, cloudscape, cosmic-rift, luma-drift, velaris, velora). Colour-capable presets derive their default palette from theme tokens at runtime. Fills its positioned parent; reduced motion renders a single static frame.
Preview
<div class="relative h-64 w-full max-w-full overflow-hidden rounded-lg border border-border bg-card" style="width: 34rem">
<x-ui.shader preset="auralis" />
</div>
Installation
php artisan ui:add shader
Note
This component ships an Alpine behavior module at
resources/js/ui/shader.js. Import it once from your bundle so it registers on alpine:init:
import './shader.js';
Registry contract
php artisan ui:add shader
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/shader.blade.php -
resources/js/ui/shader-auralis.js -
resources/js/ui/shader-chromatic.js -
resources/js/ui/shader-cloudscape.js -
resources/js/ui/shader-cosmic-rift.js -
resources/js/ui/shader-luma-drift.js -
resources/js/ui/shader-velaris.js -
resources/js/ui/shader-velora.js
- Registry dependencies
- None — installs on its own.
- Packages
-
composer: jml/brok:^0.2npm: 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.
# Brok UI: Shader (`shader`)
forgeui.in-style WebGL shader backdrop with seven presets (auralis, chromatic, cloudscape, cosmic-rift, luma-drift, velaris, velora). Colour-capable presets derive their default palette from theme tokens at runtime. Fills its positioned parent; reduced motion renders a single static frame.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add shader
```
## Usage
```blade
<div class="relative h-64 w-full max-w-full overflow-hidden rounded-lg border border-border bg-card" style="width: 34rem">
<x-ui.shader preset="auralis" />
</div>
```
## Props
- `preset` (auralis|chromatic|cloudscape|cosmic-rift|luma-drift|velaris|velora, default `auralis`) — Selects the WebGL effect and its behaviour module. An unknown value throws.
- `speed` (float, default `null`) — Animation speed multiplier (higher = faster). ~0 is nearly still.
- `grain` (float, default `null`) — Film-grain intensity (0..1). Supported by auralis, chromatic, velaris and velora.
- `colors` (string[], default `null`) — auralis: up to three hex glow stops (only the first two are rendered).
- `bg` (string, default `null`) — velaris: base backdrop hex.
- `flowStrength` (float, default `null`) — chromatic: distortion strength of the fluid flow. velora: streak/warp strength.
- `contrast` (float, default `null`) — chromatic: specular contrast of the metallic sheen.
- `colorBottom` (string, default `null`) — cloudscape, velora: the lower gradient stop.
- `colorMid` (string, default `null`) — cloudscape, velora: the middle gradient stop.
- `colorTop` (string, default `null`) — cloudscape, velora: the upper gradient stop.
- `colorBackground` (string, default `null`) — cosmic-rift: the void background tint.
- `colorStars` (string, default `null`) — cosmic-rift: the star tint.
- `density` (float, default `null`) — cosmic-rift: star-field density multiplier.
## Use when
- Use only when visual expression supports storytelling, brand differentiation, or a clear showcase moment.
- Adding a decorative, full-bleed animated WebGL background behind a hero or other relative section.
- Switching between a set of dramatic shader presets (glow, metallic sheen, sky bands, starfield, aurora, lava-lamp glow, warp tunnel) without shipping seven separate components.
## 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 lighter, CPU-only decorative layer is enough; compose backdrop's grid/glow/dots variants or dot-pattern/grid-pattern instead.
## Anti-patterns
- Heavy effects in forms or task-dense workflows
- Motion that interferes with reading or focus
## Rules
- Use the `<brok:shader>` tag (or `<x-ui.shader>`) 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/shader
- Registry JSON (files, props, contract): https://brokui.dev/r/open/shader.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| preset | auralis | chromatic | cloudscape | cosmic-rift | luma-drift | velaris | velora | auralis | Selects the WebGL effect and its behaviour module. An unknown value throws. |
| speed | float | null | Animation speed multiplier (higher = faster). ~0 is nearly still. |
| grain | float | null | Film-grain intensity (0..1). Supported by auralis, chromatic, velaris and velora. |
| colors | string[] | null | auralis: up to three hex glow stops (only the first two are rendered). |
| bg | string | null | velaris: base backdrop hex. |
| flowStrength | float | null | chromatic: distortion strength of the fluid flow. velora: streak/warp strength. |
| contrast | float | null | chromatic: specular contrast of the metallic sheen. |
| colorBottom | string | null | cloudscape, velora: the lower gradient stop. |
| colorMid | string | null | cloudscape, velora: the middle gradient stop. |
| colorTop | string | null | cloudscape, velora: the upper gradient stop. |
| colorBackground | string | null | cosmic-rift: the void background tint. |
| colorStars | string | null | cosmic-rift: the star tint. |
| density | float | null | cosmic-rift: star-field density multiplier. |
Slots
default— Primary Blade slot rendered by the component.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Always aria-hidden and pointer-events-none; purely decorative.
- Reduced motion renders exactly one static frame per preset and never starts the requestAnimationFrame loop.
- Colour-capable presets (auralis, cloudscape, cosmic-rift, velaris, velora) read their default stops from live CSS custom properties (getComputedStyle) so the layer follows the active theme; a hex prop still overrides any stop.
- Pauses its render loop via IntersectionObserver while scrolled out of view, and rebuilds/loses the WebGL context cleanly on tab suspend/restore.
- destroy() cancels the animation frame, disconnects observers and frees the GL program so unmounting mid-animation cannot leak.
- 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
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, full-bleed animated WebGL background behind a hero or other relative section.
- Switching between a set of dramatic shader presets (glow, metallic sheen, sky bands, starfield, aurora, lava-lamp glow, warp tunnel) without shipping seven separate components.
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 lighter, CPU-only decorative layer is enough; compose backdrop's grid/glow/dots variants or dot-pattern/grid-pattern instead.
Use instead
- Static content-first presentation
Anti-patterns
- Heavy effects in forms or task-dense workflows
- Motion that interferes with reading or focus
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- managed
- Focus
none
- The root is aria-hidden; never place content that needs to be perceivable inside it.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add wire:ignore to the component root because its behavior owns rendered DOM.
<div wire:ignore>
<div class="relative h-64 w-full max-w-full overflow-hidden rounded-lg border border-border bg-card" style="width: 34rem">
<x-ui.shader preset="auralis" />
</div>
</div>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
@props([
// Which WebGL effect to render. Each preset ships its own behaviour
// module (`shader-<preset>.js`); ui:add copies every module, so a
// consumer can switch presets without re-installing.
'preset' => 'auralis',
// Animation speed (higher = faster). ~0 is nearly still. Meaning and
// default vary per preset — see each module's doc comment.
'speed' => null,
// Film-grain intensity (0..1). Supported by auralis, chromatic, velaris
// and velora.
'grain' => null,
// auralis: up to three hex glow stops (colors[0]/[1] are the ones the
// shader actually reads). Defaults to theme-derived tones.
'colors' => null,
// velaris: base backdrop hex. Defaults to --background.
'bg' => null,
// chromatic: distortion strength of the fluid flow.
'flowStrength' => null,
// chromatic: specular contrast of the metallic sheen. velora reuses
// `flowStrength` for its streak/warp strength.
'contrast' => null,
// cloudscape, velora: three hex gradient stops. Default to theme-derived
// tones (cloudscape: primary/accent/background; velora:
// background/primary/accent).
'colorBottom' => null,
'colorMid' => null,
'colorTop' => null,
// cosmic-rift: background + star hex tints. Default to
// --background / --foreground.
'colorBackground' => null,
'colorStars' => null,
// cosmic-rift: star-field density multiplier.
'density' => null,
])
@php
// Keep in sync with the shader-<preset>.js modules shipped alongside
// this file and with families.php's `shader` member/knowledge props.
$shaderPresets = ['auralis', 'chromatic', 'cloudscape', 'cosmic-rift', 'luma-drift', 'velaris', 'velora'];
if (! in_array($preset, $shaderPresets, true)) {
throw new \InvalidArgumentException(
"Unknown shader preset [{$preset}]. Expected one of: ".implode(', ', $shaderPresets).'.'
);
}
// auralis -> uiShaderAuralis, cosmic-rift -> uiShaderCosmicRift, …
$dataFn = 'uiShader'.implode('', array_map('ucfirst', explode('-', $preset)));
$config = [];
foreach ([
'speed' => $speed !== null ? (float) $speed : null,
'grain' => $grain !== null ? (float) $grain : null,
'flowStrength' => $flowStrength !== null ? (float) $flowStrength : null,
'contrast' => $contrast !== null ? (float) $contrast : null,
'density' => $density !== null ? (float) $density : null,
'bg' => $bg,
'colorBottom' => $colorBottom,
'colorMid' => $colorMid,
'colorTop' => $colorTop,
'colorBackground' => $colorBackground,
'colorStars' => $colorStars,
] as $key => $value) {
if ($value !== null) {
$config[$key] = $value;
}
}
if (is_array($colors) && $colors !== []) {
$config['colors'] = array_values($colors);
}
@endphp
{{--
Shader: a decorative WebGL background (forgeui.in-style presets). The
host is aria-hidden + pointer-events-none so it never traps focus or
blocks the content layered above it. Presets that expose a colour config
derive their default palette from theme tokens (--primary/--accent/
--background/--foreground) at runtime; reduced motion paints a single
static frame.
A deprecated alias built on this primitive (auralis, cloudscape, …)
passes its own data-slot; the browser keeps only the first of two
identical attributes, so read the override and print exactly one.
--}}
@php
$slotName = (string) ($attributes->get('data-slot') ?: 'shader');
@endphp
<div
data-slot="{{ $slotName }}"
data-preset="{{ $preset }}"
x-data="{{ $dataFn }}(@js($config))"
x-ref="root"
aria-hidden="true"
{{ $attributes->except('data-slot')->merge(['class' => 'pointer-events-none absolute inset-0 overflow-hidden bg-background']) }}
>
<canvas x-ref="canvas" class="block h-full w-full"></canvas>
</div>
/**
* Auralis shader preset behaviour (WebGL-powered animated ambiance).
*
* A single full-bleed <canvas> renders layered simplex-noise light fields with a
* vignette and subtle film grain, producing a slow, dramatic glow. The effect is
* pure WebGL (vertex + fragment shader on a screen-filling triangle strip); no
* library. The host is decorative (aria-hidden / pointer-events-none, set in
* Blade) so it never traps focus or blocks the content layered above it.
*
* COLOUR: the glow stops default to the theme's `--primary` / `--accent` /
* `--background` tokens, resolved to WebGL rgb via `tokenRgb()` (a probe
* element + getComputedStyle, same technique as the `backdrop` gradient
* variant). A consumer may still override with hex via the `colors` prop; the
* fixed ember floats below are only the fallback before tokens resolve.
*
* PERFORMANCE: device-pixel ratio is capped and the backing store is rebuilt
* only when a ResizeObserver fires. Reduced motion paints a single static frame
* and never starts the rAF loop. destroy() cancels the loop, disconnects the
* observer and frees the GL program so unmounting mid-animation cannot leak.
*
* 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', () => {
const VERT = `
attribute vec2 position;
varying vec2 vUv;
void main() {
vUv = position * 0.5 + 0.5;
gl_Position = vec4(position, 0.0, 1.0);
}`;
const FRAG = `
precision highp float;
varying vec2 vUv;
uniform vec2 u_resolution;
uniform float u_time;
uniform float u_grain;
uniform vec3 u_colors[3];
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);
}
void main() {
vec2 uv = vUv;
float ratio = u_resolution.x / u_resolution.y;
vec2 p = uv * vec2(ratio, 1.0);
float t = u_time * 0.2;
float n1 = snoise(p * 0.5 + t);
float n2 = snoise(p * 0.9 - t * 0.5 + n1);
float light = pow(abs(n2), 2.5) * 0.5;
vec3 col = vec3(0.02, 0.01, 0.01);
col += u_colors[0] * smoothstep(0.1, 1.0, n1) * 0.5;
col += u_colors[1] * light;
float grain = fract(sin(dot(uv, vec2(12.9898, 78.233))) * 43758.5453 + u_time);
col += (grain - 0.5) * u_grain * 0.5;
float dist = length(uv - 0.5);
col *= smoothstep(1.2, 0.2, dist);
gl_FragColor = vec4(col, 1.0);
}`;
const hexToRgb = (hex) => {
const h = String(hex).replace('#', '');
return [
parseInt(h.slice(0, 2), 16) / 255,
parseInt(h.slice(2, 4), 16) / 255,
parseInt(h.slice(4, 6), 16) / 255,
];
};
// Reads a CSS custom property off the document root and resolves it to a
// normalised [r,g,b] (0..1) triple via a probe element (so oklch/hsl
// tokens collapse to concrete rgb WebGL can use), as the backdrop WebGL
// variants do. Falls back to a fixed tone when the property is unset or
// unresolvable.
const tokenRgb = (name, fallback) => {
const raw = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
if (!raw) return fallback;
const probe = document.createElement('span');
probe.style.display = 'none';
document.body.appendChild(probe);
probe.style.color = raw.includes('(') || raw.startsWith('#') || /[a-z]/i.test(raw) ? raw : `oklch(${raw})`;
const resolved = getComputedStyle(probe).color;
probe.remove();
const m = resolved.match(/-?\d*\.?\d+/g);
return m && m.length >= 3 ? [Number(m[0]) / 255, Number(m[1]) / 255, Number(m[2]) / 255] : fallback;
};
// asRgb normalises either a hex string (a consumer override) or an
// already-resolved [r,g,b] triple (a token default) to floats.
const asRgb = (c) => (Array.isArray(c) ? c : hexToRgb(c));
// colors[0]/[1] are the two glow stops the fragment shader actually reads
// (colors[2] is kept for the 3-slot uniform's shape and left unused).
const DEFAULT_COLORS = [
tokenRgb('--primary', [0.937, 0.267, 0.267]),
tokenRgb('--accent', [0.863, 0.149, 0.149]),
tokenRgb('--background', [0.725, 0.110, 0.110]),
];
window.Alpine.data('uiShaderAuralis', (config = {}) => ({
colors:
Array.isArray(config.colors) && config.colors.length
? config.colors.slice(0, 3)
: DEFAULT_COLORS,
speed: Math.max(0, config.speed ?? 0.3),
grain: Math.max(0, config.grain ?? 0.6),
_canvas: null,
_gl: null,
_program: null,
_buffer: null,
_locs: null,
_raf: null,
_ro: null,
_reduced: false,
_onLost: null,
_onRestored: null,
_viewObserver: null,
_offscreen: false,
init() {
this._canvas = this.$refs.canvas;
this._reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// WebGL context loss: stop the loop on lost, rebuild + restart on restore.
this._onLost = (e) => {
e.preventDefault();
if (this._raf) {
cancelAnimationFrame(this._raf);
this._raf = null;
}
};
this._onRestored = () => this.boot();
this._canvas.addEventListener('webglcontextlost', this._onLost, false);
this._canvas.addEventListener('webglcontextrestored', this._onRestored, false);
this.boot();
// Pause the rAF loop while fully scrolled out of view (same-tab;
// backgrounded tabs are already throttled by the browser) so
// off-screen instances don't burn GPU/CPU. Resume on re-entry.
// Static/reduced-motion renders never set _raf, so the `&& this._raf`
// guard leaves them untouched.
if (typeof IntersectionObserver !== 'undefined') {
this._viewObserver = new IntersectionObserver((entries) => {
const visible = entries.some((e) => e.isIntersecting);
if (!visible && this._raf) {
cancelAnimationFrame(this._raf);
this._raf = null;
this._offscreen = true;
} else if (visible && this._offscreen) {
this._offscreen = false;
this._raf = requestAnimationFrame((t) => this.loop(t));
}
});
this._viewObserver.observe(this.$refs.root);
}
},
boot() {
const gl = this._canvas.getContext('webgl', { antialias: true });
if (!gl) return;
this._gl = gl;
const shader = (type, src) => {
const s = gl.createShader(type);
gl.shaderSource(s, src);
gl.compileShader(s);
return s;
};
const program = gl.createProgram();
gl.attachShader(program, shader(gl.VERTEX_SHADER, VERT));
gl.attachShader(program, shader(gl.FRAGMENT_SHADER, FRAG));
gl.linkProgram(program);
gl.useProgram(program);
this._program = program;
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]), gl.STATIC_DRAW);
const pos = gl.getAttribLocation(program, 'position');
gl.enableVertexAttribArray(pos);
gl.vertexAttribPointer(pos, 2, gl.FLOAT, false, 0, 0);
this._locs = {
res: gl.getUniformLocation(program, 'u_resolution'),
time: gl.getUniformLocation(program, 'u_time'),
grain: gl.getUniformLocation(program, 'u_grain'),
colors: gl.getUniformLocation(program, 'u_colors'),
};
this.resize();
if (!this._ro) {
this._ro = new ResizeObserver(() => this.resize());
this._ro.observe(this.$refs.root);
}
if (this._reduced) {
this.draw(0);
return;
}
this._raf = requestAnimationFrame((t) => this.loop(t));
},
resize() {
const gl = this._gl;
if (!gl || gl.isContextLost()) return;
const root = this.$refs.root;
const canvas = this.$refs.canvas;
const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
canvas.width = Math.max(1, Math.floor(root.clientWidth * dpr));
canvas.height = Math.max(1, Math.floor(root.clientHeight * dpr));
gl.viewport(0, 0, canvas.width, canvas.height);
if (this._reduced) this.draw(0);
},
draw(timeMs) {
const gl = this._gl;
if (!gl || gl.isContextLost()) return;
const canvas = this.$refs.canvas;
gl.uniform2f(this._locs.res, canvas.width, canvas.height);
gl.uniform1f(this._locs.time, timeMs * 0.001 * this.speed);
gl.uniform1f(this._locs.grain, this.grain);
gl.uniform3fv(this._locs.colors, new Float32Array(this.colors.flatMap(asRgb)));
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
},
loop(timeMs) {
this.draw(timeMs);
this._raf = requestAnimationFrame((t) => this.loop(t));
},
destroy() {
if (this._viewObserver) { this._viewObserver.disconnect(); this._viewObserver = null; }
if (this._raf) cancelAnimationFrame(this._raf);
if (this._ro) this._ro.disconnect();
if (this._canvas) {
if (this._onLost) this._canvas.removeEventListener('webglcontextlost', this._onLost, false);
if (this._onRestored) 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);
gl.getExtension('WEBGL_lose_context')?.loseContext();
}
this._raf = null;
this._ro = null;
this._gl = this._program = this._buffer = this._locs = null;
},
}));
});
/**
* Chromatic shader preset behaviour (WebGL molten-metal sheen).
*
* A full-bleed <canvas> renders flowing fractal-noise fields lit as a specular
* metallic surface, with per-channel offset glints producing a subtle chromatic
* shimmer, a vignette and film grain. Pure WebGL on a screen-filling triangle
* strip; no library. The host is decorative (aria-hidden / pointer-events-none,
* set in Blade) so it never traps focus or blocks layered content.
*
* The palette is monochrome specular highlight, so there is no colour config —
* the look follows from `flowStrength`, `contrast`, `grain` and `speed`.
*
* PERFORMANCE: device-pixel ratio is capped; the backing store is rebuilt only
* when a ResizeObserver fires. Reduced motion paints a single static frame and
* never starts the rAF loop. destroy() cancels the loop, disconnects the
* observer and frees the GL program so unmounting mid-animation cannot leak.
*
* 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', () => {
const VERT = `
attribute vec2 position;
varying vec2 vUv;
void main() {
vUv = (position + 1.0) * 0.5;
gl_Position = vec4(position, 0.0, 1.0);
}`;
const FRAG = `
precision highp float;
uniform vec2 u_resolution;
uniform float u_time;
uniform float u_flowStrength;
uniform float u_grain;
uniform float u_contrast;
uniform float u_speed;
varying vec2 vUv;
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), 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;
mat2 rot = mat2(cos(0.5), sin(0.5), -sin(0.5), cos(0.5));
for (int i = 0; i < 4; i++) {
v += a * noise(p);
p = rot * p * 2.0;
a *= 0.5;
}
return v;
}
void main() {
vec2 uv = vUv;
float t = u_time * u_speed;
vec2 p = (uv - 0.5) * vec2(u_resolution.x / u_resolution.y, 1.0) * 2.0;
float baseNoise = fbm(p * 0.8 + t * 0.2);
float fluid = fbm(p * 1.2 + baseNoise * u_flowStrength + t * 0.3);
float eps = 0.01;
float nx = fbm(p + vec2(eps, 0.0) + baseNoise * u_flowStrength + t * 0.3) - fluid;
float ny = fbm(p + vec2(0.0, eps) + baseNoise * u_flowStrength + t * 0.3) - fluid;
vec3 normal = normalize(vec3(nx, ny, eps * 1.5));
vec3 lightDir = normalize(vec3(1.0, 1.0, 0.8));
vec3 viewDir = vec3(0.0, 0.0, 1.0);
vec3 halfVector = normalize(lightDir + viewDir);
vec3 color = vec3(0.0);
float glintIntensity = 64.0;
float specR = pow(max(dot(normalize(vec3(nx + 0.005, ny, eps*1.5)), halfVector), 0.0), glintIntensity);
float specG = pow(max(dot(normal, halfVector), 0.0), glintIntensity);
float specB = pow(max(dot(normalize(vec3(nx - 0.005, ny, eps*1.5)), halfVector), 0.0), glintIntensity);
vec3 specular = vec3(specR, specG, specB) * 1.5;
color += specular;
color = mix(vec3(0.5), color, u_contrast);
float vig = smoothstep(1.8, 0.2, length(uv - 0.5));
color *= vig;
float grainAmount = (hash(uv + t) - 0.5) * u_grain;
color += grainAmount;
gl_FragColor = vec4(clamp(color, 0.0, 1.0), 1.0);
}`;
window.Alpine.data('uiShaderChromatic', (config = {}) => ({
flowStrength: config.flowStrength ?? 0.8,
grain: Math.max(0, config.grain ?? 0.04),
contrast: config.contrast ?? 1.1,
speed: Math.max(0, config.speed ?? 0.4),
_canvas: null,
_gl: null,
_program: null,
_buffer: null,
_locs: null,
_raf: null,
_ro: null,
_start: 0,
_reduced: false,
_onLost: null,
_onRestored: null,
_viewObserver: null,
_offscreen: false,
init() {
this._canvas = this.$refs.canvas;
this._reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
this._start = performance.now();
this._onLost = (e) => {
e.preventDefault();
if (this._raf) {
cancelAnimationFrame(this._raf);
this._raf = null;
}
};
this._onRestored = () => this.boot();
this._canvas.addEventListener('webglcontextlost', this._onLost, false);
this._canvas.addEventListener('webglcontextrestored', this._onRestored, false);
this.boot();
// Pause the rAF loop while fully scrolled out of view (same-tab;
// backgrounded tabs are already throttled by the browser) so
// off-screen instances don't burn GPU/CPU. Resume on re-entry.
// Static/reduced-motion renders never set _raf, so the `&& this._raf`
// guard leaves them untouched.
if (typeof IntersectionObserver !== 'undefined') {
this._viewObserver = new IntersectionObserver((entries) => {
const visible = entries.some((e) => e.isIntersecting);
if (!visible && this._raf) {
cancelAnimationFrame(this._raf);
this._raf = null;
this._offscreen = true;
} else if (visible && this._offscreen) {
this._offscreen = false;
this._raf = requestAnimationFrame((t) => this.loop(t));
}
});
this._viewObserver.observe(this.$refs.root);
}
},
boot() {
const gl = this._canvas.getContext('webgl', { antialias: true });
if (!gl) return;
this._gl = gl;
const compile = (type, src) => {
const s = gl.createShader(type);
gl.shaderSource(s, src);
gl.compileShader(s);
return s;
};
const program = gl.createProgram();
gl.attachShader(program, compile(gl.VERTEX_SHADER, VERT));
gl.attachShader(program, compile(gl.FRAGMENT_SHADER, FRAG));
gl.linkProgram(program);
gl.useProgram(program);
this._program = program;
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]), gl.STATIC_DRAW);
const pos = gl.getAttribLocation(program, 'position');
gl.enableVertexAttribArray(pos);
gl.vertexAttribPointer(pos, 2, gl.FLOAT, false, 0, 0);
this._locs = {
res: gl.getUniformLocation(program, 'u_resolution'),
time: gl.getUniformLocation(program, 'u_time'),
flow: gl.getUniformLocation(program, 'u_flowStrength'),
grain: gl.getUniformLocation(program, 'u_grain'),
contrast: gl.getUniformLocation(program, 'u_contrast'),
speed: gl.getUniformLocation(program, 'u_speed'),
};
this.resize();
if (!this._ro) {
this._ro = new ResizeObserver(() => this.resize());
this._ro.observe(this.$refs.root);
}
if (this._reduced) {
this.draw(0);
return;
}
this._raf = requestAnimationFrame((t) => this.loop(t));
},
resize() {
const gl = this._gl;
if (!gl || gl.isContextLost()) return;
const root = this.$refs.root;
const canvas = this.$refs.canvas;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.max(1, Math.floor(root.clientWidth * dpr));
canvas.height = Math.max(1, Math.floor(root.clientHeight * dpr));
gl.viewport(0, 0, canvas.width, canvas.height);
gl.uniform2f(this._locs.res, canvas.width, canvas.height);
if (this._reduced) this.draw(0);
},
draw(elapsedMs) {
const gl = this._gl;
if (!gl || gl.isContextLost()) return;
gl.uniform1f(this._locs.time, elapsedMs / 1000);
gl.uniform1f(this._locs.flow, this.flowStrength);
gl.uniform1f(this._locs.grain, this.grain);
gl.uniform1f(this._locs.contrast, this.contrast);
gl.uniform1f(this._locs.speed, this.speed);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
},
loop(now) {
this.draw(now - this._start);
this._raf = requestAnimationFrame((t) => this.loop(t));
},
destroy() {
if (this._viewObserver) { this._viewObserver.disconnect(); this._viewObserver = null; }
if (this._raf) cancelAnimationFrame(this._raf);
if (this._ro) this._ro.disconnect();
if (this._canvas) {
if (this._onLost) this._canvas.removeEventListener('webglcontextlost', this._onLost, false);
if (this._onRestored) 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);
gl.getExtension('WEBGL_lose_context')?.loseContext();
}
this._raf = null;
this._ro = null;
this._gl = this._program = this._buffer = this._locs = null;
},
}));
});
/**
* Cloudscape shader preset behaviour (WebGL drifting cloud bands).
*
* A full-bleed <canvas> renders morphing fractal-noise cloud layers that drift
* on a slow wind, banded into three colour stops (bottom → mid → top). Pure
* WebGL on a screen-filling triangle strip; no library. The host is decorative
* (aria-hidden / pointer-events-none, set in Blade) so it never traps focus or
* blocks the content layered above it.
*
* COLOUR: the three stops default to the theme's `--primary` / `--accent` /
* `--background` tokens, resolved to WebGL rgb via `tokenRgb()` (a probe
* element + getComputedStyle, same technique as the `backdrop` gradient
* variant). A consumer may still override any stop with hex via
* `colorBottom`/`colorMid`/`colorTop`; the fixed daylight floats below are
* only the fallback before tokens resolve.
*
* PERFORMANCE: device-pixel ratio is capped; the backing store is rebuilt only
* when a ResizeObserver fires. Reduced motion paints a single static frame and
* never starts the rAF loop. destroy() tears everything down so unmounting
* mid-animation cannot leak.
*
* 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', () => {
const VERT = `
attribute vec2 position;
void main() {
gl_Position = vec4(position, 0.0, 1.0);
}`;
const FRAG = `
precision highp float;
uniform vec2 u_resolution;
uniform float u_time;
uniform vec3 u_colorBottom;
uniform vec3 u_colorMid;
uniform vec3 u_colorTop;
uniform float u_speed;
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);
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));
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y;
}
float fbm(vec2 p, float t) {
float v = 0.0;
float a = 0.5;
float fi = 0.0;
mat2 rot = mat2(0.86, 0.51, -0.51, 0.86);
for (int i = 0; i < 6; i++) {
vec2 morph = vec2(sin(t * 0.5 + fi), cos(t * 0.3 - fi)) * 0.05;
v += a * noise(p + morph);
p = rot * p * 2.0;
a *= 0.5;
fi += 1.0;
}
return v;
}
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution;
float t = u_time * u_speed;
vec2 aspect = vec2(u_resolution.x / max(u_resolution.y, 1.0), 1.0);
vec2 p = (uv - 0.5) * aspect;
vec2 wind = vec2(t * 0.1, t * 0.02);
float pattern = fbm(p * 2.2 - wind, t);
float bandLow = smoothstep(0.3, 0.65, pattern);
float bandHigh = smoothstep(0.7, 0.95, pattern);
vec3 color = mix(u_colorBottom, u_colorMid, bandLow);
color = mix(color, u_colorTop, bandHigh);
gl_FragColor = vec4(color, 1.0);
}`;
const HEX = /^#?[0-9a-fA-F]{6}$/;
// Reads a CSS custom property off the document root and resolves it to a
// normalised [r,g,b] (0..1) triple via a probe element (so oklch/hsl
// tokens collapse to concrete rgb WebGL can use), as the backdrop WebGL
// variants do. Falls back to a fixed tone when the property is unset or
// unresolvable.
const tokenRgb = (name, fallback) => {
const raw = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
if (!raw) return fallback;
const probe = document.createElement('span');
probe.style.display = 'none';
document.body.appendChild(probe);
probe.style.color = raw.includes('(') || raw.startsWith('#') || /[a-z]/i.test(raw) ? raw : `oklch(${raw})`;
const resolved = getComputedStyle(probe).color;
probe.remove();
const m = resolved.match(/-?\d*\.?\d+/g);
return m && m.length >= 3 ? [Number(m[0]) / 255, Number(m[1]) / 255, Number(m[2]) / 255] : fallback;
};
// A hex override resolves via straight hex parsing; anything else (the
// token-derived default, an [r,g,b] triple already) passes through.
const toRgb = (value, fallback) => {
if (typeof value === 'string' && HEX.test(value.trim())) {
const h = value.trim().replace('#', '');
return [
parseInt(h.slice(0, 2), 16) / 255,
parseInt(h.slice(2, 4), 16) / 255,
parseInt(h.slice(4, 6), 16) / 255,
];
}
return fallback;
};
// Sky tones derived from the theme (bottom = primary, mid = accent, top =
// background); fixed daylight floats are the fallback before tokens resolve.
const DEFAULTS = {
bottom: tokenRgb('--primary', [0.529, 0.808, 0.922]),
mid: tokenRgb('--accent', [0.973, 0.973, 0.973]),
top: tokenRgb('--background', [1, 1, 1]),
};
window.Alpine.data('uiShaderCloudscape', (config = {}) => ({
colorBottom: config.colorBottom ?? DEFAULTS.bottom,
colorMid: config.colorMid ?? DEFAULTS.mid,
colorTop: config.colorTop ?? DEFAULTS.top,
speed: Math.max(0, config.speed ?? 1),
_canvas: null,
_gl: null,
_program: null,
_buffer: null,
_locs: null,
_raf: null,
_ro: null,
_start: 0,
_reduced: false,
_onLost: null,
_onRestored: null,
_viewObserver: null,
_offscreen: false,
init() {
this._canvas = this.$refs.canvas;
this._reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
this._start = performance.now();
this._onLost = (e) => {
e.preventDefault();
if (this._raf) {
cancelAnimationFrame(this._raf);
this._raf = null;
}
};
this._onRestored = () => this.boot();
this._canvas.addEventListener('webglcontextlost', this._onLost, false);
this._canvas.addEventListener('webglcontextrestored', this._onRestored, false);
this.boot();
// Pause the rAF loop while fully scrolled out of view (same-tab;
// backgrounded tabs are already throttled by the browser) so
// off-screen instances don't burn GPU/CPU. Resume on re-entry.
// Static/reduced-motion renders never set _raf, so the `&& this._raf`
// guard leaves them untouched.
if (typeof IntersectionObserver !== 'undefined') {
this._viewObserver = new IntersectionObserver((entries) => {
const visible = entries.some((e) => e.isIntersecting);
if (!visible && this._raf) {
cancelAnimationFrame(this._raf);
this._raf = null;
this._offscreen = true;
} else if (visible && this._offscreen) {
this._offscreen = false;
this._raf = requestAnimationFrame((t) => this.loop(t));
}
});
this._viewObserver.observe(this.$refs.root);
}
},
boot() {
const gl = this._canvas.getContext('webgl', { antialias: true, alpha: true });
if (!gl) return;
this._gl = gl;
const compile = (type, src) => {
const s = gl.createShader(type);
gl.shaderSource(s, src);
gl.compileShader(s);
return s;
};
const program = gl.createProgram();
gl.attachShader(program, compile(gl.VERTEX_SHADER, VERT));
gl.attachShader(program, compile(gl.FRAGMENT_SHADER, FRAG));
gl.linkProgram(program);
gl.useProgram(program);
this._program = program;
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]), gl.STATIC_DRAW);
const pos = gl.getAttribLocation(program, 'position');
gl.enableVertexAttribArray(pos);
gl.vertexAttribPointer(pos, 2, gl.FLOAT, false, 0, 0);
this._locs = {
res: gl.getUniformLocation(program, 'u_resolution'),
time: gl.getUniformLocation(program, 'u_time'),
bottom: gl.getUniformLocation(program, 'u_colorBottom'),
mid: gl.getUniformLocation(program, 'u_colorMid'),
top: gl.getUniformLocation(program, 'u_colorTop'),
speed: gl.getUniformLocation(program, 'u_speed'),
};
this.resize();
if (!this._ro) {
this._ro = new ResizeObserver(() => this.resize());
this._ro.observe(this.$refs.root);
}
if (this._reduced) {
this.draw(0);
return;
}
this._raf = requestAnimationFrame((t) => this.loop(t));
},
resize() {
const gl = this._gl;
if (!gl || gl.isContextLost()) return;
const root = this.$refs.root;
const canvas = this.$refs.canvas;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.max(1, Math.floor(root.clientWidth * dpr));
canvas.height = Math.max(1, Math.floor(root.clientHeight * dpr));
gl.viewport(0, 0, canvas.width, canvas.height);
gl.uniform2f(this._locs.res, canvas.width, canvas.height);
if (this._reduced) this.draw(0);
},
draw(elapsedMs) {
const gl = this._gl;
if (!gl || gl.isContextLost()) return;
const cb = toRgb(this.colorBottom, DEFAULTS.bottom);
const cm = toRgb(this.colorMid, DEFAULTS.mid);
const ct = toRgb(this.colorTop, DEFAULTS.top);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.uniform1f(this._locs.time, elapsedMs / 1000);
gl.uniform3f(this._locs.bottom, cb[0], cb[1], cb[2]);
gl.uniform3f(this._locs.mid, cm[0], cm[1], cm[2]);
gl.uniform3f(this._locs.top, ct[0], ct[1], ct[2]);
gl.uniform1f(this._locs.speed, this.speed);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
},
loop(now) {
this.draw(now - this._start);
this._raf = requestAnimationFrame((t) => this.loop(t));
},
destroy() {
if (this._viewObserver) { this._viewObserver.disconnect(); this._viewObserver = null; }
if (this._raf) cancelAnimationFrame(this._raf);
if (this._ro) this._ro.disconnect();
if (this._canvas) {
if (this._onLost) this._canvas.removeEventListener('webglcontextlost', this._onLost, false);
if (this._onRestored) 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);
gl.getExtension('WEBGL_lose_context')?.loseContext();
}
this._raf = null;
this._ro = null;
this._gl = this._program = this._buffer = this._locs = null;
},
}));
});
/**
* Cosmic Rift shader preset behaviour (WebGL starfield + plasma rift).
*
* A full-bleed <canvas> renders a twinkling procedural starfield over a glowing
* multi-layer plasma "rift" that ripples across the middle of the frame, with a
* soft vignette. Pure WebGL on a screen-filling triangle strip; no library. The
* host is decorative (aria-hidden / pointer-events-none, set in Blade) so it
* never traps focus or blocks the content layered above it.
*
* COLOUR: the background and star tints default to the theme's `--background`
* and `--foreground` tokens (rift colours stay baked into the shader as
* procedural artwork), resolved to WebGL rgb via `tokenRgb()` (a probe element
* + getComputedStyle, same technique as the `backdrop` gradient variant). A
* consumer may still override either tint with hex via `colorBackground` /
* `colorStars`; the fixed void/star floats below are only the fallback before
* tokens resolve.
*
* PERFORMANCE: device-pixel ratio is used as-is from the host; the backing store
* is rebuilt only when a ResizeObserver fires. Reduced motion paints a single
* static frame and never starts the rAF loop. destroy() tears everything down so
* unmounting mid-animation cannot leak.
*
* 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', () => {
const VERT = `
attribute vec2 position;
void main() {
gl_Position = vec4(position, 0.0, 1.0);
}`;
const FRAG = `
precision highp float;
uniform vec2 u_resolution;
uniform float u_time;
uniform vec3 u_colorBackground;
uniform vec3 u_colorStars;
uniform float u_density;
uniform float u_speed;
float hash(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
}
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution;
float aspect = u_resolution.x / u_resolution.y;
vec2 p = uv;
p.x *= aspect;
float t = u_time * u_speed * 0.5;
vec3 color = u_colorBackground;
float gridSize = 35.0 * u_density;
vec2 gridPos = floor(p * gridSize);
vec2 localUV = fract(p * gridSize) - 0.5;
float r = hash(gridPos);
float threshold = 0.85;
if (r > threshold) {
vec2 offset = vec2(hash(gridPos + 12.0), hash(gridPos + 24.0)) - 0.5;
float dist = length(localUV - offset * 0.7);
float size = 0.025 + (r - threshold) * 0.6;
float brightness = smoothstep(size, 0.0, dist);
float twinkleSpeed = 1.0 + hash(gridPos + 100.0) * 3.0;
float twinkle = sin(t * twinkleSpeed * 2.0 + hash(gridPos + 200.0) * 6.28) * 0.5 + 0.5;
brightness *= (0.3 + twinkle * 0.7);
vec3 starCol = mix(u_colorStars, vec3(0.6, 0.8, 1.0), hash(gridPos + 300.0));
color += starCol * brightness * (r - threshold) * 7.0;
}
vec2 cuv = uv * 2.0 - 1.0;
cuv.x *= aspect;
vec3 waveColor = vec3(0.0);
for (float i = 1.0; i <= 4.0; i++) {
float xDistort = sin(cuv.y * 2.0 * i + t * (0.5 + i * 0.15)) * 0.2;
float yDistort = cos(cuv.x * 2.0 * i - t * 0.4) * 0.15;
vec2 distortedUV = cuv + vec2(xDistort, yDistort);
float dist = abs(distortedUV.y);
float glow = 0.015 / (dist + 0.015);
vec3 c1 = vec3(0.1, 0.02, 0.3);
vec3 c2 = vec3(0.0, 0.9, 1.0);
vec3 c3 = vec3(0.9, 0.1, 0.7);
vec3 layerCol = mix(c1, c2, sin(cuv.x * 2.5 + t + i) * 0.5 + 0.5);
layerCol = mix(layerCol, c3, cos(cuv.y * 3.0 - t) * 0.5 + 0.5);
waveColor += layerCol * glow * (1.5 / i);
}
float mask = smoothstep(1.0, 0.05, abs(cuv.y));
color += waveColor * mask;
float vignette = uv.x * (1.0 - uv.x) * uv.y * (1.0 - uv.y) * 15.0;
color *= pow(vignette, 0.15);
gl_FragColor = vec4(color, 1.0);
}`;
const HEX = /^#?[0-9a-fA-F]{6}$/;
// Reads a CSS custom property off the document root and resolves it to a
// normalised [r,g,b] (0..1) triple via a probe element (so oklch/hsl
// tokens collapse to concrete rgb WebGL can use), as the backdrop WebGL
// variants do. Falls back to a fixed tone when the property is unset or
// unresolvable.
const tokenRgb = (name, fallback) => {
const raw = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
if (!raw) return fallback;
const probe = document.createElement('span');
probe.style.display = 'none';
document.body.appendChild(probe);
probe.style.color = raw.includes('(') || raw.startsWith('#') || /[a-z]/i.test(raw) ? raw : `oklch(${raw})`;
const resolved = getComputedStyle(probe).color;
probe.remove();
const m = resolved.match(/-?\d*\.?\d+/g);
return m && m.length >= 3 ? [Number(m[0]) / 255, Number(m[1]) / 255, Number(m[2]) / 255] : fallback;
};
// A hex override resolves via straight hex parsing; anything else (the
// token-derived default, an [r,g,b] triple already) passes through.
const toRgb = (value, fallback) => {
if (typeof value === 'string' && HEX.test(value.trim())) {
const h = value.trim().replace('#', '');
return [
parseInt(h.slice(0, 2), 16) / 255,
parseInt(h.slice(2, 4), 16) / 255,
parseInt(h.slice(4, 6), 16) / 255,
];
}
return fallback;
};
// The void and star tints derived from the theme (background = the
// `--background` token, stars = `--foreground`); fixed midnight floats
// are the fallback before tokens resolve.
const DEFAULTS = {
background: tokenRgb('--background', [0.008, 0.004, 0.024]),
stars: tokenRgb('--foreground', [1, 1, 1]),
};
window.Alpine.data('uiShaderCosmicRift', (config = {}) => ({
colorBackground: config.colorBackground ?? DEFAULTS.background,
colorStars: config.colorStars ?? DEFAULTS.stars,
density: config.density ?? 1.2,
speed: Math.max(0, config.speed ?? 1),
_canvas: null,
_gl: null,
_program: null,
_buffer: null,
_locs: null,
_raf: null,
_ro: null,
_reduced: false,
_onLost: null,
_onRestored: null,
_viewObserver: null,
_offscreen: false,
init() {
this._canvas = this.$refs.canvas;
this._reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
this._onLost = (e) => {
e.preventDefault();
if (this._raf) {
cancelAnimationFrame(this._raf);
this._raf = null;
}
};
this._onRestored = () => this.boot();
this._canvas.addEventListener('webglcontextlost', this._onLost, false);
this._canvas.addEventListener('webglcontextrestored', this._onRestored, false);
this.boot();
// Pause the rAF loop while fully scrolled out of view (same-tab;
// backgrounded tabs are already throttled by the browser) so
// off-screen instances don't burn GPU/CPU. Resume on re-entry.
// Static/reduced-motion renders never set _raf, so the `&& this._raf`
// guard leaves them untouched.
if (typeof IntersectionObserver !== 'undefined') {
this._viewObserver = new IntersectionObserver((entries) => {
const visible = entries.some((e) => e.isIntersecting);
if (!visible && this._raf) {
cancelAnimationFrame(this._raf);
this._raf = null;
this._offscreen = true;
} else if (visible && this._offscreen) {
this._offscreen = false;
this._raf = requestAnimationFrame((t) => this.loop(t));
}
});
this._viewObserver.observe(this.$refs.root);
}
},
boot() {
const gl = this._canvas.getContext('webgl', { antialias: true, alpha: true });
if (!gl) return;
this._gl = gl;
const compile = (type, src) => {
const s = gl.createShader(type);
gl.shaderSource(s, src);
gl.compileShader(s);
return s;
};
const program = gl.createProgram();
gl.attachShader(program, compile(gl.VERTEX_SHADER, VERT));
gl.attachShader(program, compile(gl.FRAGMENT_SHADER, FRAG));
gl.linkProgram(program);
gl.useProgram(program);
this._program = program;
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]), gl.STATIC_DRAW);
const pos = gl.getAttribLocation(program, 'position');
gl.enableVertexAttribArray(pos);
gl.vertexAttribPointer(pos, 2, gl.FLOAT, false, 0, 0);
this._locs = {
res: gl.getUniformLocation(program, 'u_resolution'),
time: gl.getUniformLocation(program, 'u_time'),
bg: gl.getUniformLocation(program, 'u_colorBackground'),
stars: gl.getUniformLocation(program, 'u_colorStars'),
density: gl.getUniformLocation(program, 'u_density'),
speed: gl.getUniformLocation(program, 'u_speed'),
};
this.resize();
if (!this._ro) {
this._ro = new ResizeObserver(() => this.resize());
this._ro.observe(this.$refs.root);
}
if (this._reduced) {
this.draw(0);
return;
}
this._raf = requestAnimationFrame((t) => this.loop(t));
},
resize() {
const gl = this._gl;
if (!gl || gl.isContextLost()) return;
const root = this.$refs.root;
const canvas = this.$refs.canvas;
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.max(1, Math.floor(root.clientWidth * dpr));
canvas.height = Math.max(1, Math.floor(root.clientHeight * dpr));
gl.viewport(0, 0, canvas.width, canvas.height);
if (this._reduced) this.draw(0);
},
draw(timeMs) {
const gl = this._gl;
if (!gl || gl.isContextLost()) return;
const canvas = this.$refs.canvas;
const bg = toRgb(this.colorBackground, DEFAULTS.background);
const st = toRgb(this.colorStars, DEFAULTS.stars);
gl.uniform2f(this._locs.res, canvas.width, canvas.height);
gl.uniform1f(this._locs.time, timeMs * 0.001);
gl.uniform3f(this._locs.bg, bg[0], bg[1], bg[2]);
gl.uniform3f(this._locs.stars, st[0], st[1], st[2]);
gl.uniform1f(this._locs.density, this.density);
gl.uniform1f(this._locs.speed, this.speed);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
},
loop(timeMs) {
this.draw(timeMs);
this._raf = requestAnimationFrame((t) => this.loop(t));
},
destroy() {
if (this._viewObserver) { this._viewObserver.disconnect(); this._viewObserver = null; }
if (this._raf) cancelAnimationFrame(this._raf);
if (this._ro) this._ro.disconnect();
if (this._canvas) {
if (this._onLost) this._canvas.removeEventListener('webglcontextlost', this._onLost, false);
if (this._onRestored) 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);
gl.getExtension('WEBGL_lose_context')?.loseContext();
}
this._raf = null;
this._ro = null;
this._gl = this._program = this._buffer = this._locs = null;
},
}));
});
/**
* Luma Drift shader preset behaviour (WebGL aurora curtains).
*
* A full-bleed <canvas> renders soft aurora-like curtains: warped value-noise
* bands that drift and glow across the lower-middle of the frame, with a faint
* film grain. Pure WebGL on a screen-filling triangle strip; no library. The
* host is decorative (aria-hidden / pointer-events-none, set in Blade) so it
* never traps focus or blocks the content layered above it.
*
* The aurora palette is monochrome blue artwork baked into the shader, so there
* is no colour config — only `speed`.
*
* PERFORMANCE: device-pixel ratio is used as-is; the backing store is rebuilt
* only when a ResizeObserver fires. Reduced motion paints a single static frame
* and never starts the rAF loop. destroy() tears everything down so unmounting
* mid-animation cannot leak.
*
* 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', () => {
const VERT = `
attribute vec2 position;
varying vec2 vUv;
void main() {
gl_Position = vec4(position, 0.0, 1.0);
vUv = (position.xy + 1.0) * 0.5;
}`;
const FRAG = `
precision highp float;
uniform vec2 u_resolution;
uniform float u_time;
varying vec2 vUv;
vec2 hash(vec2 p) {
p = vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3)));
return -1.0 + 2.0 * fract(sin(p) * 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(dot(hash(i + vec2(0.0, 0.0)), f - vec2(0.0, 0.0)),
dot(hash(i + vec2(1.0, 0.0)), f - vec2(1.0, 0.0)), u.x),
mix(dot(hash(i + vec2(0.0, 1.0)), f - vec2(0.0, 1.0)),
dot(hash(i + vec2(1.0, 1.0)), f - vec2(1.0, 1.0)), u.x), u.y);
}
void main() {
vec2 uv = vUv;
float t = u_time * 0.0004;
float warp = noise(vec2(uv.x * 1.2 + t, t * 0.2));
vec2 uvWarped = uv;
uvWarped.x += warp * 0.1;
float n1 = noise(vec2(uvWarped.x * 2.0 + t * 0.5, uvWarped.y * 0.2 - t * 0.1));
float n2 = noise(vec2(uvWarped.x * 4.0 - t * 0.8, uvWarped.y * 0.5 + t * 0.2));
float aurora = abs(n1 + n2);
aurora = pow(1.0 - smoothstep(0.0, 0.4, aurora), 4.0);
float mask = smoothstep(0.0, 0.4, uv.y) * smoothstep(1.0, 0.5, uv.y);
aurora *= mask;
vec3 darkBlue = vec3(0.02, 0.04, 0.08);
vec3 oceanBlue = vec3(0.05, 0.15, 0.35);
vec3 skyBlue = vec3(0.1, 0.3, 0.6);
vec3 cyan = vec3(0.2, 0.6, 0.9);
vec3 brightGlow = vec3(0.4, 0.8, 1.0);
vec3 col = darkBlue;
col = mix(col, oceanBlue, aurora * 0.8);
col = mix(col, skyBlue, pow(aurora, 2.0));
col = mix(col, cyan, pow(aurora, 3.0));
col += brightGlow * pow(aurora, 5.0) * 0.5;
float grain = fract(sin(dot(uv, vec2(12.9898, 78.233))) * 43758.5453);
col += grain * 0.02;
gl_FragColor = vec4(col, 1.0);
}`;
window.Alpine.data('uiShaderLumaDrift', (config = {}) => ({
speed: Math.max(0, config.speed ?? 1),
_canvas: null,
_gl: null,
_program: null,
_buffer: null,
_locs: null,
_raf: null,
_ro: null,
_start: 0,
_reduced: false,
_onLost: null,
_onRestored: null,
_viewObserver: null,
_offscreen: false,
init() {
this._canvas = this.$refs.canvas;
this._reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
this._start = performance.now();
this._onLost = (e) => {
e.preventDefault();
if (this._raf) {
cancelAnimationFrame(this._raf);
this._raf = null;
}
};
this._onRestored = () => this.boot();
this._canvas.addEventListener('webglcontextlost', this._onLost, false);
this._canvas.addEventListener('webglcontextrestored', this._onRestored, false);
this.boot();
// Pause the rAF loop while fully scrolled out of view (same-tab;
// backgrounded tabs are already throttled by the browser) so
// off-screen instances don't burn GPU/CPU. Resume on re-entry.
// Static/reduced-motion renders never set _raf, so the `&& this._raf`
// guard leaves them untouched.
if (typeof IntersectionObserver !== 'undefined') {
this._viewObserver = new IntersectionObserver((entries) => {
const visible = entries.some((e) => e.isIntersecting);
if (!visible && this._raf) {
cancelAnimationFrame(this._raf);
this._raf = null;
this._offscreen = true;
} else if (visible && this._offscreen) {
this._offscreen = false;
this._raf = requestAnimationFrame((t) => this.loop(t));
}
});
this._viewObserver.observe(this.$refs.root);
}
},
boot() {
const gl = this._canvas.getContext('webgl') || this._canvas.getContext('experimental-webgl');
if (!gl) return;
this._gl = gl;
const compile = (type, src) => {
const s = gl.createShader(type);
gl.shaderSource(s, src);
gl.compileShader(s);
return s;
};
const program = gl.createProgram();
gl.attachShader(program, compile(gl.VERTEX_SHADER, VERT));
gl.attachShader(program, compile(gl.FRAGMENT_SHADER, FRAG));
gl.linkProgram(program);
gl.useProgram(program);
this._program = program;
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]), gl.STATIC_DRAW);
const pos = gl.getAttribLocation(program, 'position');
gl.enableVertexAttribArray(pos);
gl.vertexAttribPointer(pos, 2, gl.FLOAT, false, 0, 0);
this._locs = {
res: gl.getUniformLocation(program, 'u_resolution'),
time: gl.getUniformLocation(program, 'u_time'),
};
this.resize();
if (!this._ro) {
this._ro = new ResizeObserver(() => this.resize());
this._ro.observe(this.$refs.root);
}
if (this._reduced) {
this.draw(0);
return;
}
this._raf = requestAnimationFrame((t) => this.loop(t));
},
resize() {
const gl = this._gl;
if (!gl || gl.isContextLost()) return;
const root = this.$refs.root;
const canvas = this.$refs.canvas;
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.max(1, Math.floor(root.clientWidth * dpr));
canvas.height = Math.max(1, Math.floor(root.clientHeight * dpr));
gl.viewport(0, 0, canvas.width, canvas.height);
if (this._reduced) this.draw(0);
},
draw(elapsedMs) {
const gl = this._gl;
if (!gl || gl.isContextLost()) return;
const canvas = this.$refs.canvas;
gl.uniform2f(this._locs.res, canvas.width, canvas.height);
gl.uniform1f(this._locs.time, elapsedMs * this.speed);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
},
loop(now) {
this.draw(now - this._start);
this._raf = requestAnimationFrame((t) => this.loop(t));
},
destroy() {
if (this._viewObserver) { this._viewObserver.disconnect(); this._viewObserver = null; }
if (this._raf) cancelAnimationFrame(this._raf);
if (this._ro) this._ro.disconnect();
if (this._canvas) {
if (this._onLost) this._canvas.removeEventListener('webglcontextlost', this._onLost, false);
if (this._onRestored) 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);
gl.getExtension('WEBGL_lose_context')?.loseContext();
}
this._raf = null;
this._ro = null;
this._gl = this._program = this._buffer = this._locs = null;
},
}));
});
/**
* Velaris shader preset behaviour (WebGL layered simplex glow).
*
* A full-bleed <canvas> renders three layered simplex-noise fields blended
* across a four-stop palette over a base colour, with a radial glow, vignette
* and film grain — a slow, lava-lamp-like wash. Pure WebGL on a screen-filling
* triangle strip; no library. The host is decorative (aria-hidden /
* pointer-events-none, set in Blade) so it never traps focus or blocks the
* content layered above it.
*
* COLOUR: the base `bg` defaults to the theme's `--background` token and the
* four `colors` stops cycle `--primary` / `--accent`, resolved to WebGL rgb
* via `tokenRgb()` (a probe element + getComputedStyle, same technique as the
* `backdrop` gradient variant). A consumer may still override `bg`/`colors`
* with hex via config; the fixed lava-lamp-green floats below are only the
* fallback before tokens resolve.
*
* PERFORMANCE: device-pixel ratio is capped; the backing store is rebuilt only
* when a ResizeObserver fires. Reduced motion paints a single static frame and
* never starts the rAF loop. destroy() tears everything down so unmounting
* mid-animation cannot leak.
*
* 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', () => {
const VERT = `
attribute vec2 position;
varying vec2 vUv;
void main() {
vUv = position * 0.5 + 0.5;
gl_Position = vec4(position, 0.0, 1.0);
}`;
const FRAG = `
precision highp float;
varying vec2 vUv;
uniform vec2 u_resolution;
uniform float u_time;
uniform float u_grain;
uniform vec3 u_colors[4];
uniform vec3 u_bg;
vec3 permute(vec3 x) { return mod(((x*34.0)+1.0)*x, 289.0); }
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 = mod(i, 289.0);
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);
}
void main() {
vec2 uv = vUv;
float ratio = u_resolution.x / u_resolution.y;
vec2 p = uv - 0.5;
p.x *= ratio;
float t = u_time * 0.1;
float n1 = snoise(p * 0.4 + vec2(t * 0.2, -t * 0.3));
float n2 = snoise(p * 0.55 + vec2(-t * 0.15, t * 0.25) + n1 * 0.25);
float n3 = snoise(p * 0.75 + vec2(t * 0.1, -t * 0.2) + n2 * 0.2);
vec3 col = u_bg;
float dist = length(p) * 1.5;
float vignette = 1.0 - smoothstep(0.3, 1.2, dist);
col = mix(col, u_colors[0], smoothstep(-0.2, 0.5, n1) * 0.85);
col = mix(col, u_colors[1], smoothstep(-0.1, 0.6, n2) * 0.7);
col = mix(col, u_colors[2], smoothstep(-0.3, 0.4, n3) * 0.6);
col = mix(col, u_colors[3], smoothstep(0.0, 0.7, n1 * n2) * 0.5);
float glow = smoothstep(0.8, 0.0, dist) * 0.3;
col += u_colors[1] * glow;
col = mix(col * 0.2, col, vignette);
float grain = fract(sin(dot(uv, vec2(12.9898, 78.233))) * 43758.5453 + u_time);
col += (grain - 0.5) * u_grain * 0.1;
gl_FragColor = vec4(col, 1.0);
}`;
const hexToRgb = (hex) => {
const h = String(hex).replace('#', '');
return [
parseInt(h.slice(0, 2), 16) / 255,
parseInt(h.slice(2, 4), 16) / 255,
parseInt(h.slice(4, 6), 16) / 255,
];
};
// Reads a CSS custom property off the document root and resolves it to a
// normalised [r,g,b] (0..1) triple via a probe element (so oklch/hsl
// tokens collapse to concrete rgb WebGL can use), as the backdrop WebGL
// variants do. Falls back to a fixed tone when the property is unset or
// unresolvable.
const tokenRgb = (name, fallback) => {
const raw = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
if (!raw) return fallback;
const probe = document.createElement('span');
probe.style.display = 'none';
document.body.appendChild(probe);
probe.style.color = raw.includes('(') || raw.startsWith('#') || /[a-z]/i.test(raw) ? raw : `oklch(${raw})`;
const resolved = getComputedStyle(probe).color;
probe.remove();
const m = resolved.match(/-?\d*\.?\d+/g);
return m && m.length >= 3 ? [Number(m[0]) / 255, Number(m[1]) / 255, Number(m[2]) / 255] : fallback;
};
// asRgb normalises either a hex string (a consumer override) or an
// already-resolved [r,g,b] triple (a token default) to floats.
const asRgb = (c) => (Array.isArray(c) ? c : hexToRgb(c));
// Glow stops derived from the theme (cycling primary/accent); fixed
// lava-lamp-green floats are the fallback before tokens resolve.
const DEFAULT_COLORS = [
tokenRgb('--primary', [0.525, 0.937, 0.675]),
tokenRgb('--accent', [0.290, 0.871, 0.502]),
tokenRgb('--primary', [0.020, 0.588, 0.412]),
tokenRgb('--accent', [0, 0, 0]),
];
const DEFAULT_BG = tokenRgb('--background', [0, 0, 0]);
window.Alpine.data('uiShaderVelaris', (config = {}) => ({
bg: typeof config.bg === 'string' ? config.bg : DEFAULT_BG,
colors:
Array.isArray(config.colors) && config.colors.length >= 4
? config.colors.slice(0, 4)
: DEFAULT_COLORS,
speed: Math.max(0, config.speed ?? 2),
grain: Math.max(0, config.grain ?? 0.3),
_canvas: null,
_gl: null,
_program: null,
_buffer: null,
_locs: null,
_raf: null,
_ro: null,
_reduced: false,
_onLost: null,
_onRestored: null,
_viewObserver: null,
_offscreen: false,
init() {
this._canvas = this.$refs.canvas;
this._reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
this._onLost = (e) => {
e.preventDefault();
if (this._raf) {
cancelAnimationFrame(this._raf);
this._raf = null;
}
};
this._onRestored = () => this.boot();
this._canvas.addEventListener('webglcontextlost', this._onLost, false);
this._canvas.addEventListener('webglcontextrestored', this._onRestored, false);
this.boot();
// Pause the rAF loop while fully scrolled out of view (same-tab;
// backgrounded tabs are already throttled by the browser) so
// off-screen instances don't burn GPU/CPU. Resume on re-entry.
// Static/reduced-motion renders never set _raf, so the `&& this._raf`
// guard leaves them untouched.
if (typeof IntersectionObserver !== 'undefined') {
this._viewObserver = new IntersectionObserver((entries) => {
const visible = entries.some((e) => e.isIntersecting);
if (!visible && this._raf) {
cancelAnimationFrame(this._raf);
this._raf = null;
this._offscreen = true;
} else if (visible && this._offscreen) {
this._offscreen = false;
this._raf = requestAnimationFrame((t) => this.loop(t));
}
});
this._viewObserver.observe(this.$refs.root);
}
},
boot() {
const gl = this._canvas.getContext('webgl');
if (!gl) return;
this._gl = gl;
const shader = (type, src) => {
const s = gl.createShader(type);
gl.shaderSource(s, src);
gl.compileShader(s);
return s;
};
const program = gl.createProgram();
gl.attachShader(program, shader(gl.VERTEX_SHADER, VERT));
gl.attachShader(program, shader(gl.FRAGMENT_SHADER, FRAG));
gl.linkProgram(program);
gl.useProgram(program);
this._program = program;
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]), gl.STATIC_DRAW);
const pos = gl.getAttribLocation(program, 'position');
gl.enableVertexAttribArray(pos);
gl.vertexAttribPointer(pos, 2, gl.FLOAT, false, 0, 0);
this._locs = {
res: gl.getUniformLocation(program, 'u_resolution'),
time: gl.getUniformLocation(program, 'u_time'),
grain: gl.getUniformLocation(program, 'u_grain'),
colors: gl.getUniformLocation(program, 'u_colors'),
bg: gl.getUniformLocation(program, 'u_bg'),
};
this.resize();
if (!this._ro) {
this._ro = new ResizeObserver(() => this.resize());
this._ro.observe(this.$refs.root);
}
if (this._reduced) {
this.draw(0);
return;
}
this._raf = requestAnimationFrame((t) => this.loop(t));
},
resize() {
const gl = this._gl;
if (!gl || gl.isContextLost()) return;
const root = this.$refs.root;
const canvas = this.$refs.canvas;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.max(1, Math.floor(root.clientWidth * dpr));
canvas.height = Math.max(1, Math.floor(root.clientHeight * dpr));
gl.viewport(0, 0, canvas.width, canvas.height);
if (this._reduced) this.draw(0);
},
draw(timeMs) {
const gl = this._gl;
if (!gl || gl.isContextLost()) return;
const canvas = this.$refs.canvas;
gl.uniform2f(this._locs.res, canvas.width, canvas.height);
gl.uniform1f(this._locs.time, timeMs * 0.001 * this.speed);
gl.uniform1f(this._locs.grain, this.grain);
const bg = asRgb(this.bg);
gl.uniform3f(this._locs.bg, bg[0], bg[1], bg[2]);
gl.uniform3fv(this._locs.colors, new Float32Array(this.colors.flatMap(asRgb)));
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
},
loop(timeMs) {
this.draw(timeMs);
this._raf = requestAnimationFrame((t) => this.loop(t));
},
destroy() {
if (this._viewObserver) { this._viewObserver.disconnect(); this._viewObserver = null; }
if (this._raf) cancelAnimationFrame(this._raf);
if (this._ro) this._ro.disconnect();
if (this._canvas) {
if (this._onLost) this._canvas.removeEventListener('webglcontextlost', this._onLost, false);
if (this._onRestored) 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);
gl.getExtension('WEBGL_lose_context')?.loseContext();
}
this._raf = null;
this._ro = null;
this._gl = this._program = this._buffer = this._locs = null;
},
}));
});
/**
* Velora shader preset behaviour (WebGL warp-tunnel streaks).
*
* A full-bleed <canvas> renders a hyperspace-style tunnel: 3D fractal-noise
* streaks projected through a twisting cylinder toward the centre, graded across
* three colour stops with centre/edge fades and film grain. Pure WebGL on a
* screen-filling triangle strip; no library. The host is decorative (aria-hidden
* / pointer-events-none, set in Blade) so it never traps focus or blocks the
* content layered above it.
*
* COLOUR: the three stops default to the theme's `--background` / `--primary` /
* `--accent` tokens (bottom = background, mid = primary, top = accent),
* resolved to WebGL rgb via `tokenRgb()` (a probe element + getComputedStyle,
* same technique as the `backdrop` gradient variant). A consumer may still
* override any stop with hex via `colorBottom`/`colorMid`/`colorTop`; the
* fixed indigo floats below are only the fallback before tokens resolve.
*
* PERFORMANCE: device-pixel ratio is capped; the backing store is rebuilt only
* when a ResizeObserver fires. Reduced motion paints a single static frame and
* never starts the rAF loop. destroy() tears everything down so unmounting
* mid-animation cannot leak.
*
* 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', () => {
const VERT = `
attribute vec2 position;
varying vec2 vUv;
void main() {
vUv = (position + 1.0) * 0.5;
gl_Position = vec4(position, 0.0, 1.0);
}`;
const FRAG = `
precision highp float;
uniform vec2 u_resolution;
uniform float u_time;
uniform vec3 u_colorBottom;
uniform vec3 u_colorMid;
uniform vec3 u_colorTop;
uniform float u_flowStrength;
uniform float u_grain;
uniform float u_speed;
varying vec2 vUv;
float hash(vec3 p) {
return fract(sin(dot(p, vec3(127.1, 311.7, 74.7))) * 43758.5453123);
}
float noise(vec3 p) {
vec3 i = floor(p);
vec3 f = fract(p);
vec3 u = f * f * (3.0 - 2.0 * f);
return mix(
mix(mix(hash(i + vec3(0.0, 0.0, 0.0)), hash(i + vec3(1.0, 0.0, 0.0)), u.x),
mix(hash(i + vec3(0.0, 1.0, 0.0)), hash(i + vec3(1.0, 1.0, 0.0)), u.x), u.y),
mix(mix(hash(i + vec3(0.0, 0.0, 1.0)), hash(i + vec3(1.0, 0.0, 1.0)), u.x),
mix(hash(i + vec3(0.0, 1.0, 1.0)), hash(i + vec3(1.0, 1.0, 1.0)), u.x), u.y), u.z
);
}
float fbm(vec3 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 = (vUv - 0.5) * vec2(u_resolution.x / u_resolution.y, 1.0);
float radius = length(uv);
float angle = atan(uv.y, uv.x);
float z = 1.0 / max(radius, 0.001);
float t = u_time * u_speed * 5.0;
float streakScale = 5.0 * u_flowStrength;
vec3 cylPos = vec3(
cos(angle) * streakScale,
sin(angle) * streakScale,
(z - t) * 0.2
);
float twist = sin(t * 0.1 + z * 0.05) * 2.5;
float c = cos(twist);
float s = sin(twist);
cylPos.xy = mat2(c, -s, s, c) * cylPos.xy;
float n = fbm(cylPos);
float streak = smoothstep(0.4, 0.8, n);
vec3 color = mix(u_colorBottom, u_colorMid, smoothstep(0.3, 0.6, n));
color = mix(color, u_colorTop, smoothstep(0.6, 1.0, n));
float centerFade = smoothstep(0.0, 0.25, radius);
float edgeFade = smoothstep(1.5, 0.4, radius);
vec3 finalColor = color * streak * centerFade * edgeFade * 3.0;
finalColor += (fract(sin(dot(vUv + u_time, vec2(12.9898, 78.233))) * 43758.5453) - 0.5) * u_grain;
gl_FragColor = vec4(clamp(finalColor, 0.0, 1.0), 1.0);
}`;
const HEX = /^#?[0-9a-fA-F]{6}$/;
// Reads a CSS custom property off the document root and resolves it to a
// normalised [r,g,b] (0..1) triple via a probe element (so oklch/hsl
// tokens collapse to concrete rgb WebGL can use), as the backdrop WebGL
// variants do. Falls back to a fixed tone when the property is unset or
// unresolvable.
const tokenRgb = (name, fallback) => {
const raw = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
if (!raw) return fallback;
const probe = document.createElement('span');
probe.style.display = 'none';
document.body.appendChild(probe);
probe.style.color = raw.includes('(') || raw.startsWith('#') || /[a-z]/i.test(raw) ? raw : `oklch(${raw})`;
const resolved = getComputedStyle(probe).color;
probe.remove();
const m = resolved.match(/-?\d*\.?\d+/g);
return m && m.length >= 3 ? [Number(m[0]) / 255, Number(m[1]) / 255, Number(m[2]) / 255] : fallback;
};
// A hex override resolves via straight hex parsing; anything else (the
// token-derived default, an [r,g,b] triple already) passes through.
const toRgb = (value, fallback) => {
if (typeof value === 'string' && HEX.test(value.trim())) {
const h = value.trim().replace('#', '');
return [
parseInt(h.slice(0, 2), 16) / 255,
parseInt(h.slice(2, 4), 16) / 255,
parseInt(h.slice(4, 6), 16) / 255,
];
}
return fallback;
};
// Tunnel tones derived from the theme (bottom = background, mid =
// primary, top = accent); fixed indigo floats are the fallback before
// tokens resolve.
const DEFAULTS = {
bottom: tokenRgb('--background', [0.016, 0.020, 0.043]),
mid: tokenRgb('--primary', [0.263, 0.220, 0.792]),
top: tokenRgb('--accent', [0.647, 0.706, 0.980]),
};
window.Alpine.data('uiShaderVelora', (config = {}) => ({
colorBottom: config.colorBottom ?? DEFAULTS.bottom,
colorMid: config.colorMid ?? DEFAULTS.mid,
colorTop: config.colorTop ?? DEFAULTS.top,
flowStrength: config.flowStrength ?? 1,
grain: Math.max(0, config.grain ?? 0.04),
speed: Math.max(0, config.speed ?? 0.5),
_canvas: null,
_gl: null,
_program: null,
_buffer: null,
_locs: null,
_raf: null,
_ro: null,
_start: 0,
_reduced: false,
_onLost: null,
_onRestored: null,
_viewObserver: null,
_offscreen: false,
init() {
this._canvas = this.$refs.canvas;
this._reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
this._start = performance.now();
this._onLost = (e) => {
e.preventDefault();
if (this._raf) {
cancelAnimationFrame(this._raf);
this._raf = null;
}
};
this._onRestored = () => this.boot();
this._canvas.addEventListener('webglcontextlost', this._onLost, false);
this._canvas.addEventListener('webglcontextrestored', this._onRestored, false);
this.boot();
// Pause the rAF loop while fully scrolled out of view (same-tab;
// backgrounded tabs are already throttled by the browser) so
// off-screen instances don't burn GPU/CPU. Resume on re-entry.
// Static/reduced-motion renders never set _raf, so the `&& this._raf`
// guard leaves them untouched.
if (typeof IntersectionObserver !== 'undefined') {
this._viewObserver = new IntersectionObserver((entries) => {
const visible = entries.some((e) => e.isIntersecting);
if (!visible && this._raf) {
cancelAnimationFrame(this._raf);
this._raf = null;
this._offscreen = true;
} else if (visible && this._offscreen) {
this._offscreen = false;
this._raf = requestAnimationFrame((t) => this.loop(t));
}
});
this._viewObserver.observe(this.$refs.root);
}
},
boot() {
const gl = this._canvas.getContext('webgl', { antialias: true });
if (!gl) return;
this._gl = gl;
const compile = (type, src) => {
const s = gl.createShader(type);
gl.shaderSource(s, src);
gl.compileShader(s);
return s;
};
const program = gl.createProgram();
gl.attachShader(program, compile(gl.VERTEX_SHADER, VERT));
gl.attachShader(program, compile(gl.FRAGMENT_SHADER, FRAG));
gl.linkProgram(program);
gl.useProgram(program);
this._program = program;
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]), gl.STATIC_DRAW);
const pos = gl.getAttribLocation(program, 'position');
gl.enableVertexAttribArray(pos);
gl.vertexAttribPointer(pos, 2, gl.FLOAT, false, 0, 0);
this._locs = {
res: gl.getUniformLocation(program, 'u_resolution'),
time: gl.getUniformLocation(program, 'u_time'),
bottom: gl.getUniformLocation(program, 'u_colorBottom'),
mid: gl.getUniformLocation(program, 'u_colorMid'),
top: gl.getUniformLocation(program, 'u_colorTop'),
flow: gl.getUniformLocation(program, 'u_flowStrength'),
grain: gl.getUniformLocation(program, 'u_grain'),
speed: gl.getUniformLocation(program, 'u_speed'),
};
this.resize();
if (!this._ro) {
this._ro = new ResizeObserver(() => this.resize());
this._ro.observe(this.$refs.root);
}
if (this._reduced) {
this.draw(0);
return;
}
this._raf = requestAnimationFrame((t) => this.loop(t));
},
resize() {
const gl = this._gl;
if (!gl || gl.isContextLost()) return;
const root = this.$refs.root;
const canvas = this.$refs.canvas;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.max(1, Math.floor(root.clientWidth * dpr));
canvas.height = Math.max(1, Math.floor(root.clientHeight * dpr));
gl.viewport(0, 0, canvas.width, canvas.height);
gl.uniform2f(this._locs.res, canvas.width, canvas.height);
if (this._reduced) this.draw(0);
},
draw(elapsedMs) {
const gl = this._gl;
if (!gl || gl.isContextLost()) return;
const cb = toRgb(this.colorBottom, DEFAULTS.bottom);
const cm = toRgb(this.colorMid, DEFAULTS.mid);
const ct = toRgb(this.colorTop, DEFAULTS.top);
gl.uniform1f(this._locs.time, elapsedMs / 1000);
gl.uniform3f(this._locs.bottom, cb[0], cb[1], cb[2]);
gl.uniform3f(this._locs.mid, cm[0], cm[1], cm[2]);
gl.uniform3f(this._locs.top, ct[0], ct[1], ct[2]);
gl.uniform1f(this._locs.flow, this.flowStrength);
gl.uniform1f(this._locs.grain, this.grain);
gl.uniform1f(this._locs.speed, this.speed);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
},
loop(now) {
this.draw(now - this._start);
this._raf = requestAnimationFrame((t) => this.loop(t));
},
destroy() {
if (this._viewObserver) { this._viewObserver.disconnect(); this._viewObserver = null; }
if (this._raf) cancelAnimationFrame(this._raf);
if (this._ro) this._ro.disconnect();
if (this._canvas) {
if (this._onLost) this._canvas.removeEventListener('webglcontextlost', this._onLost, false);
if (this._onRestored) 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);
gl.getExtension('WEBGL_lose_context')?.loseContext();
}
this._raf = null;
this._ro = null;
this._gl = this._program = this._buffer = this._locs = null;
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.0.1 - 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