Soft Light Beams Canvas Background

A dark hero section lit by slanted beams of coloured light. Twenty eight beams are drawn on a canvas as tall gradient rectangles, tilted about thirty degrees, each rising slowly and pulsing in brightness. A heavy CSS blur turns them into soft stage light, and a slow breathing veil makes the whole scene swell and settle. In front sit a headline, a paragraph, two buttons and a small cue list card on frosted glass. On phones the buttons stack to full width and the card fills the column.

Code

// Adapted from Kokonut UI (https://github.com/kokonut-labs/kokonutui/blob/main/components/kokonutui/beams-background.tsx)
// Copyright (c) 2025 kokonutUI. MIT License: https://we.inc/components/licenses
import { useEffect, useRef } from "react";
import { motion, useReducedMotion } from "framer-motion";

// Tune the look here.
const BEAM_COUNT = 28;
const HUE_START = 165; // teal
const HUE_RANGE = 85; // through blue to violet
const STRENGTH = 1; // 0.7 subtle, 0.85 medium, 1 strong
// The canvas is blurred with CSS, so it is drawn at half size to keep each frame cheap.
const RENDER_SCALE = 0.5;

const cues = [
  { id: "14", label: "Blackout to amber wash", time: "2.4s" },
  { id: "15", label: "Side beams, slow rise", time: "6.0s" },
  { id: "16", label: "Strobe hit on the snare", time: "0.2s" },
];

type Beam = { x: number; y: number; width: number; length: number; angle: number; speed: number; opacity: number; hue: number; pulse: number; pulseSpeed: number };

function makeBeam(w: number, h: number): Beam {
  return {
    x: Math.random() * w * 1.5 - w * 0.25,
    y: Math.random() * h * 1.5 - h * 0.25,
    width: 70 + Math.random() * 130,
    length: h * 2.5,
    angle: -35 + Math.random() * 10,
    speed: 0.6 + Math.random() * 1.2,
    opacity: 0.2 + Math.random() * 0.18,
    hue: HUE_START + Math.random() * HUE_RANGE,
    pulse: Math.random() * Math.PI * 2,
    pulseSpeed: 0.02 + Math.random() * 0.03,
  };
}

export default function SoftLightBeamsCanvasBackground() {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const reduce = useReducedMotion();

  useEffect(() => {
    const canvas = canvasRef.current;
    const wrap = canvas?.parentElement;
    const ctx = canvas?.getContext("2d");
    if (!canvas || !wrap || !ctx) return;

    let w = 0;
    let h = 0;
    let frame = 0;
    let onScreen = true;
    let beams: Beam[] = [];

    const drawBeam = (beam: Beam) => {
      ctx.save();
      ctx.translate(beam.x, beam.y);
      ctx.rotate((beam.angle * Math.PI) / 180);
      const a = beam.opacity * (0.8 + Math.sin(beam.pulse) * 0.2) * STRENGTH;
      const colour = (alpha: number) => `hsla(${beam.hue}, 85%, 65%, ${alpha})`;
      const g = ctx.createLinearGradient(0, 0, 0, beam.length);
      g.addColorStop(0, colour(0));
      g.addColorStop(0.1, colour(a * 0.5));
      g.addColorStop(0.4, colour(a));
      g.addColorStop(0.6, colour(a));
      g.addColorStop(0.9, colour(a * 0.5));
      g.addColorStop(1, colour(0));
      ctx.fillStyle = g;
      ctx.fillRect(-beam.width / 2, 0, beam.width, beam.length);
      ctx.restore();
    };

    const paint = (move: boolean) => {
      ctx.clearRect(0, 0, w, h);
      beams.forEach((beam, i) => {
        if (move) {
          beam.y -= beam.speed;
          beam.pulse += beam.pulseSpeed;
          // A beam that has left the top comes back in from the bottom, in one of three loose columns.
          if (beam.y + beam.length < -100) {
            const spacing = w / 3;
            beam.y = h + 100;
            beam.x = (i % 3) * spacing + spacing / 2 + (Math.random() - 0.5) * spacing * 0.5;
            beam.width = 100 + Math.random() * 100;
            beam.speed = 0.5 + Math.random() * 0.4;
            beam.hue = HUE_START + (i * HUE_RANGE) / beams.length;
            beam.opacity = 0.24 + Math.random() * 0.14;
          }
        }
        drawBeam(beam);
      });
    };

    const loop = () => {
      paint(true);
      frame = requestAnimationFrame(loop);
    };
    const start = () => {
      cancelAnimationFrame(frame);
      if (reduce || !onScreen || document.hidden) return;
      frame = requestAnimationFrame(loop);
    };

    const resize = () => {
      w = wrap.clientWidth;
      h = wrap.clientHeight;
      canvas.width = Math.max(1, Math.round(w * RENDER_SCALE));
      canvas.height = Math.max(1, Math.round(h * RENDER_SCALE));
      ctx.setTransform(RENDER_SCALE, 0, 0, RENDER_SCALE, 0, 0);
      beams = Array.from({ length: BEAM_COUNT }, () => makeBeam(w, h));
      paint(false);
    };

    const sizeWatcher = new ResizeObserver(resize);
    sizeWatcher.observe(wrap);
    const viewWatcher = new IntersectionObserver(([entry]) => {
      onScreen = entry.isIntersecting;
      start();
    });
    viewWatcher.observe(wrap);
    document.addEventListener("visibilitychange", start);
    resize();
    start();

    return () => {
      cancelAnimationFrame(frame);
      sizeWatcher.disconnect();
      viewWatcher.disconnect();
      document.removeEventListener("visibilitychange", start);
    };
  }, [reduce]);

  const rise = (delay: number) => ({
    initial: reduce ? false : ({ opacity: 0, y: 20 } as const),
    animate: { opacity: 1, y: 0 },
    transition: { duration: 0.8, delay, ease: [0.22, 0.61, 0.36, 1] as const },
  });

  return (
    <section className="relative flex min-h-[680px] w-full items-center justify-center overflow-hidden bg-neutral-950 px-4 py-20">
      <canvas ref={canvasRef} className="absolute inset-0 h-full w-full blur-xl" aria-hidden="true" />
      {/* A slow breathing veil so the light swells and settles. */}
      <motion.div
        className="pointer-events-none absolute inset-0 bg-neutral-950"
        animate={reduce ? { opacity: 0.05 } : { opacity: [0.02, 0.16, 0.02] }}
        transition={{ duration: 10, ease: "easeInOut", repeat: Infinity }}
        aria-hidden="true"
      />
      <div className="pointer-events-none absolute inset-x-0 bottom-0 h-40 bg-gradient-to-t from-neutral-950 to-transparent" aria-hidden="true" />

      <div className="relative z-10 mx-auto flex max-w-3xl flex-col items-center text-center">
        <motion.p {...rise(0.1)} className="text-xs font-semibold uppercase tracking-[0.25em] text-teal-200">
          Lighting control for live rooms
        </motion.p>
        <motion.h1 {...rise(0.2)} className="mt-5 text-5xl font-semibold tracking-tighter text-white sm:text-7xl">
          Every cue,
          <br />
          on the beat
        </motion.h1>
        <motion.p {...rise(0.35)} className="mt-6 max-w-xl text-base leading-relaxed text-neutral-200 sm:text-lg">
          Lumen Stage listens to the desk feed and fires your looks in time with the band, so one operator can run a two hundred fixture rig.
        </motion.p>
        <motion.div {...rise(0.5)} className="mt-8 flex w-full flex-col items-center justify-center gap-3 sm:w-auto sm:flex-row">
          <a href="#" className="inline-flex h-12 w-full items-center justify-center rounded-lg bg-white px-7 text-sm font-semibold text-neutral-950 transition hover:bg-teal-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950 sm:w-auto">
            Book a rig demo
          </a>
          <a href="#" className="inline-flex h-12 w-full items-center justify-center rounded-lg border border-white/30 bg-white/5 px-7 text-sm font-semibold text-white transition hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950 sm:w-auto">
            Download the show file
          </a>
        </motion.div>

        <motion.div {...rise(0.7)} className="mt-12 w-full max-w-md rounded-xl border border-white/15 bg-neutral-950/60 p-2 text-left backdrop-blur-md">
          <p className="px-3 pb-1 pt-2 text-xs font-semibold uppercase tracking-widest text-neutral-300">Cue stack</p>
          <ul>
            {cues.map((cue, i) => (
              <li key={cue.id} className={`flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm ${i === 0 ? "bg-white/10 text-white" : "text-neutral-300"}`}>
                <span className="w-6 font-mono text-xs text-teal-200">{cue.id}</span>
                <span className="flex-1 truncate">{cue.label}</span>
                {i === 0 && <span className="h-2 w-2 rounded-full bg-teal-300 motion-safe:animate-pulse" aria-hidden="true" />}
                <span className="font-mono text-xs">{cue.time}</span>
              </li>
            ))}
          </ul>
        </motion.div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/soft-light-beams-canvas-background.json

When to use it

How to customize it

Accessibility

Questions

How are the beams drawn?

Each beam is a rectangle filled with a vertical gradient that fades to transparent at both ends. The canvas is moved and rotated before each rectangle is filled, and every frame the beam moves up by its speed. When it leaves the top it is sent back to the bottom in one of three loose columns.

Why is the canvas drawn at half size?

The picture is blurred heavily, so fine detail is lost anyway. Rendering at half resolution cuts the pixels filled per frame to a quarter and looks the same after the blur.

Why blur with CSS instead of the canvas filter?

The canvas `filter` property is slow on large surfaces and is missing in some browsers. A CSS blur on the canvas element runs on the graphics card and behaves the same everywhere.

Can I put my own content on top?

Yes. Everything inside the `relative z-10` container is ordinary markup. Replace the headline, buttons and cue list with your own and keep that wrapper.

Will it resize with the window?

Yes. A ResizeObserver watches the section, resizes the canvas and creates a fresh set of beams sized for the new area.

Adapted from Beams Background in Kokonut UI, used under its open-source licence. Licences and credits

Related: Animated Grid Background with Travelling Light Beams, Aurora Background with Drifting Colour Bands, Particle Flow Field Canvas Background.

More animated backgrounds or all free UI components. Start for free to have a full site built around it.

More in Backgrounds

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap