A raw WebGL fragment shader that layers three passes of domain-warped fbm into glowing nebula clouds with dark dust lanes over near-black space, plus an optional field of softly twinkling stars. Falls back to blurred, drifting CSS gradients when WebGL is unavailable.
Preview
Nebula Gradient
Domain-warped star clouds drifting through deep space.
Customize
8 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add nebula-gradientWrites components/ui/nebula-gradient.tsx and components/styles/nebula-gradient.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { NebulaGradient } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <NebulaGradient />6 );7}Props
Accessibility
The canvas (or CSS fallback) is an aria-hidden decorative layer; children render above in normal flow with light, shadowed text for contrast on the dark field. The render loop stops off-screen, at speed 0, and under prefers-reduced-motion (a single static frame is drawn). GL resources are deleted on unmount and a lost context switches to the CSS fallback.
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/nebula-gradient.module.css";5 6export interface NebulaGradientProps {7 /** Primary cloud color */8 color1?: string;9 /** Secondary cloud color */10 color2?: string;11 /** Accent color for the brightest filaments */12 color3?: string;13 /** Deep-space base color behind the clouds */14 backgroundColor?: string;15 /** Drift speed multiplier (1 = default, 0 = paused) */16 speed?: number;17 /** Cloud density / detail (0.2 = sparse wisps, 2 = thick nebula) */18 density?: number;19 /** Render a subtle field of twinkling stars */20 stars?: boolean;21 /** Overall glow brightness (0.2–2) */22 intensity?: number;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 35type RGB = [number, number, number];36type UniformMap = Record<string, number | readonly number[]>;37 38const FRAG = `39#ifdef GL_FRAGMENT_PRECISION_HIGH40precision highp float;41#else42precision mediump float;43#endif44uniform vec2 u_res; uniform float u_time;45uniform vec3 u_c1; uniform vec3 u_c2; uniform vec3 u_c3; uniform vec3 u_bg;46uniform float u_density; uniform float u_stars; uniform float u_intensity;47float hash(vec2 p){ p = fract(p * vec2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); }48float noise(vec2 p){ vec2 i = floor(p), f = fract(p); vec2 u = f * f * (3.0 - 2.0 * f);49 return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x), mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y); }50float fbm(vec2 p){ float v = 0.0, a = 0.5; mat2 m = mat2(1.6, 1.2, -1.2, 1.6);51 for (int i = 0; i < 5; i++){ v += a * noise(p); p = m * p; a *= 0.5; } return v; }52void main(){53 vec2 uv = (gl_FragCoord.xy - 0.5 * u_res) / u_res.y;54 float t = u_time * 0.05;55 vec2 p = uv * (1.4 + u_density * 0.9);56 vec2 q = vec2(fbm(p + vec2(0.0, t)), fbm(p + vec2(5.2, 1.3) - t));57 vec2 r = vec2(fbm(p + 3.0 * q + vec2(1.7, 9.2) + t * 1.5), fbm(p + 3.0 * q + vec2(8.3, 2.8) - t));58 float f = fbm(p + 2.6 * r);59 float d = smoothstep(0.42 - 0.14 * u_density, 0.98, f);60 vec3 col = mix(u_c1, u_c2, clamp(length(q) * 1.15 - 0.2, 0.0, 1.0));61 col = mix(col, u_c3, clamp(r.x * r.x * 1.8 - 0.15, 0.0, 1.0));62 vec3 neb = col * d * d * 2.2 + col * pow(d, 5.0) * 1.6 + vec3(1.0) * pow(d, 9.0) * 0.5;63 float dust = smoothstep(0.48, 0.78, fbm(p * 1.9 - r * 1.4 + t));64 neb *= (1.0 - dust * 0.6) * u_intensity;65 vec3 c = u_bg + (1.0 - exp(-neb * 1.3));66 if (u_stars > 0.5) {67 vec2 g = gl_FragCoord.xy / u_res.y * 44.0;68 vec2 id = floor(g); vec2 fp = fract(g) - 0.5;69 float h = hash(id);70 vec2 off = (vec2(hash(id + 3.1), hash(id + 7.7)) - 0.5) * 0.6;71 float tw = 0.55 + 0.45 * sin(u_time * (1.2 + h * 2.5) + h * 40.0);72 float s = smoothstep(0.13, 0.0, length(fp - off)) * step(0.9, h) * (h - 0.88) * 9.0;73 c += vec3(0.85, 0.9, 1.0) * s * tw * (1.0 - d * 0.75);74 }75 c *= 1.0 - 0.3 * dot(uv, uv);76 gl_FragColor = vec4(c, 1.0);77}`;78 79const VERT = "attribute vec2 a_pos;void main(){gl_Position=vec4(a_pos,0.0,1.0);}";80 81function hexRgb(hex: string, fb: RGB): RGB {82 const m = hex.trim().replace(/^#/, "");83 const full = m.length === 3 ? m.split("").map((ch) => ch + ch).join("") : m;84 if (!/^[0-9a-fA-F]{6}$/.test(full)) return fb;85 const n = parseInt(full, 16);86 return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];87}88 89const rgba = (c: RGB, a: number) => `rgba(${Math.round(c[0] * 255)},${Math.round(c[1] * 255)},${Math.round(c[2] * 255)},${a})`;90 91function useReducedMotionPref() {92 const [reduced, setReduced] = React.useState(false);93 React.useEffect(() => {94 if (typeof window === "undefined" || !window.matchMedia) return;95 const mq = window.matchMedia("(prefers-reduced-motion: reduce)");96 const update = () => setReduced(mq.matches);97 update();98 mq.addEventListener("change", update);99 return () => mq.removeEventListener("change", update);100 }, []);101 return reduced;102}103 104function useInView(ref: React.RefObject<HTMLElement | null>) {105 const [inView, setInView] = React.useState(true);106 React.useEffect(() => {107 const el = ref.current;108 if (!el || typeof IntersectionObserver === "undefined") return;109 const io = new IntersectionObserver((entries) => {110 const entry = entries[entries.length - 1];111 if (entry) setInView(entry.isIntersecting);112 }, { rootMargin: "80px" });113 io.observe(el);114 return () => io.disconnect();115 }, [ref]);116 return inView;117}118 119function compile(gl: WebGLRenderingContext, type: number, src: string) {120 const sh = gl.createShader(type);121 if (!sh) return null;122 gl.shaderSource(sh, src);123 gl.compileShader(sh);124 if (gl.getShaderParameter(sh, gl.COMPILE_STATUS)) return sh;125 gl.deleteShader(sh);126 return null;127}128 129/** Full-screen-triangle WebGL renderer; returns true when the CSS fallback should be shown instead */130function useShader(131 rootRef: React.RefObject<HTMLDivElement | null>,132 canvasRef: React.RefObject<HTMLCanvasElement | null>,133 frag: string,134 uniforms: () => UniformMap,135 running: boolean,136 speed: number,137 paramsKey: string,138 maxDpr = 2,139) {140 const [fallback, setFallback] = React.useState(false);141 const timeRef = React.useRef(12);142 const drawRef = React.useRef<(() => void) | null>(null);143 const uniRef = React.useRef(uniforms);144 uniRef.current = uniforms;145 const speedRef = React.useRef(speed);146 speedRef.current = speed;147 148 React.useEffect(() => {149 const root = rootRef.current;150 const canvas = canvasRef.current;151 if (!root || !canvas || fallback) return;152 const gl = canvas.getContext("webgl", { alpha: false, antialias: false, depth: false, stencil: false, powerPreference: "low-power" });153 if (!gl) { setFallback(true); return; }154 const vs = compile(gl, gl.VERTEX_SHADER, VERT);155 const fs = compile(gl, gl.FRAGMENT_SHADER, frag);156 const prog = gl.createProgram();157 const buf = gl.createBuffer();158 const dispose = () => {159 gl.deleteBuffer(buf);160 gl.deleteProgram(prog);161 gl.deleteShader(vs);162 gl.deleteShader(fs);163 };164 if (!vs || !fs || !prog || !buf) { dispose(); setFallback(true); return; }165 gl.attachShader(prog, vs);166 gl.attachShader(prog, fs);167 gl.linkProgram(prog);168 if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { dispose(); setFallback(true); return; }169 gl.useProgram(prog);170 gl.bindBuffer(gl.ARRAY_BUFFER, buf);171 gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);172 const aPos = gl.getAttribLocation(prog, "a_pos");173 gl.enableVertexAttribArray(aPos);174 gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);175 const locs = new Map<string, WebGLUniformLocation | null>();176 const loc = (n: string) => {177 if (!locs.has(n)) locs.set(n, gl.getUniformLocation(prog, n));178 return locs.get(n) ?? null;179 };180 const resize = () => {181 const rect = root.getBoundingClientRect();182 const dpr = Math.min(maxDpr, window.devicePixelRatio || 1);183 canvas.width = Math.max(1, Math.round(rect.width * dpr));184 canvas.height = Math.max(1, Math.round(rect.height * dpr));185 gl.viewport(0, 0, canvas.width, canvas.height);186 };187 const draw = () => {188 if (gl.isContextLost()) return;189 gl.uniform2f(loc("u_res"), canvas.width, canvas.height);190 gl.uniform1f(loc("u_time"), timeRef.current);191 const u = uniRef.current();192 for (const k in u) {193 const v = u[k];194 if (typeof v === "number") gl.uniform1f(loc(k), v);195 else if (v.length === 2) gl.uniform2f(loc(k), v[0], v[1]);196 else gl.uniform3f(loc(k), v[0], v[1], v[2]);197 }198 gl.drawArrays(gl.TRIANGLES, 0, 3);199 };200 resize();201 draw();202 drawRef.current = draw;203 const ro = new ResizeObserver(() => { resize(); draw(); });204 ro.observe(root);205 const onLost = (e: Event) => { e.preventDefault(); drawRef.current = null; setFallback(true); };206 canvas.addEventListener("webglcontextlost", onLost);207 return () => {208 ro.disconnect();209 canvas.removeEventListener("webglcontextlost", onLost);210 drawRef.current = null;211 if (!gl.isContextLost()) dispose();212 };213 }, [frag, fallback, maxDpr, rootRef, canvasRef]);214 215 React.useEffect(() => {216 if (!running || fallback) return;217 let raf = 0;218 let last = performance.now();219 const tick = (now: number) => {220 const dt = Math.min(0.05, (now - last) / 1000);221 last = now;222 timeRef.current += dt * speedRef.current;223 drawRef.current?.();224 raf = requestAnimationFrame(tick);225 };226 raf = requestAnimationFrame(tick);227 return () => cancelAnimationFrame(raf);228 }, [running, fallback]);229 230 React.useEffect(() => { drawRef.current?.(); }, [paramsKey]);231 232 return fallback;233}234 235const rand = (n: number) => {236 const x = Math.sin(n * 9301 + 49297) * 233280;237 return x - Math.floor(x);238};239 240export function NebulaGradient({241 color1 = "#7c3aed",242 color2 = "#ec4899",243 color3 = "#22d3ee",244 backgroundColor = "#05040b",245 speed = 1,246 density = 1,247 stars = true,248 intensity = 1,249 height = 380,250 borderRadius = 20,251 children,252 className = "",253 style,254}: NebulaGradientProps) {255 const rootRef = React.useRef<HTMLDivElement>(null);256 const canvasRef = React.useRef<HTMLCanvasElement>(null);257 const reduced = useReducedMotionPref();258 const inView = useInView(rootRef);259 const safeSpeed = Math.max(0, speed);260 const safeDensity = Math.max(0.2, Math.min(2, density));261 const safeIntensity = Math.max(0.2, Math.min(2, intensity));262 const c1 = hexRgb(color1, [0.49, 0.23, 0.93]);263 const c2 = hexRgb(color2, [0.93, 0.28, 0.6]);264 const c3 = hexRgb(color3, [0.13, 0.83, 0.93]);265 const bg = hexRgb(backgroundColor, [0.02, 0.016, 0.043]);266 267 const fallback = useShader(268 rootRef,269 canvasRef,270 FRAG,271 () => ({ u_c1: c1, u_c2: c2, u_c3: c3, u_bg: bg, u_density: safeDensity, u_stars: stars ? 1 : 0, u_intensity: safeIntensity }),272 !reduced && inView && safeSpeed > 0,273 safeSpeed,274 [color1, color2, color3, backgroundColor, safeDensity, stars, safeIntensity].join("|"),275 1.5,276 );277 278 const starField = React.useMemo(279 () =>280 Array.from({ length: 28 }, (_, i) => `radial-gradient(1px 1px at ${(rand(i) * 100).toFixed(1)}% ${(rand(i + 50) * 100).toFixed(1)}%, rgba(235,240,255,${(0.4 + rand(i + 9) * 0.6).toFixed(2)}), transparent)`).join(","),281 [],282 );283 284 const styleVars = {285 "--mf-nb-bg": backgroundColor,286 "--mf-nb-clouds": `radial-gradient(38% 46% at 30% 42%, ${rgba(c1, 0.75 * safeIntensity)}, transparent 70%), radial-gradient(34% 40% at 66% 58%, ${rgba(c2, 0.6 * safeIntensity)}, transparent 70%), radial-gradient(22% 26% at 52% 36%, ${rgba(c3, 0.45 * safeIntensity)}, transparent 70%)`,287 "--mf-nb-stars": starField,288 "--mf-nb-dur": `${(28 / Math.max(0.1, safeSpeed)).toFixed(1)}s`,289 height: `${height}px`,290 borderRadius: `${borderRadius}px`,291 ...style,292 } as React.CSSProperties;293 294 return (295 <div ref={rootRef} style={styleVars} className={`${styles.root} ${className}`.trim()}>296 {fallback ? (297 <div aria-hidden="true" className={`${styles.fallback} ${safeSpeed === 0 ? styles.paused : ""}`.trim()}>298 <div className={styles.clouds} />299 {stars ? <div className={styles.stars} /> : null}300 </div>301 ) : (302 <canvas ref={canvasRef} aria-hidden="true" className={styles.canvas} />303 )}304 <div className={styles.content}>305 {children ?? (306 <div className={styles.copy}>307 <h3 className={styles.title}>Nebula Gradient</h3>308 <p className={styles.subtitle}>Domain-warped star clouds drifting through deep space.</p>309 </div>310 )}311 </div>312 </div>313 );314}