A raw WebGL fire shader: an fbm-turbulent heat glow climbs from the bottom edge through two heat colors into a dark top, while three parallax layers of flickering, swaying ember particles drift upward and burn out. Falls back to a CSS heat glow with rising spark elements when WebGL is unavailable.
Preview
Ember Gradient
Heat rising from below, scattering drifting sparks.
Customize
7 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add ember-gradientWrites components/ui/ember-gradient.tsx and components/styles/ember-gradient.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { EmberGradient } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <EmberGradient />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; fallback sparks are hidden). 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/ember-gradient.module.css";5 6export interface EmberGradientProps {7 /** Hot core color of the rising heat */8 color1?: string;9 /** Outer, cooler glow color */10 color2?: string;11 /** Color of the drifting ember particles */12 emberColor?: string;13 /** Dark base color above the heat */14 backgroundColor?: string;15 /** Animation speed multiplier (1 = default, 0 = paused) */16 speed?: number;17 /** Approximate number of embers on screen (0–200) */18 emberCount?: number;19 /** How high and bright the heat glow rises (0.2–2) */20 heat?: 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_ember; uniform vec3 u_bg;44uniform float u_count; uniform float u_heat;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 / u_res.y;52 float y = gl_FragCoord.y / u_res.y;53 float aspect = u_res.x / u_res.y;54 float t = u_time;55 float n = fbm(vec2(uv.x * 2.4, y * 1.5 - t * 0.55));56 float n2 = noise(vec2(uv.x * 5.0 + t * 0.2, y * 3.0 - t * 1.1));57 float g = exp(-y * 3.1 / u_heat) * (0.5 + 0.9 * n + 0.25 * n2);58 vec3 heat = u_c2 * smoothstep(0.0, 0.95, g) * 0.95 + u_c1 * pow(g, 2.2) * 1.35 + vec3(1.0, 0.9, 0.7) * pow(g, 5.0) * 0.45;59 vec3 col = heat;60 float prob = clamp(u_count / (aspect * 286.0), 0.0, 1.0);61 for (int l = 0; l < 3; l++) {62 float fl = float(l);63 float s = 6.0 + fl * fl * 1.5 + fl * 1.5;64 vec2 p = uv * s + vec2(fl * 13.7, 0.0);65 float column = floor(p.x);66 float rise = 0.55 + 0.7 * hash(vec2(column, fl + 2.0));67 p.y -= t * rise * (1.25 - fl * 0.2);68 vec2 id = floor(p);69 float h = hash(id + fl * 31.0);70 if (h < prob) {71 vec2 fp = fract(p) - 0.5;72 vec2 off = (vec2(hash(id + 1.3), hash(id + 7.1)) - 0.5) * 0.5;73 off.x += sin(t * (1.5 + h * 2.0) + h * 40.0) * 0.14;74 float d = length(fp - off);75 float size = 0.045 + 0.04 * hash(id + 4.4);76 float flick = 0.6 + 0.4 * sin(t * (6.0 + h * 9.0) + h * 70.0);77 float life = smoothstep(1.05, 0.05, y) * flick * (1.0 - fl * 0.22);78 col += (u_ember * smoothstep(size * 2.6, 0.0, d) * 0.55 + mix(u_ember, vec3(1.0), 0.5) * smoothstep(size, 0.0, d) * 1.4) * life;79 }80 }81 vec3 c = u_bg + (1.0 - exp(-col * 1.15));82 vec2 v = gl_FragCoord.xy / u_res - 0.5;83 c *= 1.0 - 0.45 * dot(v, v);84 gl_FragColor = vec4(c, 1.0);85}`;86 87const VERT = "attribute vec2 a_pos;void main(){gl_Position=vec4(a_pos,0.0,1.0);}";88 89function hexRgb(hex: string, fb: RGB): RGB {90 const m = hex.trim().replace(/^#/, "");91 const full = m.length === 3 ? m.split("").map((ch) => ch + ch).join("") : m;92 if (!/^[0-9a-fA-F]{6}$/.test(full)) return fb;93 const n = parseInt(full, 16);94 return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];95}96 97const rgba = (c: RGB, a: number) => `rgba(${Math.round(c[0] * 255)},${Math.round(c[1] * 255)},${Math.round(c[2] * 255)},${a})`;98 99function useReducedMotionPref() {100 const [reduced, setReduced] = React.useState(false);101 React.useEffect(() => {102 if (typeof window === "undefined" || !window.matchMedia) return;103 const mq = window.matchMedia("(prefers-reduced-motion: reduce)");104 const update = () => setReduced(mq.matches);105 update();106 mq.addEventListener("change", update);107 return () => mq.removeEventListener("change", update);108 }, []);109 return reduced;110}111 112function useInView(ref: React.RefObject<HTMLElement | null>) {113 const [inView, setInView] = React.useState(true);114 React.useEffect(() => {115 const el = ref.current;116 if (!el || typeof IntersectionObserver === "undefined") return;117 const io = new IntersectionObserver((entries) => {118 const entry = entries[entries.length - 1];119 if (entry) setInView(entry.isIntersecting);120 }, { rootMargin: "80px" });121 io.observe(el);122 return () => io.disconnect();123 }, [ref]);124 return inView;125}126 127function compile(gl: WebGLRenderingContext, type: number, src: string) {128 const sh = gl.createShader(type);129 if (!sh) return null;130 gl.shaderSource(sh, src);131 gl.compileShader(sh);132 if (gl.getShaderParameter(sh, gl.COMPILE_STATUS)) return sh;133 gl.deleteShader(sh);134 return null;135}136 137/** Full-screen-triangle WebGL renderer; returns true when the CSS fallback should be shown instead */138function useShader(139 rootRef: React.RefObject<HTMLDivElement | null>,140 canvasRef: React.RefObject<HTMLCanvasElement | null>,141 frag: string,142 uniforms: () => UniformMap,143 running: boolean,144 speed: number,145 paramsKey: string,146 maxDpr = 2,147) {148 const [fallback, setFallback] = React.useState(false);149 const timeRef = React.useRef(12);150 const drawRef = React.useRef<(() => void) | null>(null);151 const uniRef = React.useRef(uniforms);152 uniRef.current = uniforms;153 const speedRef = React.useRef(speed);154 speedRef.current = speed;155 156 React.useEffect(() => {157 const root = rootRef.current;158 const canvas = canvasRef.current;159 if (!root || !canvas || fallback) return;160 const gl = canvas.getContext("webgl", { alpha: false, antialias: false, depth: false, stencil: false, powerPreference: "low-power" });161 if (!gl) { setFallback(true); return; }162 const vs = compile(gl, gl.VERTEX_SHADER, VERT);163 const fs = compile(gl, gl.FRAGMENT_SHADER, frag);164 const prog = gl.createProgram();165 const buf = gl.createBuffer();166 const dispose = () => {167 gl.deleteBuffer(buf);168 gl.deleteProgram(prog);169 gl.deleteShader(vs);170 gl.deleteShader(fs);171 };172 if (!vs || !fs || !prog || !buf) { dispose(); setFallback(true); return; }173 gl.attachShader(prog, vs);174 gl.attachShader(prog, fs);175 gl.linkProgram(prog);176 if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { dispose(); setFallback(true); return; }177 gl.useProgram(prog);178 gl.bindBuffer(gl.ARRAY_BUFFER, buf);179 gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);180 const aPos = gl.getAttribLocation(prog, "a_pos");181 gl.enableVertexAttribArray(aPos);182 gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);183 const locs = new Map<string, WebGLUniformLocation | null>();184 const loc = (n: string) => {185 if (!locs.has(n)) locs.set(n, gl.getUniformLocation(prog, n));186 return locs.get(n) ?? null;187 };188 const resize = () => {189 const rect = root.getBoundingClientRect();190 const dpr = Math.min(maxDpr, window.devicePixelRatio || 1);191 canvas.width = Math.max(1, Math.round(rect.width * dpr));192 canvas.height = Math.max(1, Math.round(rect.height * dpr));193 gl.viewport(0, 0, canvas.width, canvas.height);194 };195 const draw = () => {196 if (gl.isContextLost()) return;197 gl.uniform2f(loc("u_res"), canvas.width, canvas.height);198 gl.uniform1f(loc("u_time"), timeRef.current);199 const u = uniRef.current();200 for (const k in u) {201 const v = u[k];202 if (typeof v === "number") gl.uniform1f(loc(k), v);203 else if (v.length === 2) gl.uniform2f(loc(k), v[0], v[1]);204 else gl.uniform3f(loc(k), v[0], v[1], v[2]);205 }206 gl.drawArrays(gl.TRIANGLES, 0, 3);207 };208 resize();209 draw();210 drawRef.current = draw;211 const ro = new ResizeObserver(() => { resize(); draw(); });212 ro.observe(root);213 const onLost = (e: Event) => { e.preventDefault(); drawRef.current = null; setFallback(true); };214 canvas.addEventListener("webglcontextlost", onLost);215 return () => {216 ro.disconnect();217 canvas.removeEventListener("webglcontextlost", onLost);218 drawRef.current = null;219 if (!gl.isContextLost()) dispose();220 };221 }, [frag, fallback, maxDpr, rootRef, canvasRef]);222 223 React.useEffect(() => {224 if (!running || fallback) return;225 let raf = 0;226 let last = performance.now();227 const tick = (now: number) => {228 const dt = Math.min(0.05, (now - last) / 1000);229 last = now;230 timeRef.current += dt * speedRef.current;231 drawRef.current?.();232 raf = requestAnimationFrame(tick);233 };234 raf = requestAnimationFrame(tick);235 return () => cancelAnimationFrame(raf);236 }, [running, fallback]);237 238 React.useEffect(() => { drawRef.current?.(); }, [paramsKey]);239 240 return fallback;241}242 243const rand = (n: number) => {244 const x = Math.sin(n * 9301 + 49297) * 233280;245 return x - Math.floor(x);246};247 248export function EmberGradient({249 color1 = "#ff7a1a",250 color2 = "#b3122e",251 emberColor = "#ffb347",252 backgroundColor = "#070304",253 speed = 1,254 emberCount = 70,255 heat = 1,256 height = 380,257 borderRadius = 20,258 children,259 className = "",260 style,261}: EmberGradientProps) {262 const rootRef = React.useRef<HTMLDivElement>(null);263 const canvasRef = React.useRef<HTMLCanvasElement>(null);264 const reduced = useReducedMotionPref();265 const inView = useInView(rootRef);266 const safeSpeed = Math.max(0, speed);267 const safeCount = Math.max(0, Math.min(200, Math.round(emberCount)));268 const safeHeat = Math.max(0.2, Math.min(2, heat));269 const c1 = hexRgb(color1, [1, 0.48, 0.1]);270 const c2 = hexRgb(color2, [0.7, 0.07, 0.18]);271 const em = hexRgb(emberColor, [1, 0.7, 0.28]);272 const bg = hexRgb(backgroundColor, [0.027, 0.012, 0.016]);273 274 const fallback = useShader(275 rootRef,276 canvasRef,277 FRAG,278 () => ({ u_c1: c1, u_c2: c2, u_ember: em, u_bg: bg, u_count: safeCount, u_heat: safeHeat }),279 !reduced && inView && safeSpeed > 0,280 safeSpeed,281 [color1, color2, emberColor, backgroundColor, safeCount, safeHeat].join("|"),282 );283 284 const dur = 9 / Math.max(0.1, safeSpeed);285 const styleVars = {286 "--mf-em-bg": backgroundColor,287 "--mf-em-heat": `radial-gradient(${(70 + safeHeat * 30).toFixed(0)}% ${(35 + safeHeat * 30).toFixed(0)}% at 50% 100%, ${rgba(c1, 0.95)}, ${rgba(c2, 0.7)} 45%, transparent 80%)`,288 "--mf-em-color": emberColor,289 "--mf-em-dur": `${(dur * 0.5).toFixed(1)}s`,290 height: `${height}px`,291 borderRadius: `${borderRadius}px`,292 ...style,293 } as React.CSSProperties;294 295 return (296 <div ref={rootRef} style={styleVars} className={`${styles.root} ${className}`.trim()}>297 {fallback ? (298 <div aria-hidden="true" className={`${styles.fallback} ${safeSpeed === 0 ? styles.paused : ""}`.trim()}>299 <div className={styles.heat} />300 {Array.from({ length: Math.min(40, Math.ceil(safeCount / 2)) }, (_, i) => (301 <span302 key={i}303 className={styles.ember}304 style={{305 left: `${(rand(i) * 100).toFixed(2)}%`,306 width: `${(2 + rand(i + 3) * 3).toFixed(1)}px`,307 height: `${(2 + rand(i + 3) * 3).toFixed(1)}px`,308 animationDuration: `${(dur * (0.6 + rand(i + 1) * 0.8)).toFixed(2)}s`,309 animationDelay: `-${(rand(i + 2) * dur).toFixed(2)}s`,310 }}311 />312 ))}313 </div>314 ) : (315 <canvas ref={canvasRef} aria-hidden="true" className={styles.canvas} />316 )}317 <div className={styles.content}>318 {children ?? (319 <div className={styles.copy}>320 <h3 className={styles.title}>Ember Gradient</h3>321 <p className={styles.subtitle}>Heat rising from below, scattering drifting sparks.</p>322 </div>323 )}324 </div>325 </div>326 );327}