backgrounds
Light Beams
Soft volumetric god-ray shafts that sweep slowly across the layer for a living-light ambient effect. Each beam is a tall CSS gradient pillar (--primary / --accent) blurred into a glowing shaft, independently angled 12–22°. Beams drift laterally and pulse opacity on independent cycles so the pattern never visually repeats. Geometry randomised post-mount for hydration safety; IntersectionObserver pauses the sweep offscreen. Purely decorative — position:absolute; inset:0; pointer-events:none; aria-hidden.
light-beams
Install
1. Register the namespace (once per project):
json
// components.json — register the @bottega namespace once
{
"registries": {
"@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
}
}2. Add the component:
bash
npx shadcn add @bottega/light-beamsUsage
Usagetsx
<LightBeams />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| count | number | 5 | Number of light shafts rendered simultaneously. Default 5. |
| speed | number | 10 | Sweep cycle duration in seconds — longer is more languid. Default 10. |
| intensity | number | 0.6 | Peak beam opacity (0–1 scale factor). Default 0.6. |
Source
light-beams.tsxtsx
"use client";
import { useEffect, useRef, useState } from "react";
import { useReducedMotion } from "motion/react";
import type React from "react";
import styles from "./light-beams.module.css";
export interface LightBeamsProps extends React.HTMLAttributes<HTMLDivElement> {
/** Number of light shafts rendered simultaneously. Default 5. */
count?: number;
/** Sweep cycle duration in seconds — longer is more languid. Default 10. */
speed?: number;
/** Peak beam opacity (0–1 scale factor). Default 0.6. */
intensity?: number;
}
interface Beam {
left: string;
width: string;
height: string;
angle: string;
duration: string;
delay: string;
opacityMax: number;
opacityMin: number;
travel: string;
useAccent: boolean;
}
/**
* Volumetric light-beams (god-ray) background layer.
*
* Each beam is a blurred CSS gradient pillar (--primary / --accent), angled
* 12–22° and set on an independent sweep cycle that drifts laterally while
* pulsing opacity. The spread of random durations/delays means no two beams
* peak in sync, giving a living-light impression that never visually repeats.
*
* Geometry randomised post-mount (hydration-safe). IntersectionObserver pauses
* the sweep offscreen. Reduced-motion users see three static shafts.
*
* Decorative: root is position:absolute; inset:0; pointer-events:none; aria-hidden.
* Place sibling content at z-10 above this z-0 backdrop.
*/
export function LightBeams({
count = 5,
speed = 10,
intensity = 0.6,
className = "",
style,
"aria-hidden": ariaHidden = true,
...props
}: LightBeamsProps) {
// Contract mount-gate + null-safe reduce check (avoids React #418 + flash).
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const prefersReducedMotion = useReducedMotion();
const reduce = prefersReducedMotion === null || prefersReducedMotion; // null → reduced
const animate = mounted && !reduce;
const layerRef = useRef<HTMLDivElement>(null);
const [beams, setBeams] = useState<Beam[]>([]);
const [running, setRunning] = useState(true);
// Generate randomised beam geometry only after mount (hydration-safe).
useEffect(() => {
if (!animate) {
setBeams([]);
return;
}
const baseSpeed = Math.max(speed, 0.5); // guard against 0 speed
const next: Beam[] = Array.from({ length: count }, (_, i) => {
// Even spread across 8–88% of width, plus a small random jitter.
const spreadBase = count > 1 ? (i / (count - 1)) * 80 + 8 : 48;
const left = spreadBase + (Math.random() * 14 - 7);
// Shaft width 50–130px; taller shafts to ensure full vertical coverage.
const width = Math.round(Math.random() * 80 + 50);
const height = Math.round(Math.random() * 40 + 160); // 160–200%
// Tilt angle 12–22°; slight per-beam variation gives the field depth.
const angle = Math.random() * 10 + 12;
// ±20% duration spread so beams cycle at different rates → never in sync.
const dur = baseSpeed * (0.8 + Math.random() * 0.4);
// Negative delay starts beams mid-cycle so frame 0 is populated (not all dim).
const delay = -(Math.random() * baseSpeed);
// opacityMax: 60–100% of intensity; opacityMin: 30–45% of opacityMax.
const opMax = Math.min(intensity * (0.6 + Math.random() * 0.4), 1);
const opMin = opMax * (0.3 + Math.random() * 0.15);
// vw travel so drift is viewport-relative, not beam-width-relative.
const travelVw = (Math.random() * 2.5 + 1).toFixed(1);
return {
left: `${left.toFixed(1)}%`,
width: `${width}px`,
height: `${height}%`,
angle: `${angle.toFixed(1)}deg`,
duration: `${dur.toFixed(1)}s`,
delay: `${delay.toFixed(1)}s`,
opacityMax: opMax,
opacityMin: opMin,
travel: `${travelVw}vw`,
useAccent: i % 4 === 3, // every 4th beam uses --accent for variety
};
});
setBeams(next);
}, [animate, count, speed, intensity]);
// Pause the sweep when this layer is scrolled fully out of view.
useEffect(() => {
if (!animate) return;
const el = layerRef.current;
if (!el || typeof IntersectionObserver === "undefined") return;
const io = new IntersectionObserver(
([entry]) => setRunning(entry.isIntersecting),
{ rootMargin: "0px" },
);
io.observe(el);
return () => io.disconnect();
}, [animate]);
return (
<div
role="presentation"
aria-hidden={ariaHidden}
className={[styles.root, className].filter(Boolean).join(" ")}
style={style}
{...props}
>
{/* Animated shafts — post-mount, non-reduced clients only. */}
{animate && (
<div ref={layerRef} className={styles.layer}>
{beams.map((b, i) => (
<div
key={i}
className={[
styles.beam,
b.useAccent ? styles.beamAccent : styles.beamPrimary,
].join(" ")}
style={
{
"--beam-left": b.left,
"--beam-width": b.width,
"--beam-height": b.height,
"--beam-angle": b.angle,
"--beam-duration": b.duration,
"--beam-delay": b.delay,
"--beam-op-max": b.opacityMax,
"--beam-op-min": b.opacityMin,
"--beam-travel": b.travel,
animationPlayState: running ? "running" : "paused",
} as React.CSSProperties
}
/>
))}
</div>
)}
{/* Static spatial fallback — always in the DOM and visible at SSR/first paint.
The component hides it via .staticLayerHidden once the animated layer
takes over for non-reduced mounted clients. Reduced-motion users and SSR
always see these three faint static shafts for spatial depth without motion. */}
<div
className={[
styles.staticLayer,
animate ? styles.staticLayerHidden : "",
]
.filter(Boolean)
.join(" ")}
aria-hidden="true"
>
<div
className={[styles.staticBeam, styles.beamPrimary].join(" ")}
style={
{
"--beam-left": "18%",
"--beam-width": "70px",
"--beam-angle": "16deg",
"--beam-op-max": intensity * 0.3,
} as React.CSSProperties
}
/>
<div
className={[styles.staticBeam, styles.beamPrimary].join(" ")}
style={
{
"--beam-left": "50%",
"--beam-width": "90px",
"--beam-angle": "20deg",
"--beam-op-max": intensity * 0.3,
} as React.CSSProperties
}
/>
<div
className={[styles.staticBeam, styles.beamAccent].join(" ")}
style={
{
"--beam-left": "76%",
"--beam-width": "65px",
"--beam-angle": "14deg",
"--beam-op-max": intensity * 0.3,
} as React.CSSProperties
}
/>
</div>
</div>
);
}
Dependencies
- motion
- @bottega/tokens