Spotlight
Soft pool of accent light that drifts slowly or holds a position, with a vignette that darkens the edges.
Spotlight, drift
Installation
npx shadcn add https://www.reelcn.dev/r/spotlight.jsonFiles land in src/reelcn/ with the items it builds on. Dependencies: remotion.
Usage
<AbsoluteFill>
<Spotlight />
<Center><TextReveal text="Hello" /></Center>
</AbsoluteFill>Props
PropTypeDescription
delaynumberFrames to wait before entering.
durationnumberEnter duration in frames. Defaults to 0.6s. Some items reuse
duration for an unrelated local timing. See that item's own prop docs.exitnumber | booleanExit at the end of the parent `<Sequence>`.
false keeps it on screen, a number sets the exit length in frames.motionMotionPersonalityOverride the theme's motion personality.
posterbooleanRenders fully entered, with no exit: for a still frame or thumbnail. Defaults to
false.holdFramesnumberDelays the start of the exit window by this many frames, for a minimum on-screen hold. Defaults to
0.backgroundstringBase fill. Defaults to the theme background.
colorstringLight color. Defaults to the theme accent.
intensitynumberOpacity at the center of the light, 0–1. Default
0.35.position{ x: number; y: number; }Pin the light at fractions of the canvas (0–1). Omit to let it drift on a slow figure-eight.
sizenumberLight radius in design units. Default
820.vignettenumberHow dark the corners get, 0–1. Default
0.4.speednumberDrift speed multiplier. 0 holds the light still. Default
1.styleCSSProperties
classNamestring
Built from
Use
- Product hero shots, single-idea statements and dramatic reveals
- Drawing the eye to one area of the frame without a hard shape
Avoid
- Several directional light shafts. Use
beams
Guides
Components take their colors from a theme, their timing from motion props and their layout from the canvas.
Source
registry/items/spotlight.tsx
/**
* @title Spotlight
* @category backgrounds
* @description Soft pool of accent light that drifts slowly or holds a position, with a vignette that darkens the edges.
* @duration sustained
* @use Product hero shots, single-idea statements and dramatic reveals
* @use Drawing the eye to one area of the frame without a hard shape
* @avoid Several directional light shafts. Use `beams`
* @tags spotlight, light, vignette, stage, focus
* @example
* <AbsoluteFill>
* <Spotlight />
* <Center><TextReveal text="Hello" /></Center>
* </AbsoluteFill>
*/
import type React from "react";
import { AbsoluteFill } from "remotion";
import { alpha, type MotionProps, useMotion, useTheme, useViewport } from "./core";
import { FlatAccentLook, FlatBlocksLook, GrainFieldLook, GridSweepLook } from "./core-physical-light";
export type SpotlightProps = MotionProps & {
/** Base fill. Defaults to the theme background. */
background?: string;
/** Light color. Defaults to the theme accent. */
color?: string;
/** Opacity at the center of the light, 0–1. */
intensity?: number;
/** Pin the light at fractions of the canvas (0–1). Omit to let it drift on a slow figure-eight. */
position?: { x: number; y: number };
/** Light radius in design units. */
size?: number;
/** How dark the corners get, 0–1. */
vignette?: number;
/** Drift speed multiplier. 0 holds the light still. */
speed?: number;
style?: React.CSSProperties;
className?: string;
};
type SpotlightLook = "pool" | "grid-sweep" | "grain-field" | "flat-blocks" | "flat-accent";
const LOOK_BY_THEME: Record<string, SpotlightLook> = {
daylight: "pool",
midnight: "grid-sweep",
mono: "flat-accent",
ledger: "grain-field",
afterglow: "pool",
chromewave: "flat-blocks",
};
const clamp01 = (value: number) => Math.min(Math.max(value, 0), 1);
export function Spotlight({
background,
color,
intensity = 0.35,
position,
size = 820,
vignette = 0.4,
speed = 1,
style,
className,
...motion
}: SpotlightProps) {
const theme = useTheme();
const { width, height, u } = useViewport();
const m = useMotion(motion);
const light = color ?? (theme.name === "daylight" ? theme.colors.foreground : theme.colors.accent);
const seconds = (m.frame / m.fps) * speed;
// A 1:2 Lissajous curve: a lazy figure-eight around the upper middle of the frame.
const x = position ? position.x : 0.5 + 0.2 * Math.sin(seconds * 0.5);
const y = position ? position.y : 0.42 + 0.09 * Math.sin(seconds + 0.6);
const radius = u(size) * (1 + 0.04 * Math.sin((m.frame / m.fps) * 0.9));
const strength = intensity * m.presence;
const look = LOOK_BY_THEME[theme.name] ?? "pool";
if (look === "grid-sweep")
return (
<GridSweepLook
theme={theme}
seconds={seconds}
strength={strength}
background={background}
className={className}
style={style}
/>
);
if (look === "grain-field")
return (
<GrainFieldLook
theme={theme}
seconds={seconds}
strength={strength}
palette={[light]}
background={background}
className={className}
style={style}
/>
);
if (look === "flat-blocks")
return (
<FlatBlocksLook
theme={theme}
seconds={seconds}
strength={strength}
palette={[light, theme.colors.highlight]}
background={background}
className={className}
style={style}
/>
);
if (look === "flat-accent")
return (
<FlatAccentLook
theme={theme}
seconds={seconds}
strength={strength}
background={background}
className={className}
style={style}
/>
);
const stop = (share: number) => alpha(light, clamp01(strength * share));
const pool = `radial-gradient(circle ${radius}px at ${x * width}px ${y * height}px, ${stop(1)} 0%, ${stop(0.62)} 22%, ${stop(0.28)} 48%, ${stop(0.08)} 75%, ${stop(0)} 100%)`;
const edges = `radial-gradient(ellipse farthest-corner at 50% 50%, ${alpha("#000000", 0)} 45%, ${alpha("#000000", clamp01(vignette))} 100%)`;
return (
<AbsoluteFill
className={className}
style={{
backgroundColor: background ?? theme.colors.background,
backgroundImage: `${edges}, ${pool}`,
...style,
}}
/>
);
}