bottega

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-beams

Usage

Usagetsx
<LightBeams />

Props

PropTypeDefaultDescription
countnumber5Number of light shafts rendered simultaneously. Default 5.
speednumber10Sweep cycle duration in seconds — longer is more languid. Default 10.
intensitynumber0.6Peak 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