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 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add spotlight-gradientWrites 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.
1import { SpotlightGradient } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <SpotlightGradient />6 );7}Props
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
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}