Light Rays
An ambient WebGL background that renders slow, animated volumetric god-rays sweeping from a focal point. Ray colour derives from the --primary semantic token at runtime, so it follows the active theme. Decorative, aria-hidden, pointer-events-none; reduced motion paints a single static frame.
Preview
Light Rays
<div class="relative h-56 w-full max-w-md overflow-hidden rounded-lg border border-border bg-background">
<x-ui.light-rays />
<div class="relative flex h-full items-end p-5">
<p class="text-sm font-medium text-foreground">Light Rays</p>
</div>
</div>
Installation
php artisan ui:add light-rays
Note
This component ships an Alpine behavior module at
resources/js/ui/light-rays.js. Import it once from your bundle so it registers on alpine:init:
import './light-rays.js';
Registry contract
php artisan ui:add light-rays
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/light-rays.blade.php -
resources/js/ui/light-rays.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: Light Rays (`light-rays`)
An ambient WebGL background that renders slow, animated volumetric god-rays sweeping from a focal point. Ray colour derives from the --primary semantic token at runtime, so it follows the active theme. Decorative, aria-hidden, pointer-events-none; reduced motion paints 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 light-rays
```
## Usage
```blade
<div class="relative h-56 w-full max-w-md overflow-hidden rounded-lg border border-border bg-background">
<x-ui.light-rays />
<div class="relative flex h-full items-end p-5">
<p class="text-sm font-medium text-foreground">Light Rays</p>
</div>
</div>
```
## Props
- `rayCount` (int, default `24`) — Number of rays radiating from the focal point.
- `intensity` (float, default `0.6`) — Overall brightness of the rays (0-1).
- `reach` (float, default `1.0`) — How far the rays reach across the box before their radial falloff fades them out.
- `position` (int, default `50`) — Horizontal position of the focal point, as a percentage (0-100).
- `speed` (float, default `1.0`) — Sweep speed of the rays; used only when animate is true.
- `animate` (bool, default `true`) — Runs the render loop when true; false paints one static frame.
- `color` (mixed|null, default `null`) — Explicit ray colour override; when null it's derived at runtime from the --primary token.
## Use when
- Use only when visual expression supports storytelling, brand differentiation, or a clear showcase moment.
- Adding a dramatic, cinematic WebGL light-beam backdrop behind a hero.
- Wanting rays whose colour tracks the --primary token so they follow the active theme automatically.
## Avoid when
- Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
- Wanting a lighter-weight effect without WebGL; use dot-pattern, grid-pattern or noise instead.
## Anti-patterns
- Heavy effects in forms or task-dense workflows
- Motion that interferes with reading or focus
## Rules
- Use the `<brok:light-rays>` tag (or `<x-ui.light-rays>`) 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/light-rays
- Registry JSON (files, props, contract): https://brokui.dev/r/open/light-rays.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 |
|---|---|---|---|
| rayCount | int | 24 | Number of rays radiating from the focal point. |
| intensity | float | 0.6 | Overall brightness of the rays (0-1). |
| reach | float | 1.0 | How far the rays reach across the box before their radial falloff fades them out. |
| position | int | 50 | Horizontal position of the focal point, as a percentage (0-100). |
| speed | float | 1.0 | Sweep speed of the rays; used only when animate is true. |
| animate | bool | true | Runs the render loop when true; false paints one static frame. |
| color | mixed | null | null | Explicit ray colour override; when null it's derived at runtime from the --primary token. |
Slots
default— Not used by this component; present for attribute-bag compatibility.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Renders a single fullscreen WebGL fragment shader that sums rotating sine bands around a focal point and fades them with a radial falloff; rayCount, intensity, reach, position and speed all feed shader uniforms.
- Gracefully degrades to an empty decorative box if WebGL is unavailable, and pauses its render loop via IntersectionObserver while scrolled out of view.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized, 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 dramatic, cinematic WebGL light-beam backdrop behind a hero.
- Wanting rays whose colour tracks the --primary token so they follow the active theme automatically.
Avoid when
- Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
- Wanting a lighter-weight effect without WebGL; use dot-pattern, grid-pattern or noise 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 host is aria-hidden and pointer-events-none.
- Reduced motion (or animate=false) paints a single static frame instead of animating.
- 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-56 w-full max-w-md overflow-hidden rounded-lg border border-border bg-background">
<x-ui.light-rays />
<div class="relative flex h-full items-end p-5">
<p class="text-sm font-medium text-foreground">Light Rays</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.
@props([
// Number of rays radiating from the focal point.
'rayCount' => 24,
// Overall brightness of the rays (0..1).
'intensity' => 0.6,
// How far the rays reach across the box before fading (radial falloff).
'reach' => 1.0,
// Horizontal position of the focal point as a percentage (0..100).
'position' => 50,
// Sweep speed (higher = faster). Used only when animate is true.
'speed' => 1.0,
// When false, paints a single static frame and never starts the rAF loop.
'animate' => true,
// Optional ray colour override. Any CSS color string. When null the colour is
// derived at runtime from the --primary semantic token off the root element,
// so the rays follow the active theme. Passing a value puts it ONLY into the
// Alpine JS config below — never into a style attribute — so default markup
// stays free of raw colour.
'color' => null,
])
@php
$clean = fn ($c) => is_string($c) && trim($c) !== '' ? trim($c) : null;
$config = [
'rayCount' => max(1, (int) $rayCount),
'intensity' => max(0, min(1, (float) $intensity)),
'reach' => max(0.01, (float) $reach),
'position' => max(0, min(100, (float) $position)),
'speed' => max(0, (float) $speed),
'animate' => (bool) $animate,
// null => behaviour layer derives the colour from the --primary token.
'color' => $clean($color),
];
@endphp
{{-- Light Rays: a decorative WebGL backdrop that fills its positioned parent with
slow volumetric god-rays emanating from a focal point. The host is aria-hidden
and pointer-events-none so it never traps focus or blocks content layered
above it. The ray colour defaults to the --primary semantic token (the
text-primary class documents and anchors that token), so the effect follows
the active theme; no raw colour ever appears in markup. Reduced motion (or
animate=false) renders one static frame with no animation loop. --}}
<div
data-slot="light-rays"
x-data="uiLightRays(@js($config))"
x-ref="root"
aria-hidden="true"
{{ $attributes->merge(['class' => 'pointer-events-none absolute inset-0 overflow-hidden bg-background text-primary']) }}
>
<canvas x-ref="canvas" class="block h-full w-full"></canvas>
</div>
/**
* Light Rays behaviour (WebGL-powered volumetric god-rays background).
*
* A single full-bleed <canvas> renders animated crepuscular "light rays"
* emanating from a focal point. The effect is a single fullscreen WebGL pass
* (vertex + fragment shader on a screen-filling triangle strip); no library. For
* each pixel the fragment shader computes the angle to the focal point `u_pos`,
* sums a few sinusoidal bands across that angle that scroll slowly with
* `u_time*u_speed` to form `u_count` soft rays, multiplies by a radial falloff
* (`u_reach`) and `u_intensity`, and outputs `u_color * brightness` with alpha so
* it composites over the content layered above it. The host is decorative
* (aria-hidden / pointer-events-none, set in Blade) so it never traps focus.
*
* COLOUR is SEMANTIC: when no `color` is configured we derive the ray colour at
* runtime from the root's `--primary` token (falling back to the computed `color`
* / `--foreground`), so the rays follow the active theme rather than any hard-
* coded value. The resolved colour is fed to the shader as a normalized vec3
* float uniform (`u_color`) — the GLSL itself carries no colour literals.
*
* PERFORMANCE: device-pixel ratio is capped and the backing store is rebuilt only
* when a ResizeObserver fires. Reduced motion (or animate=false) paints a single
* static frame and never starts the rAF loop. destroy() cancels the loop,
* disconnects the observer and loses the GL context 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);
}`;
// Fullscreen ray field. Angle to the focal point drives a sum of sine bands
// that scroll over time → soft rotating rays; a radial falloff fades them out
// with distance. Colour and all tunables arrive as float uniforms only.
const FRAG = `
precision highp float;
varying vec2 vUv;
uniform vec2 u_resolution;
uniform float u_time;
uniform vec2 u_pos;
uniform float u_count;
uniform float u_intensity;
uniform float u_reach;
uniform float u_speed;
uniform vec3 u_color;
void main() {
// Aspect-correct the coordinate space so rays stay angularly even.
float ratio = u_resolution.x / max(u_resolution.y, 1.0);
vec2 uv = vUv;
vec2 d = vec2((uv.x - u_pos.x) * ratio, uv.y - u_pos.y);
float dist = length(d);
float angle = atan(d.y, d.x);
float t = u_time * u_speed;
// Primary ray band: u_count soft lobes around the focal point that slowly
// rotate. Layer a second, faster, finer band for a volumetric shimmer.
float a1 = sin(angle * u_count + t);
float a2 = sin(angle * (u_count * 2.0) - t * 0.6 + 1.7);
float rays = a1 * 0.6 + a2 * 0.4;
rays = rays * 0.5 + 0.5; // 0..1
rays = pow(rays, 3.0); // sharpen into beams
// Radial falloff: bright near the focal point, fading out by u_reach. Add a
// soft core glow so the origin reads as a light source.
float falloff = 1.0 - smoothstep(0.0, u_reach, dist);
float core = exp(-dist * 6.0 / max(u_reach, 0.01)) * 0.6;
float brightness = (rays * falloff + core) * u_intensity;
brightness = clamp(brightness, 0.0, 1.0);
// Premultiplied-style output so it composites cleanly over content below.
gl_FragColor = vec4(u_color * brightness, brightness);
}`;
window.Alpine.data('uiLightRays', (config = {}) => ({
rayCount: Math.max(1, Math.floor(config.rayCount ?? 24)),
intensity: Math.max(0, Math.min(1, config.intensity ?? 0.6)),
reach: Math.max(0.01, config.reach ?? 1.0),
position: Math.max(0, Math.min(100, config.position ?? 50)),
speed: Math.max(0, config.speed ?? 1.0),
animate: config.animate !== false,
color: typeof config.color === 'string' && config.color.trim() !== '' ? config.color.trim() : null,
_canvas: null,
_gl: null,
_program: null,
_buffer: null,
_locs: null,
_raf: null,
_ro: null,
_reduced: false,
_t0: 0,
_rgb: [1, 1, 1], // resolved ray colour, normalized 0..1
_onLost: null,
_onRestored: null,
_viewObserver: null,
_offscreen: false,
init() {
this._canvas = this.$refs.canvas;
this._reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
this._t0 = performance.now();
// 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() {
// Premultiplied alpha so the rays blend additively over content.
const gl = this._canvas.getContext('webgl', { antialias: true, alpha: true, premultipliedAlpha: true });
if (!gl) return; // graceful degrade: host stays an empty decorative box
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;
gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
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'),
pos: gl.getUniformLocation(program, 'u_pos'),
count: gl.getUniformLocation(program, 'u_count'),
intensity: gl.getUniformLocation(program, 'u_intensity'),
reach: gl.getUniformLocation(program, 'u_reach'),
speed: gl.getUniformLocation(program, 'u_speed'),
color: gl.getUniformLocation(program, 'u_color'),
};
this.resolveColor();
this.resize();
if (!this._ro) {
this._ro = new ResizeObserver(() => this.resize());
this._ro.observe(this.$refs.root);
}
if (this._reduced || !this.animate) {
this.draw(0);
return;
}
this._raf = requestAnimationFrame((t) => this.loop(t));
},
// Resolve a CSS color string from a token into a usable value. Tries the
// raw value first, then common wrappers (hsl / oklch / rgb) for bare
// channel lists, mirroring how the design tokens are stored.
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 = `hsl(${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;
},
// Parse any resolved CSS color string into normalized [r,g,b] (0..1) via a
// probe element's computed color (always comes back as rgb()).
toRgb(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) return [1, 1, 1];
return [Number(m[0]) / 255, Number(m[1]) / 255, Number(m[2]) / 255];
},
// Ray colour: explicit config override, else derived from the --primary
// semantic token (falling back to the root's computed color / --foreground)
// so the rays follow the active theme.
resolveColor() {
const root = this.$refs.root;
const current = getComputedStyle(root).color || 'currentColor';
const primary = this.resolveToken('--primary', root) || this.resolveToken('--foreground', root) || current;
const chosen = this.color || primary;
this._rgb = this.toRgb(chosen);
},
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.animate) this.draw(0);
},
draw(timeMs) {
const gl = this._gl;
if (!gl || gl.isContextLost()) return;
const canvas = this.$refs.canvas;
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.uniform2f(this._locs.res, canvas.width, canvas.height);
gl.uniform1f(this._locs.time, timeMs * 0.001);
// Focal point: configured X (0..100 → 0..1), vertically centred.
gl.uniform2f(this._locs.pos, this.position / 100, 0.5);
gl.uniform1f(this._locs.count, this.rayCount);
gl.uniform1f(this._locs.intensity, this.intensity);
gl.uniform1f(this._locs.reach, this.reach);
gl.uniform1f(this._locs.speed, this.speed);
gl.uniform3fv(this._locs.color, new Float32Array(this._rgb));
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
},
loop(timeMs) {
this.draw(timeMs - this._t0);
this._raf = requestAnimationFrame((t) => this.loop(t));
},
destroy() {
if (this._viewObserver) { this._viewObserver.disconnect(); this._viewObserver = null; }
if (this._raf) {
cancelAnimationFrame(this._raf);
this._raf = null;
}
if (this._ro) {
this._ro.disconnect();
this._ro = null;
}
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._gl = null;
this._program = null;
this._buffer = null;
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.1.3 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0