ShadersGradient Backgroundsno dependencies

EmberGradient

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

Installation

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

npx motionfoundry add ember-gradient

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

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

Props

PropTypeDefault
color1

Hot core color of the rising heat (hex).

string'#ff7a1a'
color2

Outer, cooler glow color (hex).

string'#b3122e'
emberColor

Color of the drifting ember particles (hex).

string'#ffb347'
backgroundColor

Dark base color above the heat (hex).

string'#070304'
speed

Animation speed multiplier (0 pauses).

number1
emberCount

Approximate number of embers on screen (0–200).

number70
heat

How high and bright the heat glow rises (0.2–2).

number1
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; 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

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