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 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add eclipse-gradientWrites 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.
1import { EclipseGradient } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <EclipseGradient />6 );7}Props
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
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}