ShadersGradient Backgroundsno dependencies

SpotlightGradient

A dark dot or line grid lit by a two-tone radial spotlight that follows the pointer with frame-rate-independent easing, brightening the grid beneath it, and drifts along a slow Lissajous path when the pointer leaves.

Preview

Spotlight Gradient

Move your cursor to steer the light across the grid.

Customize

9 props
320
0.14
1
360
20

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add spotlight-gradient

Writes components/ui/spotlight-gradient.tsx and components/styles/spotlight-gradient.module.css.

Usage

Updates live as you change the controls — only props that differ from the defaults are included.

example.tsx
1import { SpotlightGradient } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <SpotlightGradient />6  );7}

Props

PropTypeDefault
color1

Inner spotlight color.

string'#22d3ee'
color2

Outer spotlight halo color.

string'#8b5cf6'
backgroundColor

Dark surface color.

string'#09090f'
radius

Spotlight radius in px.

number320
gridType

Background grid pattern.

"dots" | "lines" | "none"'dots'
gridOpacity

Base grid opacity (0–1); the grid brightens under the spotlight.

number0.14
speed

Idle auto-drift speed multiplier (0 freezes the drift).

number1
height

Height of the background in px.

number360
borderRadius

Corner radius in px.

number20
children

Content rendered centered on top; defaults to a heading and subline.

React.ReactNodeundefined
className

Additional class merged onto the root element.

string''
style

Inline styles merged onto the root element.

React.CSSPropertiesundefined

Accessibility

Grid, spotlight and vignette layers are aria-hidden with pointer-events: none, so content remains interactive. The loop stops off-screen; speed 0 freezes the idle drift (pointer following still works); prefers-reduced-motion renders a static spotlight with no tracking or drift.

Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.

Source

components/ui/spotlight-gradient.tsx
1"use client";2 3import * as React from "react";4import styles from "../styles/spotlight-gradient.module.css";5 6export interface SpotlightGradientProps {7  /** Inner spotlight color */8  color1?: string;9  /** Outer spotlight halo color */10  color2?: string;11  /** Dark surface color */12  backgroundColor?: string;13  /** Spotlight radius in px */14  radius?: number;15  /** Background grid pattern */16  gridType?: "dots" | "lines" | "none";17  /** Base opacity of the grid (0–1); the grid brightens under the spotlight */18  gridOpacity?: number;19  /** Idle auto-drift speed multiplier (1 = default, 0 = paused) */20  speed?: number;21  /** Height of the background in px */22  height?: number;23  /** Corner radius in px */24  borderRadius?: number;25  /** Foreground content rendered centered on top */26  children?: React.ReactNode;27  /** Optional className merged onto the root */28  className?: string;29  /** Inline styles merged onto the root */30  style?: React.CSSProperties;31}32 33function useReducedMotionPref() {34  const [reduced, setReduced] = React.useState(false);35  React.useEffect(() => {36    if (typeof window === "undefined" || !window.matchMedia) return;37    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");38    const update = () => setReduced(mq.matches);39    update();40    mq.addEventListener("change", update);41    return () => mq.removeEventListener("change", update);42  }, []);43  return reduced;44}45 46function useInView(ref: React.RefObject<HTMLElement | null>) {47  const [inView, setInView] = React.useState(true);48  React.useEffect(() => {49    const el = ref.current;50    if (!el || typeof IntersectionObserver === "undefined") return;51    const io = new IntersectionObserver((entries) => {52      const entry = entries[entries.length - 1];53      if (entry) setInView(entry.isIntersecting);54    }, { rootMargin: "80px" });55    io.observe(el);56    return () => io.disconnect();57  }, [ref]);58  return inView;59}60 61/** Idle path: a slow Lissajous figure around the center, in 0–1 coordinates */62function driftPoint(t: number): [number, number] {63  return [0.5 + Math.sin(t * 0.5) * 0.3 + Math.sin(t * 0.23) * 0.06, 0.5 + Math.sin(t * 0.37 + 1.2) * 0.24];64}65 66export function SpotlightGradient({67  color1 = "#22d3ee",68  color2 = "#8b5cf6",69  backgroundColor = "#09090f",70  radius = 320,71  gridType = "dots",72  gridOpacity = 0.14,73  speed = 1,74  height = 360,75  borderRadius = 20,76  children,77  className = "",78  style,79}: SpotlightGradientProps) {80  const rootRef = React.useRef<HTMLDivElement>(null);81  const reduced = useReducedMotionPref();82  const inView = useInView(rootRef);83  const safeSpeed = Math.max(0, speed);84 85  React.useEffect(() => {86    const el = rootRef.current;87    if (!el || reduced || !inView) return;88    const start = driftPoint(0);89    const cur = { x: start[0], y: start[1] };90    const pointer = { x: 0.5, y: 0.5, active: false };91    let t = 0;92    let raf = 0;93    let last = performance.now();94    const tick = (now: number) => {95      const dt = Math.min(0.05, (now - last) / 1000);96      last = now;97      t += dt * safeSpeed;98      let tx: number;99      let ty: number;100      if (pointer.active) {101        tx = pointer.x;102        ty = pointer.y;103      } else {104        const p = driftPoint(t);105        tx = p[0];106        ty = p[1];107      }108      const ease = 1 - Math.pow(pointer.active ? 0.0008 : 0.04, dt);109      cur.x += (tx - cur.x) * ease;110      cur.y += (ty - cur.y) * ease;111      el.style.setProperty("--mf-sg-x", `${(cur.x * 100).toFixed(2)}%`);112      el.style.setProperty("--mf-sg-y", `${(cur.y * 100).toFixed(2)}%`);113      raf = requestAnimationFrame(tick);114    };115    const onMove = (e: PointerEvent) => {116      const rect = el.getBoundingClientRect();117      pointer.x = (e.clientX - rect.left) / rect.width;118      pointer.y = (e.clientY - rect.top) / rect.height;119      pointer.active = true;120    };121    const onLeave = () => {122      pointer.active = false;123    };124    el.addEventListener("pointermove", onMove);125    el.addEventListener("pointerleave", onLeave);126    raf = requestAnimationFrame(tick);127    return () => {128      el.removeEventListener("pointermove", onMove);129      el.removeEventListener("pointerleave", onLeave);130      cancelAnimationFrame(raf);131    };132  }, [reduced, inView, safeSpeed]);133 134  const gridClass = gridType === "lines" ? styles.lines : gridType === "dots" ? styles.dots : "";135  const styleVars = {136    "--mf-sg-bg": backgroundColor,137    "--mf-sg-c1": color1,138    "--mf-sg-c2": color2,139    "--mf-sg-r": `${Math.max(40, radius)}px`,140    "--mf-sg-grid": String(Math.max(0, Math.min(1, gridOpacity))),141    height: `${height}px`,142    borderRadius: `${borderRadius}px`,143    ...style,144  } as React.CSSProperties;145 146  return (147    <div ref={rootRef} style={styleVars} className={`${styles.root} ${className}`.trim()}>148      {gridClass ? <div aria-hidden="true" className={`${styles.grid} ${gridClass}`} /> : null}149      <div aria-hidden="true" className={styles.spot} />150      {gridClass ? <div aria-hidden="true" className={`${styles.grid} ${styles.lit} ${gridClass}`} /> : null}151      <div aria-hidden="true" className={styles.vignette} />152      <div className={styles.content}>153        {children ?? (154          <div className={styles.copy}>155            <h3 className={styles.title}>Spotlight Gradient</h3>156            <p className={styles.subtitle}>Move your cursor to steer the light across the grid.</p>157          </div>158        )}159      </div>160    </div>161  );162}