Skip to content

FreeIn reviewv1.0.0

Spotlights

Stage lights through haze: beams that sweep and land in pools of light, pulled toward the pointer.
npx shadcn@latest add @vibebox/spotlights

Controls

Look
1
1
<Spotlights />

About

Each beam is a cone with a fixed angular width from a point above the frame, brightest at its source and thinning with distance, multiplied by a haze noise that scrolls slowly. Its lean is a sine on its own period; near the pointer it blends toward the angle that points at it. The pool is a flattened Gaussian where the beam meets the floor line.

Use it when

  • An event, concert, nightclub, awards or conference page wants the stage.
  • A theatre, school show or pageant wants the curtain call.
  • A launch wants a reveal.

Skip it when

  • The brand is quiet and daylight.
  • The page has a lot of body copy over the beams.

Install

  1. 1Add the VibeBox registry to components.json:
    {
      "registries": {
        "@vibebox": "https://motion.vibeboxph.com/r/{name}.json"
      }
    }
  2. 2Add the component with the shadcn CLI:
    npx shadcn@latest add @vibebox/spotlights
  3. 3Use it:
    import { Spotlights } from "@/components/vibebox/spotlights";
    
    <Spotlights preset="stage" className="min-h-[560px] rounded-xl">
      <div className="p-12 text-white">
        <h1 className="text-4xl font-semibold">Doors at eight. Lights at nine.</h1>
      </div>
    </Spotlights>

Code

3 files
components/vibebox/spotlights/index.tsx
/**
 * VibeBox Motion · Spotlights
 * Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
 * https://motion.vibeboxph.com/license
 * Use it in unlimited personal, commercial and client projects.
 * Do not sell or redistribute it as a component, kit, template or builder.
 */
export { Spotlights, type SpotlightsProps, type SpotlightsColor, type SpotlightsValue } from "./spotlights.client";
export { PRESET_NAMES as SPOTLIGHTS_PRESETS, presets as spotlightsPresets, type SpotlightsPreset } from "./spotlights.shader";
components/vibebox/spotlights/spotlights.client.tsx
/**
 * VibeBox Motion · Spotlights
 * Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
 * https://motion.vibeboxph.com/license
 * Use it in unlimited personal, commercial and client projects.
 * Do not sell or redistribute it as a component, kit, template or builder.
 */
"use client";

import type { ReactNode } from "react";
import { BackdropCanvas, type PosterSpec } from "@/lib/vibebox/backdrop-canvas";
import { spotlightsPiece, presets, type SpotlightsPreset } from "./spotlights.shader";

export type SpotlightsColor = "stage" | "beamA" | "beamB" | "beamC" | "floor";
export type SpotlightsValue = "beams" | "sweep" | "width" | "hazeS";

export type SpotlightsProps = {
  /** What sits on the scene: a heading, a call to action, a whole section. */
  children?: ReactNode;
  /** A named look: `stage`, `laser`, `club`, `gala`. */
  preset?: SpotlightsPreset;
  /** Overrides single palette colours with any CSS colour. */
  colors?: Partial<Record<SpotlightsColor, string>>;
  /** Multiplies the preset's speed (1 keeps it, 0 holds the scene still). */
  speed?: number;
  /** Multiplies the preset's brightness. */
  intensity?: number;
  /** The pointer and touch move the scene. */
  interactive?: boolean;
  /** Accessible name; leave it out when the scene is only a background. */
  label?: string;
  /** Hold one still frame until this is false (for a gallery card). */
  still?: boolean;
  className?: string;
} & Partial<Record<SpotlightsValue, number>>;

const POSTER: PosterSpec = { type: "linear", from: "stage", to: "stage", angle: 180, blobs: [{ color: "beamA", at: [26, 95], size: 40 }, { color: "beamB", at: [55, 98], size: 40 }, { color: "beamC", at: [82, 95], size: 38 }] };

export function Spotlights({ children, preset, colors, speed, intensity, interactive = true, label, still, className, beams, sweep, width, hazeS }: SpotlightsProps) {
  return (
    <BackdropCanvas
      slug="spotlights"
      piece={spotlightsPiece}
      presets={presets}
      fallback="stage"
      preset={preset}
      colors={colors}
      values={{ beams, sweep, width, hazeS }}
      speed={speed}
      intensity={intensity}
      poster={POSTER}
      scale={0.5}
      stillTime={3}
      still={still}
      interactive={interactive}
      label={label}
      className={className}
    >
      {children}
    </BackdropCanvas>
  );
}
components/vibebox/spotlights/spotlights.shader.ts
/**
 * VibeBox Motion · Spotlights
 * Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
 * https://motion.vibeboxph.com/license
 * Use it in unlimited personal, commercial and client projects.
 * Do not sell or redistribute it as a component, kit, template or builder.
 */
import { compose, createScenePiece, type ScenePreset } from "@/lib/vibebox/engine/backdrops";

/*
 * Stage lights through haze: beams hang from a rig above the frame, each a narrow cone that
 * sweeps on its own slow cycle, picks up the haze as it passes through it and lands in a pool of
 * light on the floor. The pointer pulls the beams toward it.
 */

const SCENE = /* glsl */ `
uniform vec3 uStage;
uniform vec3 uBeamA;
uniform vec3 uBeamB;
uniform vec3 uBeamC;
uniform vec3 uFloor;
uniform float uBeams;
uniform float uSweep;
uniform float uWidth;
uniform float uHazeS;
uniform float uSpeed;
uniform float uIntensity;

float T;

vec3 beamColor(int i) {
  int k = i - (i / 3) * 3;
  return k == 0 ? lin(uBeamA) : k == 1 ? lin(uBeamB) : lin(uBeamC);
}

void main() {
  vec2 uv = centred(gl_FragCoord.xy);
  T = uTime * uSpeed;
  float aspect = uResolution.x / uResolution.y;
  vec2 pu = vec2((uPointer.x - 0.5) * aspect, uPointer.y - 0.5);

  vec3 col = lin(uStage) * mix(0.9, 0.35, smoothstep(0.5, -0.5, uv.y));
  float haze = 0.55 + 0.45 * fbm3(vec2(uv.x * 2.0 + T * 0.03, uv.y * 2.2 - T * 0.05));
  float floorY = -0.36;
  int count = int(clamp(uBeams, 2.0, 8.0));

  for (int i = 0; i < 8; i++) {
    if (i >= count) break;
    float fi = float(i);
    float spread = (fi + 0.5) / float(count) - 0.5;
    vec2 origin = vec2(spread * aspect * 1.05, 0.56);
    // Each beam hangs a little off the vertical and sweeps on its own period.
    float lean = -spread * 0.5 + uSweep * 0.42 * sin(T * (0.35 + 0.11 * fi) + fi * 2.3);
    // The pointer draws the beams toward it.
    vec2 toPointer = pu - origin;
    float want = atan(toPointer.x, -toPointer.y);
    lean = mix(lean, want, uHover * 0.35);
    vec2 v = uv - origin;
    float ang = atan(v.x, -v.y);
    float r = length(v);
    float width = (0.045 + 0.02 * h1(int(fi) + 3)) * uWidth;
    // The cone: constant angular width, brightest at the source and thinning away.
    float cone = smoothstep(width, 0.0, abs(ang - lean)) * smoothstep(0.0, 0.08, r);
    float along = exp(-r * 0.9);
    vec3 bc = beamColor(i);
    col += bc * cone * along * haze * uHazeS * 1.1;
    // The source itself.
    col += bc * exp(-length(uv - origin) * 28.0) * 0.8;
    // The pool on the floor: where the beam reaches y = floorY.
    float t = (origin.y - floorY) / max(cos(lean), 0.2);
    vec2 hit = origin + vec2(sin(lean), -cos(lean)) * t;
    vec2 q = (uv - hit) * vec2(1.0, 3.4);
    col += bc * exp(-dot(q, q) * 14.0) * 0.55 * smoothstep(floorY + 0.12, floorY - 0.04, uv.y + 0.0);
  }
  // The floor, a little lighter than the wall and lit by the pools.
  float floorMask = smoothstep(floorY + 0.01, floorY - 0.02, uv.y);
  col = mix(col, col * 0.7 + lin(uFloor) * 0.12, floorMask);

  col *= uIntensity;
  col *= 1.0 - 0.35 * smoothstep(0.5, 1.2, length(uv * vec2(0.8, 1.0)));
  fragColor = vec4(finish(col, gl_FragCoord.xy), 1.0);
}
`;

export const fragment = compose(SCENE);

export const PRESET_NAMES = ["stage", "laser", "club", "gala"] as const;
export type SpotlightsPreset = (typeof PRESET_NAMES)[number];

export const presets: Record<SpotlightsPreset, ScenePreset> = {
  stage: {
    colors: { stage: "#07040d", beamA: "#6aa8ff", beamB: "#ff4fb6", beamC: "#ffd27a", floor: "#2a1a4a" },
    values: { beams: 5, sweep: 1, width: 1, hazeS: 1, speed: 0.8, intensity: 1.1 },
  },
  laser: {
    colors: { stage: "#02040a", beamA: "#3dff9a", beamB: "#ff2f5f", beamC: "#2fd0ff", floor: "#10281c" },
    values: { beams: 7, sweep: 1.3, width: 0.45, hazeS: 1.2, speed: 1.2, intensity: 1.2 },
  },
  club: {
    colors: { stage: "#080212", beamA: "#b24bff", beamB: "#ff3fa4", beamC: "#4aa8ff", floor: "#2a0f4a" },
    values: { beams: 6, sweep: 1.1, width: 0.8, hazeS: 1.1, speed: 1, intensity: 1.15 },
  },
  gala: {
    colors: { stage: "#0b0805", beamA: "#ffe3a8", beamB: "#fff6e0", beamC: "#ffc66a", floor: "#3a2a12" },
    values: { beams: 4, sweep: 0.6, width: 1.3, hazeS: 0.9, speed: 0.5, intensity: 1.1 },
  },
};

export const spotlightsPiece = createScenePiece("spotlights", fragment);

Props

PropTypeDefaultDescription
childrenReactNode—What sits on the scene: a heading, a call to action, a whole section.
preset"stage" | "laser" | "club" | "gala""stage"The look: stage, laser, club, gala.
colors{ stage?: string; beamA?: string; beamB?: string; beamC?: string; floor?: string }—Overrides single palette colours with any CSS colour: stage (the dark of the room), beamA (the first beam colour), beamB (the second beam colour), beamC (the third beam colour), floor (the floor's tint).
speednumber1Multiplies the preset's speed; 0 holds the scene still.
intensitynumber1Multiplies the preset's brightness, 0.5 to 1.4.
beamsnumber5How many beams (2 to 8) (2 to 8; each look sets its own).
sweepnumber1How far the beams swing (0 to 1.6; each look sets its own).
widthnumber1How wide each beam is (0.3 to 1.8; each look sets its own).
hazeSnumber1How much haze the beams pass through (0.3 to 1.8; each look sets its own).
interactivebooleantrueThe pointer draws the beams toward it.
labelstring—Accessible name; leave it out when the scene is only a background.
stillbooleanfalseHold one composed frame until false (a gallery card that plays on hover).
classNamestring—Merged with cn(); give it a size and a radius.

Accessibility and motion

Keyboard
Not focusable; content on it keeps its own focus order.
Screen readers
Decorative by default; with label it is an image with that name.
Touch
A finger moves the scene like the cursor does; scrolling is never blocked.
Reduced motion
One still frame of the beams; they do not sweep.

Edge cases and limits

  • Laser is fast and busy; use it only for a moment.
  • Keep copy out of the pools at the floor.
  • Eight beams on a phone is crowded; use 4.
  • Green and violet aurora curtains over a mountain ridge and stars

    Aurora

    Free

    Curtains of northern light over a night sky and a mountain ridge, drawn toward the pointer.

    Backgrounds · WebGL2

  • Warm amber bokeh lights of different sizes on a dark background

    Bokeh

    Free

    Out-of-focus lights in three depths, brighter at the rim and drifting with the pointer.

    Backgrounds · WebGL2

  • A dusk skyline of lit towers under an orange and violet sky

    A skyline of lit windows against a glowing sky, with a road of light trails in front.

    Backgrounds · WebGL2

↑ ↓ to move↵ to openesc to close