Skip to content
Brok UI

Loading…

No results

Light Rays

Open source

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.

Version
v1.1.3
Stability
stable
License
MIT
Related
Dot Pattern
Grid Pattern
Noise

Preview

Light Rays

previews.components.light-rays.default.blade.php 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>

Installation

terminal
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:

resources/js/ui/index.js JS
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.

  • blade resources/views/components/ui/light-rays.blade.php
  • js resources/js/ui/light-rays.js
Registry dependencies
None — installs on its own.
Packages
composer: jml/brok:^0.2
npm: alpinejs

Use with AI

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

light-rays.md
# 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

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
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.

light-rays

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

Expressive and showcase effect

Support brand expression or a showcase moment.

Use when

  • Use only when visual expression supports storytelling, brand differentiation, or a clear showcase moment.
  • Adding a 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
root
Theming hooks
light-rays

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, so keyboard focus is always visible.
  • Disabled and invalid states are conveyed to assistive tech, not by color alone.
  • Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
  • Labels go through __() and layout uses logical properties (ms-*, text-start), so it mirrors under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:ignore

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

livewire-component.blade.php Blade
<div wire:ignore>
    <div class="relative h-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.

resources/views/components/ui/light-rays.blade.php Blade
@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>
resources/js/ui/light-rays.js JS
/**
 * 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