A raw WebGL plasma shader: three domain-warped fbm iso-contours glow as hyperbolic electric filaments with white-hot cores over a near-black field, finished with an adjustable vignette. Falls back to rotating masked CSS arcs when WebGL is unavailable.
Preview
Plasma Gradient
Electric filaments arcing through a dark charged field.
Customize
7 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add plasma-gradientWrites components/ui/plasma-gradient.tsx and components/styles/plasma-gradient.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { PlasmaGradient } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <PlasmaGradient />6 );7}Props
Accessibility
The canvas (or CSS fallback) is an aria-hidden decorative layer; children render above with light, shadowed text. Animation stops off-screen, at speed 0, and under prefers-reduced-motion (one static frame). 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/plasma-gradient.module.css";5 6export interface PlasmaGradientProps {7 /** First filament color */8 color1?: string;9 /** Second filament color */10 color2?: string;11 /** Third filament color */12 color3?: string;13 /** Flow speed multiplier (1 = default, 0 = paused) */14 speed?: number;15 /** Size of the plasma structures (higher = larger, calmer arcs) */16 scale?: number;17 /** Filament glow strength (0.2–3) */18 glow?: number;19 /** Edge darkening amount (0 = none, 1 = strong) */20 vignette?: 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 33type RGB = [number, number, number];34type UniformMap = Record<string, number | readonly number[]>;35 36const FRAG = `37#ifdef GL_FRAGMENT_PRECISION_HIGH38precision highp float;39#else40precision mediump float;41#endif42uniform vec2 u_res; uniform float u_time;43uniform vec3 u_c1; uniform vec3 u_c2; uniform vec3 u_c3;44uniform float u_scale; uniform float u_glow; uniform float u_vig;45float hash(vec2 p){ p = fract(p * vec2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); }46float noise(vec2 p){ vec2 i = floor(p), f = fract(p); vec2 u = f * f * (3.0 - 2.0 * f);47 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); }48float fbm(vec2 p){ float v = 0.0, a = 0.5; mat2 m = mat2(1.6, 1.2, -1.2, 1.6);49 for (int i = 0; i < 4; i++){ v += a * noise(p); p = m * p; a *= 0.5; } return v; }50void main(){51 vec2 uv = (gl_FragCoord.xy - 0.5 * u_res) / u_res.y;52 float t = u_time * 0.3;53 vec2 p = uv * (2.6 / u_scale);54 vec2 w = vec2(fbm(p + vec2(t * 0.3, 0.0)), fbm(p + vec2(4.1, 1.7) - t * 0.25));55 vec3 col = vec3(0.0);56 for (int i = 0; i < 3; i++) {57 float fi = float(i);58 vec2 o = p + vec2(fi * 1.7, fi * 2.3);59 float n = fbm(o + 1.9 * w + vec2(t * 0.35 * (1.0 + fi * 0.3), -t * 0.28));60 float d = abs(n - 0.5);61 float g = u_glow * 0.0055 / (d + 0.0035);62 vec3 c = i == 0 ? u_c1 : (i == 1 ? u_c2 : u_c3);63 col += c * g + vec3(1.0) * pow(g, 3.0) * 0.06;64 }65 float amb = fbm(p * 0.6 - w + t * 0.1);66 col += mix(u_c1, u_c2, amb) * amb * amb * 0.18;67 col = 1.0 - exp(-col * 0.9);68 float r = length(uv * vec2(0.85, 1.0));69 col *= mix(1.0, smoothstep(1.05, 0.15, r), u_vig);70 col += (hash(gl_FragCoord.xy + fract(t)) - 0.5) * 0.012;71 gl_FragColor = vec4(max(col, 0.0), 1.0);72}`;73 74const VERT = "attribute vec2 a_pos;void main(){gl_Position=vec4(a_pos,0.0,1.0);}";75 76function hexRgb(hex: string, fb: RGB): RGB {77 const m = hex.trim().replace(/^#/, "");78 const full = m.length === 3 ? m.split("").map((ch) => ch + ch).join("") : m;79 if (!/^[0-9a-fA-F]{6}$/.test(full)) return fb;80 const n = parseInt(full, 16);81 return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];82}83 84const rgba = (c: RGB, a: number) => `rgba(${Math.round(c[0] * 255)},${Math.round(c[1] * 255)},${Math.round(c[2] * 255)},${a})`;85 86function useReducedMotionPref() {87 const [reduced, setReduced] = React.useState(false);88 React.useEffect(() => {89 if (typeof window === "undefined" || !window.matchMedia) return;90 const mq = window.matchMedia("(prefers-reduced-motion: reduce)");91 const update = () => setReduced(mq.matches);92 update();93 mq.addEventListener("change", update);94 return () => mq.removeEventListener("change", update);95 }, []);96 return reduced;97}98 99function useInView(ref: React.RefObject<HTMLElement | null>) {100 const [inView, setInView] = React.useState(true);101 React.useEffect(() => {102 const el = ref.current;103 if (!el || typeof IntersectionObserver === "undefined") return;104 const io = new IntersectionObserver((entries) => {105 const entry = entries[entries.length - 1];106 if (entry) setInView(entry.isIntersecting);107 }, { rootMargin: "80px" });108 io.observe(el);109 return () => io.disconnect();110 }, [ref]);111 return inView;112}113 114function compile(gl: WebGLRenderingContext, type: number, src: string) {115 const sh = gl.createShader(type);116 if (!sh) return null;117 gl.shaderSource(sh, src);118 gl.compileShader(sh);119 if (gl.getShaderParameter(sh, gl.COMPILE_STATUS)) return sh;120 gl.deleteShader(sh);121 return null;122}123 124/** Full-screen-triangle WebGL renderer; returns true when the CSS fallback should be shown instead */125function useShader(126 rootRef: React.RefObject<HTMLDivElement | null>,127 canvasRef: React.RefObject<HTMLCanvasElement | null>,128 frag: string,129 uniforms: () => UniformMap,130 running: boolean,131 speed: number,132 paramsKey: string,133 maxDpr = 2,134) {135 const [fallback, setFallback] = React.useState(false);136 const timeRef = React.useRef(12);137 const drawRef = React.useRef<(() => void) | null>(null);138 const uniRef = React.useRef(uniforms);139 uniRef.current = uniforms;140 const speedRef = React.useRef(speed);141 speedRef.current = speed;142 143 React.useEffect(() => {144 const root = rootRef.current;145 const canvas = canvasRef.current;146 if (!root || !canvas || fallback) return;147 const gl = canvas.getContext("webgl", { alpha: false, antialias: false, depth: false, stencil: false, powerPreference: "low-power" });148 if (!gl) { setFallback(true); return; }149 const vs = compile(gl, gl.VERTEX_SHADER, VERT);150 const fs = compile(gl, gl.FRAGMENT_SHADER, frag);151 const prog = gl.createProgram();152 const buf = gl.createBuffer();153 const dispose = () => {154 gl.deleteBuffer(buf);155 gl.deleteProgram(prog);156 gl.deleteShader(vs);157 gl.deleteShader(fs);158 };159 if (!vs || !fs || !prog || !buf) { dispose(); setFallback(true); return; }160 gl.attachShader(prog, vs);161 gl.attachShader(prog, fs);162 gl.linkProgram(prog);163 if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { dispose(); setFallback(true); return; }164 gl.useProgram(prog);165 gl.bindBuffer(gl.ARRAY_BUFFER, buf);166 gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);167 const aPos = gl.getAttribLocation(prog, "a_pos");168 gl.enableVertexAttribArray(aPos);169 gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);170 const locs = new Map<string, WebGLUniformLocation | null>();171 const loc = (n: string) => {172 if (!locs.has(n)) locs.set(n, gl.getUniformLocation(prog, n));173 return locs.get(n) ?? null;174 };175 const resize = () => {176 const rect = root.getBoundingClientRect();177 const dpr = Math.min(maxDpr, window.devicePixelRatio || 1);178 canvas.width = Math.max(1, Math.round(rect.width * dpr));179 canvas.height = Math.max(1, Math.round(rect.height * dpr));180 gl.viewport(0, 0, canvas.width, canvas.height);181 };182 const draw = () => {183 if (gl.isContextLost()) return;184 gl.uniform2f(loc("u_res"), canvas.width, canvas.height);185 gl.uniform1f(loc("u_time"), timeRef.current);186 const u = uniRef.current();187 for (const k in u) {188 const v = u[k];189 if (typeof v === "number") gl.uniform1f(loc(k), v);190 else if (v.length === 2) gl.uniform2f(loc(k), v[0], v[1]);191 else gl.uniform3f(loc(k), v[0], v[1], v[2]);192 }193 gl.drawArrays(gl.TRIANGLES, 0, 3);194 };195 resize();196 draw();197 drawRef.current = draw;198 const ro = new ResizeObserver(() => { resize(); draw(); });199 ro.observe(root);200 const onLost = (e: Event) => { e.preventDefault(); drawRef.current = null; setFallback(true); };201 canvas.addEventListener("webglcontextlost", onLost);202 return () => {203 ro.disconnect();204 canvas.removeEventListener("webglcontextlost", onLost);205 drawRef.current = null;206 if (!gl.isContextLost()) dispose();207 };208 }, [frag, fallback, maxDpr, rootRef, canvasRef]);209 210 React.useEffect(() => {211 if (!running || fallback) return;212 let raf = 0;213 let last = performance.now();214 const tick = (now: number) => {215 const dt = Math.min(0.05, (now - last) / 1000);216 last = now;217 timeRef.current += dt * speedRef.current;218 drawRef.current?.();219 raf = requestAnimationFrame(tick);220 };221 raf = requestAnimationFrame(tick);222 return () => cancelAnimationFrame(raf);223 }, [running, fallback]);224 225 React.useEffect(() => { drawRef.current?.(); }, [paramsKey]);226 227 return fallback;228}229 230export function PlasmaGradient({231 color1 = "#a855f7",232 color2 = "#38bdf8",233 color3 = "#f472b6",234 speed = 1,235 scale = 1,236 glow = 1,237 vignette = 0.6,238 height = 380,239 borderRadius = 20,240 children,241 className = "",242 style,243}: PlasmaGradientProps) {244 const rootRef = React.useRef<HTMLDivElement>(null);245 const canvasRef = React.useRef<HTMLCanvasElement>(null);246 const reduced = useReducedMotionPref();247 const inView = useInView(rootRef);248 const safeSpeed = Math.max(0, speed);249 const safeScale = Math.max(0.3, Math.min(3, scale));250 const safeGlow = Math.max(0.2, Math.min(3, glow));251 const safeVig = Math.max(0, Math.min(1, vignette));252 const c1 = hexRgb(color1, [0.66, 0.33, 0.97]);253 const c2 = hexRgb(color2, [0.22, 0.74, 0.97]);254 const c3 = hexRgb(color3, [0.96, 0.45, 0.71]);255 256 const fallback = useShader(257 rootRef,258 canvasRef,259 FRAG,260 () => ({ u_c1: c1, u_c2: c2, u_c3: c3, u_scale: safeScale, u_glow: safeGlow, u_vig: safeVig }),261 !reduced && inView && safeSpeed > 0,262 safeSpeed,263 [color1, color2, color3, safeScale, safeGlow, safeVig].join("|"),264 1.5,265 );266 267 const a = Math.min(1, 0.55 * safeGlow);268 const styleVars = {269 "--mf-pl-arcs": `conic-gradient(from 0deg at 50% 50%, transparent 0deg, ${rgba(c1, a)} 40deg, transparent 80deg, ${rgba(c2, a)} 150deg, transparent 200deg, ${rgba(c3, a)} 270deg, transparent 320deg)`,270 "--mf-pl-vig": `radial-gradient(85% 100% at 50% 50%, transparent ${(70 - safeVig * 40).toFixed(0)}%, rgba(0,0,0,${(0.2 + safeVig * 0.75).toFixed(2)}) 100%)`,271 "--mf-pl-dur": `${(14 / Math.max(0.1, safeSpeed)).toFixed(1)}s`,272 height: `${height}px`,273 borderRadius: `${borderRadius}px`,274 ...style,275 } as React.CSSProperties;276 277 return (278 <div ref={rootRef} style={styleVars} className={`${styles.root} ${className}`.trim()}>279 {fallback ? (280 <div aria-hidden="true" className={`${styles.fallback} ${safeSpeed === 0 ? styles.paused : ""}`.trim()}>281 <div className={styles.arcs} />282 <div className={`${styles.arcs} ${styles.arcsAlt}`} />283 <div className={styles.vignette} />284 </div>285 ) : (286 <canvas ref={canvasRef} aria-hidden="true" className={styles.canvas} />287 )}288 <div className={styles.content}>289 {children ?? (290 <div className={styles.copy}>291 <h3 className={styles.title}>Plasma Gradient</h3>292 <p className={styles.subtitle}>Electric filaments arcing through a dark charged field.</p>293 </div>294 )}295 </div>296 </div>297 );298}