ShadersGradient Backgroundsno dependencies

GradientOrbs

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 props
6
1
28
360
20

Installation

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

npx motionfoundry add gradient-orbs

Writes 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.

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

Props

PropTypeDefault
color1

First orb color.

string'#fb7185'
color2

Second orb color.

string'#818cf8'
color3

Third orb color.

string'#2dd4bf'
backgroundColor

Surface color behind the orbs.

string'#07070d'
orbCount

Number of orbs, clamped to 2–12.

number6
speed

Float speed multiplier (0 pauses).

number1
blur

Base blur radius in px; distant orbs are blurred more.

number28
followPointer

Orbs drift toward the pointer with depth-based parallax.

booleantrue
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

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

components/ui/gradient-orbs.tsx
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}