ShadersGradient Backgroundsno dependencies

PlasmaGradient

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 props
1
1
1
0.6

Installation

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

npx motionfoundry add plasma-gradient

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

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

Props

PropTypeDefault
color1

First filament color (hex).

string'#a855f7'
color2

Second filament color (hex).

string'#38bdf8'
color3

Third filament color (hex).

string'#f472b6'
speed

Flow speed multiplier (0 pauses).

number1
scale

Size of the plasma structures; higher = larger, calmer arcs (0.3–3).

number1
glow

Filament glow strength (0.2–3).

number1
vignette

Edge darkening amount, 0 (none) to 1 (strong).

number0.6
height

Height of the background in px.

number380
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 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

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