ShadersGradient Backgroundsno dependencies

EclipseGradient

A raw WebGL eclipse shader: a pure black disk ringed by a slowly turning corona of noise-driven rays, a hot inner glow and an RGB-split chromatic rim, with optional pointer tilt that parallaxes the disk against its corona. Falls back to a CSS disk with a rotating masked conic corona when WebGL is unavailable. Defaults to a cool lavender corona with a crisp rim.

Preview

Eclipse Gradient

A living corona blazing around a perfect black disk.

Customize

8 props
3
0.5
0

Installation

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

npx motionfoundry add eclipse-gradient

Writes components/ui/eclipse-gradient.tsx and components/styles/eclipse-gradient.module.css.

Usage

Updates live as you change the controls — only props that differ from the defaults are included.

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

Props

PropTypeDefault
color1

Hot inner corona color (hex).

string'#a8aeff'
color2

Outer corona and halo color (hex).

string'#caabd3'
color3

Chromatic rim color around the disk (hex).

string'#0d3040'
backgroundColor

Deep space color behind the eclipse (hex).

string'#020205'
speed

Corona animation speed (0 pauses).

number3
ringSize

Size of the eclipsed disk (0.5–1.6).

number0.5
rayIntensity

Length and brightness of the corona rays (0–2).

number0
interactive

Tilt the disk and corona toward the pointer.

booleantrue
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, heavily shadowed text. Pointer tilt is purely cosmetic and needs no interaction. Animation stops off-screen, at speed 0 (tilt still eases when interactive), and under prefers-reduced-motion (one static frame, tilt disabled). 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/eclipse-gradient.tsx
1"use client";2 3import * as React from "react";4import styles from "../styles/eclipse-gradient.module.css";5 6export interface EclipseGradientProps {7  /** Hot inner corona color */8  color1?: string;9  /** Outer corona / halo color */10  color2?: string;11  /** Chromatic rim color around the disk */12  color3?: string;13  /** Deep space color behind the eclipse */14  backgroundColor?: string;15  /** Corona animation speed (1 = default, 0 = paused) */16  speed?: number;17  /** Size of the eclipsed disk (0.5–1.6) */18  ringSize?: number;19  /** Length and brightness of the corona rays (0–2) */20  rayIntensity?: number;21  /** Tilt the eclipse toward the pointer */22  interactive?: 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 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_ring; uniform float u_rays; uniform vec2 u_ptr;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 < 4; 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;55  vec2 ctr = u_ptr * vec2(0.05, 0.04);56  float R = 0.26 * u_ring;57  vec2 d = uv - ctr; float r = length(d);58  vec2 dc = uv - ctr * 0.4; float rc = length(dc); vec2 dir = dc / max(rc, 0.0001);59  float ca = cos(t * 0.03), sa = sin(t * 0.03);60  vec2 rd = mat2(ca, -sa, sa, ca) * dir;61  float rays = fbm(dir * 2.2 + vec2(t * 0.06, -t * 0.04)) * 0.6 + noise(rd * 7.0 + t * 0.15) * 0.3 + noise(rd * 17.0 - t * 0.1) * 0.15;62  float x = max(rc - R, 0.0);63  float corona = exp(-x * 4.5 / (0.3 + rays * rays * 2.4 * u_rays)) * (0.5 + rays * 0.9 * u_rays);64  float inner = exp(-x * 26.0);65  vec3 col = mix(u_c2, u_c1, clamp(inner + exp(-x * 9.0) * 0.5, 0.0, 1.0)) * corona * 1.3 + u_c1 * inner * 1.1;66  col += u_c2 * exp(-rc * 3.0) * 0.16;67  col = 1.0 - exp(-col * 1.1);68  float disk = smoothstep(R + 0.0015, R - 0.0015, r);69  vec3 diskCol = u_bg * 0.5 + u_c3 * 0.05 * smoothstep(R * 0.55, R, r);70  vec3 c = mix(u_bg + col, diskCol, disk);71  float w = 0.0035 + 0.002 * u_ring;72  float dl = 0.0055;73  vec3 e3 = (vec3(r) - vec3(R + dl, R, R - dl * 0.6)) / w;74  vec3 edge = exp(-e3 * e3);75  float lp = length(u_ptr);76  float bias = 0.75 + 0.6 * max(dot(d / max(r, 0.0001), u_ptr / max(lp, 0.0001)), 0.0) * lp;77  c += edge * mix(vec3(1.0), u_c3, 0.55) * 1.5 * bias;78  c *= 1.0 - 0.25 * dot(uv, uv);79  gl_FragColor = vec4(c, 1.0);80}`;81 82const VERT = "attribute vec2 a_pos;void main(){gl_Position=vec4(a_pos,0.0,1.0);}";83 84function hexRgb(hex: string, fb: RGB): RGB {85  const m = hex.trim().replace(/^#/, "");86  const full = m.length === 3 ? m.split("").map((ch) => ch + ch).join("") : m;87  if (!/^[0-9a-fA-F]{6}$/.test(full)) return fb;88  const n = parseInt(full, 16);89  return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];90}91 92const rgba = (c: RGB, a: number) => `rgba(${Math.round(c[0] * 255)},${Math.round(c[1] * 255)},${Math.round(c[2] * 255)},${a})`;93 94function useReducedMotionPref() {95  const [reduced, setReduced] = React.useState(false);96  React.useEffect(() => {97    if (typeof window === "undefined" || !window.matchMedia) return;98    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");99    const update = () => setReduced(mq.matches);100    update();101    mq.addEventListener("change", update);102    return () => mq.removeEventListener("change", update);103  }, []);104  return reduced;105}106 107function useInView(ref: React.RefObject<HTMLElement | null>) {108  const [inView, setInView] = React.useState(true);109  React.useEffect(() => {110    const el = ref.current;111    if (!el || typeof IntersectionObserver === "undefined") return;112    const io = new IntersectionObserver((entries) => {113      const entry = entries[entries.length - 1];114      if (entry) setInView(entry.isIntersecting);115    }, { rootMargin: "80px" });116    io.observe(el);117    return () => io.disconnect();118  }, [ref]);119  return inView;120}121 122function compile(gl: WebGLRenderingContext, type: number, src: string) {123  const sh = gl.createShader(type);124  if (!sh) return null;125  gl.shaderSource(sh, src);126  gl.compileShader(sh);127  if (gl.getShaderParameter(sh, gl.COMPILE_STATUS)) return sh;128  gl.deleteShader(sh);129  return null;130}131 132/** Full-screen-triangle WebGL renderer; returns true when the CSS fallback should be shown instead */133function useShader(134  rootRef: React.RefObject<HTMLDivElement | null>,135  canvasRef: React.RefObject<HTMLCanvasElement | null>,136  frag: string,137  uniforms: () => UniformMap,138  running: boolean,139  speed: number,140  paramsKey: string,141  maxDpr = 2,142) {143  const [fallback, setFallback] = React.useState(false);144  const timeRef = React.useRef(12);145  const drawRef = React.useRef<(() => void) | null>(null);146  const uniRef = React.useRef(uniforms);147  uniRef.current = uniforms;148  const speedRef = React.useRef(speed);149  speedRef.current = speed;150 151  React.useEffect(() => {152    const root = rootRef.current;153    const canvas = canvasRef.current;154    if (!root || !canvas || fallback) return;155    const gl = canvas.getContext("webgl", { alpha: false, antialias: false, depth: false, stencil: false, powerPreference: "low-power" });156    if (!gl) { setFallback(true); return; }157    const vs = compile(gl, gl.VERTEX_SHADER, VERT);158    const fs = compile(gl, gl.FRAGMENT_SHADER, frag);159    const prog = gl.createProgram();160    const buf = gl.createBuffer();161    const dispose = () => {162      gl.deleteBuffer(buf);163      gl.deleteProgram(prog);164      gl.deleteShader(vs);165      gl.deleteShader(fs);166    };167    if (!vs || !fs || !prog || !buf) { dispose(); setFallback(true); return; }168    gl.attachShader(prog, vs);169    gl.attachShader(prog, fs);170    gl.linkProgram(prog);171    if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { dispose(); setFallback(true); return; }172    gl.useProgram(prog);173    gl.bindBuffer(gl.ARRAY_BUFFER, buf);174    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);175    const aPos = gl.getAttribLocation(prog, "a_pos");176    gl.enableVertexAttribArray(aPos);177    gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);178    const locs = new Map<string, WebGLUniformLocation | null>();179    const loc = (n: string) => {180      if (!locs.has(n)) locs.set(n, gl.getUniformLocation(prog, n));181      return locs.get(n) ?? null;182    };183    const resize = () => {184      const rect = root.getBoundingClientRect();185      const dpr = Math.min(maxDpr, window.devicePixelRatio || 1);186      canvas.width = Math.max(1, Math.round(rect.width * dpr));187      canvas.height = Math.max(1, Math.round(rect.height * dpr));188      gl.viewport(0, 0, canvas.width, canvas.height);189    };190    const draw = () => {191      if (gl.isContextLost()) return;192      gl.uniform2f(loc("u_res"), canvas.width, canvas.height);193      gl.uniform1f(loc("u_time"), timeRef.current);194      const u = uniRef.current();195      for (const k in u) {196        const v = u[k];197        if (typeof v === "number") gl.uniform1f(loc(k), v);198        else if (v.length === 2) gl.uniform2f(loc(k), v[0], v[1]);199        else gl.uniform3f(loc(k), v[0], v[1], v[2]);200      }201      gl.drawArrays(gl.TRIANGLES, 0, 3);202    };203    resize();204    draw();205    drawRef.current = draw;206    const ro = new ResizeObserver(() => { resize(); draw(); });207    ro.observe(root);208    const onLost = (e: Event) => { e.preventDefault(); drawRef.current = null; setFallback(true); };209    canvas.addEventListener("webglcontextlost", onLost);210    return () => {211      ro.disconnect();212      canvas.removeEventListener("webglcontextlost", onLost);213      drawRef.current = null;214      if (!gl.isContextLost()) dispose();215    };216  }, [frag, fallback, maxDpr, rootRef, canvasRef]);217 218  React.useEffect(() => {219    if (!running || fallback) return;220    let raf = 0;221    let last = performance.now();222    const tick = (now: number) => {223      const dt = Math.min(0.05, (now - last) / 1000);224      last = now;225      timeRef.current += dt * speedRef.current;226      drawRef.current?.();227      raf = requestAnimationFrame(tick);228    };229    raf = requestAnimationFrame(tick);230    return () => cancelAnimationFrame(raf);231  }, [running, fallback]);232 233  React.useEffect(() => { drawRef.current?.(); }, [paramsKey]);234 235  return fallback;236}237 238export function EclipseGradient({239  color1 = "#a8aeff",240  color2 = "#caabd3",241  color3 = "#0d3040",242  backgroundColor = "#020205",243  speed = 3,244  ringSize = 0.5,245  rayIntensity = 0,246  interactive = true,247  height = 380,248  borderRadius = 20,249  children,250  className = "",251  style,252}: EclipseGradientProps) {253  const rootRef = React.useRef<HTMLDivElement>(null);254  const canvasRef = React.useRef<HTMLCanvasElement>(null);255  const target = React.useRef({ x: 0, y: 0 });256  const ptr = React.useRef({ x: 0, y: 0 });257  const reduced = useReducedMotionPref();258  const inView = useInView(rootRef);259  const safeSpeed = Math.max(0, speed);260  const safeRing = Math.max(0.5, Math.min(1.6, ringSize));261  const safeRays = Math.max(0, Math.min(2, rayIntensity));262  const tilt = interactive && !reduced;263  const c1 = hexRgb(color1, [1, 0.85, 0.66]);264  const c2 = hexRgb(color2, [1, 0.42, 0.24]);265  const c3 = hexRgb(color3, [0.49, 0.83, 0.99]);266  const bg = hexRgb(backgroundColor, [0.008, 0.008, 0.02]);267 268  const fallback = useShader(269    rootRef,270    canvasRef,271    FRAG,272    () => {273      const p = ptr.current;274      p.x += (target.current.x - p.x) * 0.07;275      p.y += (target.current.y - p.y) * 0.07;276      return { u_c1: c1, u_c2: c2, u_c3: c3, u_bg: bg, u_ring: safeRing, u_rays: safeRays, u_ptr: [p.x, p.y] };277    },278    !reduced && inView && (safeSpeed > 0 || tilt),279    safeSpeed,280    [color1, color2, color3, backgroundColor, safeRing, safeRays].join("|"),281    2,282  );283 284  React.useEffect(() => {285    const root = rootRef.current;286    if (!root || !tilt) return;287    const move = (e: PointerEvent) => {288      const rect = root.getBoundingClientRect();289      target.current = {290        x: ((e.clientX - rect.left) / rect.width) * 2 - 1,291        y: -(((e.clientY - rect.top) / rect.height) * 2 - 1),292      };293      root.style.setProperty("--mf-ec-tx", `${(target.current.x * 10).toFixed(1)}px`);294      root.style.setProperty("--mf-ec-ty", `${(-target.current.y * 8).toFixed(1)}px`);295    };296    const leave = () => {297      target.current = { x: 0, y: 0 };298      root.style.setProperty("--mf-ec-tx", "0px");299      root.style.setProperty("--mf-ec-ty", "0px");300    };301    root.addEventListener("pointermove", move);302    root.addEventListener("pointerleave", leave);303    return () => {304      root.removeEventListener("pointermove", move);305      root.removeEventListener("pointerleave", leave);306      target.current = { x: 0, y: 0 };307    };308  }, [tilt]);309 310  const styleVars = {311    "--mf-ec-bg": backgroundColor,312    "--mf-ec-c1": color1,313    "--mf-ec-c2": color2,314    "--mf-ec-c3": color3,315    "--mf-ec-size": `${(52 * safeRing).toFixed(1)}%`,316    "--mf-ec-corona": `conic-gradient(from 0deg, ${rgba(c2, 0.9)}, ${rgba(c1, 0.35 + 0.3 * safeRays)}, ${rgba(c2, 0.5)}, ${rgba(c1, 0.8)}, ${rgba(c2, 0.4)}, ${rgba(c1, 0.6)}, ${rgba(c2, 0.9)})`,317    "--mf-ec-op": (0.35 + safeRays * 0.5).toFixed(2),318    "--mf-ec-dur": `${(40 / Math.max(0.1, safeSpeed)).toFixed(1)}s`,319    height: `${height}px`,320    borderRadius: `${borderRadius}px`,321    ...style,322  } as React.CSSProperties;323 324  return (325    <div ref={rootRef} style={styleVars} className={`${styles.root} ${className}`.trim()}>326      {fallback ? (327        <div aria-hidden="true" className={`${styles.fallback} ${safeSpeed === 0 ? styles.paused : ""}`.trim()}>328          <div className={styles.corona} />329          <div className={styles.disk} />330        </div>331      ) : (332        <canvas ref={canvasRef} aria-hidden="true" className={styles.canvas} />333      )}334      <div className={styles.content}>335        {children ?? (336          <div className={styles.copy}>337            <h3 className={styles.title}>Eclipse Gradient</h3>338            <p className={styles.subtitle}>A living corona blazing around a perfect black disk.</p>339          </div>340        )}341      </div>342    </div>343  );344}