Glowing radial-gradient orbs float on seeded CSS loops and lean toward the pointer with lerped parallax, where nearer (larger, sharper) orbs move further than distant, softer ones.
Preview
Gradient Orbs
Glowing spheres that float and lean toward your cursor.
Customize
10 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add gradient-orbsWrites components/ui/gradient-orbs.tsx and components/styles/gradient-orbs.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { GradientOrbs } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <GradientOrbs />6 );7}Props
Accessibility
The orb field and vignette are aria-hidden with pointer-events: none, so children stay fully interactive. Floating pauses off-screen or at speed 0; prefers-reduced-motion stops floating and disables pointer parallax. Orb layout uses a seeded random so SSR markup is stable.
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/gradient-orbs.module.css";5 6export interface GradientOrbsProps {7 /** First orb color */8 color1?: string;9 /** Second orb color */10 color2?: string;11 /** Third orb color */12 color3?: string;13 /** Surface color behind the orbs */14 backgroundColor?: string;15 /** Number of orbs (2–12) */16 orbCount?: number;17 /** Float speed multiplier (1 = default, 0 = paused) */18 speed?: number;19 /** Blur radius applied to the orbs, in px */20 blur?: number;21 /** Orbs drift toward the pointer with depth-based parallax */22 followPointer?: boolean;23 /** Height of the background in px */24 height?: number;25 /** Corner radius in px */26 borderRadius?: number;27 /** Foreground content rendered centered on top */28 children?: React.ReactNode;29 /** Optional className merged onto the root */30 className?: string;31 /** Inline styles merged onto the root */32 style?: React.CSSProperties;33}34 35const rand = (n: number) => {36 const x = Math.sin(n * 9301 + 49297) * 233280;37 return x - Math.floor(x);38};39 40const MAX_SHIFT_PX = 70;41 42function useReducedMotionPref() {43 const [reduced, setReduced] = React.useState(false);44 React.useEffect(() => {45 if (typeof window === "undefined" || !window.matchMedia) return;46 const mq = window.matchMedia("(prefers-reduced-motion: reduce)");47 const update = () => setReduced(mq.matches);48 update();49 mq.addEventListener("change", update);50 return () => mq.removeEventListener("change", update);51 }, []);52 return reduced;53}54 55function useInView(ref: React.RefObject<HTMLElement | null>) {56 const [inView, setInView] = React.useState(true);57 React.useEffect(() => {58 const el = ref.current;59 if (!el || typeof IntersectionObserver === "undefined") return;60 const io = new IntersectionObserver((entries) => {61 const entry = entries[entries.length - 1];62 if (entry) setInView(entry.isIntersecting);63 }, { rootMargin: "80px" });64 io.observe(el);65 return () => io.disconnect();66 }, [ref]);67 return inView;68}69 70export function GradientOrbs({71 color1 = "#fb7185",72 color2 = "#818cf8",73 color3 = "#2dd4bf",74 backgroundColor = "#07070d",75 orbCount = 6,76 speed = 1,77 blur = 28,78 followPointer = true,79 height = 360,80 borderRadius = 20,81 children,82 className = "",83 style,84}: GradientOrbsProps) {85 const rootRef = React.useRef<HTMLDivElement>(null);86 const orbRefs = React.useRef<Array<HTMLSpanElement | null>>([]);87 const reduced = useReducedMotionPref();88 const inView = useInView(rootRef);89 const count = Math.max(2, Math.min(12, Math.round(orbCount)));90 const safeSpeed = Math.max(0, speed);91 const running = !reduced && safeSpeed > 0 && inView;92 93 const orbs = React.useMemo(94 () =>95 Array.from({ length: count }, (_, i) => {96 const depth = 0.25 + rand(i + 11) * 0.75;97 return {98 x: 10 + rand(i + 1) * 80,99 y: 12 + rand(i + 2) * 76,100 size: 80 + depth * 150,101 depth,102 color: [color1, color2, color3][i % 3],103 dur: 9 + rand(i + 3) * 9,104 delay: rand(i + 4) * 10,105 dx: (rand(i + 5) - 0.5) * 90,106 dy: (rand(i + 6) - 0.5) * 70,107 };108 }),109 [count, color1, color2, color3],110 );111 112 React.useEffect(() => {113 const el = rootRef.current;114 if (!el || !followPointer || reduced) return;115 const target = { x: 0, y: 0 };116 const cur = { x: 0, y: 0 };117 let raf = 0;118 const apply = () => {119 for (let i = 0; i < orbs.length; i++) {120 const node = orbRefs.current[i];121 if (!node) continue;122 const d = orbs[i].depth;123 node.style.transform = `translate3d(${(cur.x * d * MAX_SHIFT_PX).toFixed(2)}px, ${(cur.y * d * MAX_SHIFT_PX).toFixed(2)}px, 0)`;124 }125 };126 const step = () => {127 // critically-damped-style lerp toward the pointer target128 cur.x += (target.x - cur.x) * 0.07;129 cur.y += (target.y - cur.y) * 0.07;130 apply();131 if (Math.abs(target.x - cur.x) > 0.001 || Math.abs(target.y - cur.y) > 0.001) {132 raf = requestAnimationFrame(step);133 } else {134 raf = 0;135 }136 };137 const kick = () => {138 if (!raf) raf = requestAnimationFrame(step);139 };140 const onMove = (e: PointerEvent) => {141 const rect = el.getBoundingClientRect();142 target.x = Math.max(-1, Math.min(1, ((e.clientX - rect.left) / rect.width) * 2 - 1));143 target.y = Math.max(-1, Math.min(1, ((e.clientY - rect.top) / rect.height) * 2 - 1));144 kick();145 };146 const onLeave = () => {147 target.x = 0;148 target.y = 0;149 kick();150 };151 el.addEventListener("pointermove", onMove);152 el.addEventListener("pointerleave", onLeave);153 return () => {154 el.removeEventListener("pointermove", onMove);155 el.removeEventListener("pointerleave", onLeave);156 cancelAnimationFrame(raf);157 };158 }, [followPointer, reduced, orbs]);159 160 const styleVars = {161 "--mf-go-bg": backgroundColor,162 "--mf-go-blur": `${Math.max(0, blur)}px`,163 "--mf-go-state": running ? "running" : "paused",164 height: `${height}px`,165 borderRadius: `${borderRadius}px`,166 ...style,167 } as React.CSSProperties;168 169 return (170 <div ref={rootRef} style={styleVars} className={`${styles.root} ${className}`.trim()}>171 <div aria-hidden="true" className={styles.field}>172 {orbs.map((o, i) => (173 <span174 key={i}175 ref={(node) => {176 orbRefs.current[i] = node;177 }}178 className={styles.anchor}179 style={{ left: `${o.x.toFixed(2)}%`, top: `${o.y.toFixed(2)}%` }}180 >181 <span182 className={styles.orb}183 style={184 {185 width: `${o.size.toFixed(0)}px`,186 height: `${o.size.toFixed(0)}px`,187 background: `radial-gradient(circle at 35% 30%, color-mix(in srgb, ${o.color} 55%, #ffffff) 0%, ${o.color} 28%, color-mix(in srgb, ${o.color} 70%, transparent) 50%, transparent 72%)`,188 opacity: Number((0.45 + o.depth * 0.45).toFixed(2)),189 filter: `blur(calc(var(--mf-go-blur, 28px) * ${(1.25 - o.depth * 0.6).toFixed(2)}))`,190 animationDuration: `${(o.dur / (safeSpeed > 0 ? safeSpeed : 1)).toFixed(2)}s`,191 animationDelay: `-${o.delay.toFixed(2)}s`,192 "--mf-go-dx": `${o.dx.toFixed(1)}px`,193 "--mf-go-dy": `${o.dy.toFixed(1)}px`,194 } as React.CSSProperties195 }196 />197 </span>198 ))}199 </div>200 <div aria-hidden="true" className={styles.vignette} />201 <div className={styles.content}>202 {children ?? (203 <div className={styles.copy}>204 <h3 className={styles.title}>Gradient Orbs</h3>205 <p className={styles.subtitle}>Glowing spheres that float and lean toward your cursor.</p>206 </div>207 )}208 </div>209 </div>210 );211}