Flame Wrap
Wrap any element in a border of fire. The outline burns molten, sparks drift off the edges, and the content shimmers in the heat.
Demo
Recolor the flames, raise them higher, or melt the edges further. Use the toggle below the demo to try other UI.
Ember Blackwood
@emberburns
Pyrotechnics engineer. Building things that burn bright.
Install
npx shadcn@latest add @canvas-ui/flame-wrap-reactOr copy the source below into your project. Prefer WGSL? Switch the renderer above to the WebGPU build.
Dependencies
The install command above adds these automatically. If you copy the source by hand, install them yourself.
# No dependencies for the WebGL build.Code
"use client";
import {
useEffect,
useRef,
useState,
useSyncExternalStore,
type ReactNode,
} from "react";
export interface FlameWrapOptions {
/** Flame color as [r, g, b] in 0-1 range. */
color?: [number, number, number];
/** Overall brightness of the fire (0 to 3). */
intensity?: number;
/** Reach of the flames above the top edge in CSS pixels. */
height?: number;
/** Reach of the glow on the sides and bottom in CSS pixels. */
spread?: number;
/** Corner radius of the burning outline in CSS pixels. Match your content. */
radius?: number;
/** Animation speed multiplier for the whole effect. */
speed?: number;
/** Flame detail from 0 (broad licks) to 1 (fine licks). */
scale?: number;
/** Amplitude of the turbulence waves shaping the flames (0 to 1). */
turbulence?: number;
/** Frequency multiplier of the turbulence waves (0.2 to 3). */
turbulenceScale?: number;
/** How far from the edges the heat warps the content, in CSS pixels. */
turbulenceReach?: number;
/** Brightness of the spark highlights (0 to 3). 0 disables them. */
sparks?: number;
/** Size multiplier for individual sparks (0.2 to 3). */
sparkSize?: number;
/** How many sparks fly at once (0.3 to 2.5). */
sparkDensity?: number;
/** How fast sparks rise and flicker (0.1 to 3). */
sparkSpeed?: number;
/** Strength of the molten glow hugging the edges (0 to 3). */
rim?: number;
/** How far the flames eat into the content silhouette in CSS pixels. */
melt?: number;
/** Heat shimmer displacement of the content near the edges in CSS pixels. */
distortion?: number;
/** Amount of smoke drifting off the flames (0 to 2). */
smoke?: number;
/** Brightness of the glowing ember line on the burnt edges (0 to 2). */
ember?: number;
/** Darkness of the charred band on the content edges (0 to 2). */
scorch?: number;
}
export interface FlameWrapElements {
/** Canvas with layoutsubtree that hosts the HTML content. */
source: HTMLCanvasElement;
/** The element inside the source canvas that gets captured. */
content: HTMLElement;
/** Canvas the WebGL effect renders to. */
output: HTMLCanvasElement;
}
export interface FlameWrapInstance {
/** Update effect options live. */
setOptions: (options: FlameWrapOptions) => void;
/** Re-read canvas size. Call when the element is resized. */
resize: () => void;
/** Stop the loop and release all GPU resources. */
destroy: () => void;
}
const DEFAULTS: Required<FlameWrapOptions> = {
color: [0.31, 0.54, 1],
intensity: 0.5,
height: 170,
spread: 8,
radius: 40,
speed: 0.25,
scale: 0.75,
turbulence: 0.5,
turbulenceScale: 0.5,
turbulenceReach: 25,
sparks: 1.5,
sparkSize: 0.35,
sparkDensity: 1,
sparkSpeed: 1,
rim: 2.5,
melt: 4.5,
distortion: 10,
smoke: 1.5,
ember: 2,
scorch: 0,
};
type PaintableCanvas = HTMLCanvasElement & {
onpaint?: (() => void) | null;
requestPaint?: () => void;
};
type ElementImageContext = CanvasRenderingContext2D & {
drawElementImage?: (element: Element, x: number, y: number) => void;
};
const VERT = `#version 300 es
precision highp float;
layout(location = 0) in vec2 aPos;
out vec2 vUv;
void main () {
vUv = aPos * 0.5 + 0.5;
gl_Position = vec4(aPos, 0.0, 1.0);
}`;
const FRAG = `#version 300 es
precision highp float;
in vec2 vUv;
out vec4 outColor;
uniform sampler2D uContent;
uniform vec2 uResolution;
uniform float uTime;
uniform vec2 uRectCenter;
uniform vec2 uRectHalf;
uniform float uCorner;
uniform vec3 uColor;
uniform float uIntensity;
uniform float uHeight;
uniform float uSpread;
uniform float uScale;
uniform float uTurbulence;
uniform float uTurbScale;
uniform float uTurbReach;
uniform float uSparks;
uniform float uSparkSize;
uniform float uSparkDensity;
uniform float uSparkSpeed;
uniform float uRim;
uniform float uMelt;
uniform float uDistortion;
uniform float uSmoke;
uniform float uEmber;
uniform float uScorch;
uniform float uHasContent;
#define S(a, b, t) smoothstep(a, b, t)
vec3 permute (vec3 x) {
return mod(((x * 34.0) + 1.0) * x, 289.0);
}
float snoise (vec2 v) {
const vec4 C = vec4(
0.211324865405187, 0.366025403784439,
-0.577350269189626, 0.024390243902439
);
vec2 i = floor(v + dot(v, C.yy));
vec2 x0 = v - i + dot(i, C.xx);
vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
vec4 x12 = x0.xyxy + C.xxzz;
x12.xy -= i1;
i = mod(i, 289.0);
vec3 p = permute(
permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0)
);
vec3 m = max(
0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)),
0.0
);
m = m * m;
m = m * m;
vec3 x = 2.0 * fract(p * C.www) - 1.0;
vec3 h = abs(x) - 0.5;
vec3 ox = floor(x + 0.5);
vec3 a0 = x - ox;
m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h);
vec3 g;
g.x = a0.x * x0.x + h.x * x0.y;
g.yz = a0.yz * x12.xz + h.yz * x12.yw;
return 130.0 * dot(m, g);
}
float fbm (vec2 p) {
mat2 m = mat2(0.8, -0.6, 0.6, 0.8);
float v = 0.5 * snoise(p);
p = m * p * 2.03 + vec2(11.3, 7.1);
v += 0.27 * snoise(p);
p = m * p * 1.97 + vec2(3.7, 19.1);
v += 0.15 * snoise(p);
p = m * p * 2.01 + vec2(8.3, 2.9);
v += 0.08 * snoise(p);
return v * 0.5 + 0.5;
}
float fbm2 (vec2 p) {
float v = 0.62 * snoise(p);
v += 0.31 * snoise(mat2(0.8, -0.6, 0.6, 0.8) * p * 2.13 + vec2(5.2, 1.3));
return v * 0.54 + 0.5;
}
vec2 turbulence (vec2 p) {
float freq = 12.0 * clamp(uScale, 0.05, 1.0) * clamp(uTurbScale, 0.2, 3.0);
mat2 rot = mat2(0.6, -0.8, 0.8, 0.6);
for (float i = 0.0; i < 7.0; i++) {
float phase = freq * (p * rot).y + 6.0 * uTime + i;
p += uTurbulence * rot[0] * sin(phase) / freq;
rot *= mat2(0.6, -0.8, 0.8, 0.6);
freq *= 1.2;
}
return p;
}
vec3 hash3 (vec2 p) {
vec3 q = vec3(
dot(p, vec2(127.1, 311.7)),
dot(p, vec2(269.5, 183.3)),
dot(p, vec2(419.2, 371.9))
);
return fract(sin(q) * 43758.5453);
}
float sdRoundRect (vec2 p, vec2 b, float r) {
vec2 q = abs(p) - b + r;
return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r;
}
void main () {
vec2 frag = vUv * uResolution;
vec2 rel = frag - uRectCenter;
float unit = max(uHeight, 24.0);
float corner = min(uCorner, min(uRectHalf.x, uRectHalf.y));
float spreadPx = max(uSpread, 8.0);
float t = uTime;
float detail = clamp(uScale, 0.05, 1.0);
float d0 = sdRoundRect(rel, uRectHalf, corner);
float px = rel.x / unit;
float py = rel.y / unit;
float yA = max(rel.y - uRectHalf.y, 0.0) / unit;
float sway = snoise(vec2(px * 1.1, t * 0.5)) * 0.55
+ snoise(vec2(px * 2.4, t * 0.9 + 41.0)) * 0.25;
float sx = px + yA * sway;
float env = fbm2(vec2(sx * 1.6 * detail + 3.7, t * 0.55 - yA * 0.4));
float env2 = fbm2(vec2(sx * 3.6 * detail, t * 0.85 + 17.0 - yA * 0.6));
float tongue = clamp(
0.75 * S(0.3, 0.9, env) + 0.5 * S(0.4, 0.95, env2),
0.0,
1.0
);
float meltPx = max(uMelt, 1.0);
float biteTop = (3.0 + meltPx * 1.4) * (0.35 + 0.65 * tongue)
+ 2.0 * snoise(vec2(px * 5.0 * detail, t * 1.1 + 5.0));
float yF = uRectHalf.y - biteTop;
float frontTop = rel.y - yF;
float perim = fbm2(rel * (1.9 / unit) * detail + vec2(0.0, t * 0.4) + 31.0);
float biteSB = 3.0 + meltPx * (0.25 + 0.75 * perim);
float frontSB = d0 + biteSB;
float wTop = S(-0.62 * unit, -0.1 * unit, rel.y - uRectHalf.y)
* S(10.0, -30.0, abs(rel.x) - (uRectHalf.x - corner));
float front = mix(frontSB, frontTop, wTop);
float reach = mix(
spreadPx * 0.9,
unit * (0.2 + 0.45 * tongue),
wTop
);
float q = front / reach;
vec2 np = vec2(px * 2.3, py * 1.25 - t * 1.85) * detail;
np = turbulence(np);
float n = fbm(np);
float win = S(-0.08, 0.02, q);
float root = exp(-abs(q) * 5.0);
float ridge = 1.0 - abs(2.0 * n - 1.0);
float flameH = mix(1.0, 0.5 + 0.6 * tongue, wTop);
float g = max(q, 0.0) / flameH;
float shred = fbm2(np * 1.9 + 63.0);
g *= 1.0 + 0.7 * (shred - 0.5) * S(0.2, 0.8, g);
float dens = n * 0.95 + ridge * 0.45 - 0.18
+ (1.0 - min(g, 1.0)) * 0.3
- g * (0.9 + 0.25 * n);
dens = clamp(dens * 2.4, 0.0, 1.0) * win;
dens *= mix(1.0 - S(0.32, 1.05, q), 1.0 - S(0.9, 1.2, g), wTop);
float body = dens * dens * (3.0 - 2.0 * dens);
float emis = clamp(uIntensity, 0.0, 2.0);
float e = body * (0.55 + 0.75 * root) * (0.45 + 0.55 * n)
+ win * root * (0.1 + 0.4 * n);
e *= mix(0.45, 1.0, wTop) * max(emis, 0.001);
vec3 hot = mix(uColor, vec3(1.0), 0.35);
vec3 deep = mix(uColor, uColor * uColor, 0.5) * 0.9;
float ramp = 1.0 - exp(-e * 2.4);
vec3 fireCol = mix(deep, uColor, S(0.0, 0.55, ramp));
float core = ramp * (0.45 + 0.55 * exp(-g * 2.2)) * (0.5 + 0.5 * n);
fireCol = mix(fireCol, hot, S(0.7, 1.05, core));
fireCol *= 0.8 + 0.4 * ramp;
float fireA = clamp(1.0 - exp(-e * 3.4), 0.0, 1.0);
float halo = exp(-max(front, 0.0) / (spreadPx * 1.2)) * S(0.0, 3.0, front)
* (0.5 + 0.5 * n) * 0.3 * clamp(uRim, 0.0, 2.0) * mix(1.0, 0.45, wTop);
vec3 glow = uColor * halo * clamp(uIntensity, 0.0, 2.0);
if (uSparks > 0.001) {
float sSpeed = max(uSparkSpeed, 0.05);
float sCells = 5.0 * clamp(uSparkDensity, 0.3, 2.5);
float sSize = clamp(uSparkSize, 0.2, 3.0);
float gate = S(-0.05, 0.1, q) * (1.0 - S(1.3, 2.2, q)) * wTop;
float spark = 0.0;
for (float L = 0.0; L < 2.0; L++) {
float speed = 1.5 * sSpeed * (0.75 + 0.5 * L);
vec2 ps = vec2(px, py - t * speed);
ps.x += 0.08 * snoise(vec2(py * 0.9 + L * 5.0, t * 0.5));
float cells = sCells * (1.0 + 0.6 * L);
vec2 cl = floor(ps * cells) + L * 19.0;
vec2 fr = fract(ps * cells);
vec3 rnd = hash3(cl);
vec3 rnd2 = hash3(cl + 7.3);
float on = step(rnd2.x, 0.42);
float life = fract(rnd.z + t * sSpeed * (0.3 + 0.5 * rnd2.x));
vec2 ppos = vec2(0.5) + 0.56 * (rnd.xy - 0.5);
ppos.x += 0.14 * sin(t * (0.7 + rnd.z * 2.8) + rnd.y * 6.2832)
+ 0.1 * snoise(vec2(t * 0.6 + rnd.x * 9.0, cl.y * 0.7))
+ (life - 0.5) * 0.5 * (rnd2.y - 0.5);
ppos.y += (life - 0.5) * 0.3 * rnd2.y;
float tw = S(0.02, 0.2, life) * S(1.0, 0.55, life);
tw *= 0.75 + 0.25 * sin(t * (6.0 + rnd2.z * 9.0) + rnd.x * 6.2832);
vec2 pd = (fr - ppos) / cells * unit;
pd.y *= 0.55 + 0.3 * rnd2.z;
float dp = length(pd);
float r = (0.004 + 0.014 * rnd.y * rnd.y) * unit * sSize
* mix(1.15, 0.55, life);
float bmask = S(0.5, 0.32, max(abs(fr.x - 0.5), abs(fr.y - 0.5)));
float sbody = exp(-dp * dp / (r * r));
float sbloom = exp(-dp * dp / (r * r * 6.0)) * 0.3;
spark += (sbody + sbloom) * tw * tw * on * bmask * (1.0 - 0.35 * L);
}
spark *= gate * uSparks;
fireCol += mix(uColor, vec3(1.0), 0.55) * spark * 1.6;
fireA = clamp(fireA + spark * 0.85, 0.0, 1.0);
}
vec2 edgePx = min(frag, uResolution - frag);
float fadeW = max(24.0, spreadPx * 0.75);
float fade = S(0.0, fadeW, edgePx.x) * S(0.0, fadeW, edgePx.y);
fireA *= fade;
glow *= fade;
halo *= fade;
float wisp = S(0.45, 0.9, fbm2(np * 0.55 + vec2(0.0, 17.0)));
float smoke = S(1.55, 1.05, g) * S(0.85, 1.15, g)
* (1.0 - body) * wTop
* wisp * 0.055 * clamp(uSmoke, 0.0, 2.0) * fade;
vec3 smokeCol = mix(vec3(0.5), uColor, 0.5);
if (uHasContent < 0.5) {
float sA = clamp(smoke, 0.0, 1.0);
float a = clamp(fireA + sA * (1.0 - fireA), 0.0, 1.0);
outColor = vec4(
fireCol * fireA + glow + smokeCol * sA * (1.0 - fireA),
clamp(a + halo * 0.6, 0.0, 1.0)
);
return;
}
vec2 cUv = (rel + uRectHalf) / (2.0 * uRectHalf);
float inRect = step(abs(cUv.x - 0.5), 0.5) * step(abs(cUv.y - 0.5), 0.5);
float heatBand = exp(-abs(front) / max(uTurbReach, 4.0));
vec2 wob = vec2(
snoise(np * 1.7 + 9.0),
snoise(np * 1.7 + 27.0)
);
vec2 disp = wob * min(uDistortion, 32.0) * heatBand;
vec2 cUvD = clamp(cUv + disp / (2.0 * uRectHalf), vec2(0.002), vec2(0.998));
vec4 content = texture(uContent, vec2(cUvD.x, 1.0 - cUvD.y));
float burn = clamp(uIntensity, 0.0, 1.0);
float depth = max(-front, 0.0);
float charPatch = 0.5 + 0.5 * fbm2(rel * (2.6 / unit) * detail + 57.0);
float charW = mix(4.0, 6.0 + meltPx * 1.6, wTop) * charPatch;
float charT = (1.0 - S(charW, charW * 2.4, depth));
content.rgb = mix(
content.rgb,
content.rgb * vec3(0.22, 0.19, 0.17),
clamp(charT * 0.85 * burn * clamp(uScorch, 0.0, 2.0), 0.0, 1.0)
);
float emberW = mix(2.5, 5.5, wTop);
float emberN = 0.3 + 0.7 * fbm2(np * 2.2 + 73.0);
float emberK = clamp(uEmber, 0.0, 2.0);
float ember = exp(-depth / emberW) * emberN * emberK;
float whiteHot = exp(-depth / (emberW * 0.4)) * emberN * emberN * emberK;
content.rgb = mix(content.rgb, uColor * 1.2, clamp(ember, 0.0, 1.0) * burn);
content.rgb = mix(
content.rgb,
mix(uColor, vec3(1.0), 0.3) * 1.2,
clamp(whiteHot, 0.0, 1.0) * burn
);
float dn = fbm2(rel * (3.2 / unit) * detail + vec2(0.0, t * 0.5) + 91.0);
float dw = mix(2.0, 5.0, wTop);
float dissolve = S(-dw, dw, front + (dn - 0.5) * dw * 2.5);
float cA = content.a * (1.0 - dissolve) * inRect;
float smk = smoke * (1.0 - cA);
float baseA = min(cA + smk, 1.0);
vec3 base = content.rgb * cA + smokeCol * smk;
vec3 col = fireCol * fireA + base * (1.0 - fireA) + glow;
float alpha = clamp(fireA + baseA * (1.0 - fireA) + halo * 0.5, 0.0, 1.0);
outColor = vec4(col, alpha);
}`;
export function supportsHtmlInCanvas(): boolean {
if (typeof document === "undefined") return false;
const probe = document.createElement("canvas") as PaintableCanvas;
const ctx = probe.getContext("2d") as ElementImageContext | null;
return Boolean(
ctx &&
typeof ctx.drawElementImage === "function" &&
typeof probe.requestPaint === "function",
);
}
export function createFlameWrap(
elements: FlameWrapElements,
options: FlameWrapOptions = {},
): FlameWrapInstance | null {
const config = { ...DEFAULTS, ...options };
const { source, content, output } = elements;
const gl = output.getContext("webgl2", {
alpha: true,
depth: false,
stencil: false,
antialias: false,
premultipliedAlpha: true,
});
if (!gl || gl.isContextLost()) return null;
const sourceCtx = source.getContext("2d") as ElementImageContext | null;
const paintable = source as PaintableCanvas;
const htmlInCanvas = Boolean(
sourceCtx &&
typeof sourceCtx.drawElementImage === "function" &&
typeof paintable.requestPaint === "function",
);
let contentDirty = false;
let wake = () => {};
if (htmlInCanvas) {
paintable.onpaint = () => {
try {
sourceCtx!.reset();
sourceCtx!.drawElementImage!(content, 0, 0);
contentDirty = true;
wake();
} catch {}
};
}
function compile(type: number, text: string): WebGLShader {
const shader = gl!.createShader(type)!;
gl!.shaderSource(shader, text);
gl!.compileShader(shader);
if (!gl!.getShaderParameter(shader, gl!.COMPILE_STATUS)) {
console.error("FlameWrap shader error:", gl!.getShaderInfoLog(shader));
}
return shader;
}
const vertexShader = compile(gl.VERTEX_SHADER, VERT);
const fragmentShader = compile(gl.FRAGMENT_SHADER, FRAG);
const program = gl.createProgram()!;
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
const uniforms: Record<string, WebGLUniformLocation> = {};
const count = gl.getProgramParameter(program, gl.ACTIVE_UNIFORMS);
for (let i = 0; i < count; i++) {
const info = gl.getActiveUniform(program, i)!;
uniforms[info.name] = gl.getUniformLocation(program, info.name)!;
}
const quad = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, quad);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
gl.STATIC_DRAW,
);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
const contentTexture = gl.createTexture()!;
gl.bindTexture(gl.TEXTURE_2D, contentTexture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
1,
1,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
new Uint8Array([0, 0, 0, 0]),
);
const rect = { cx: 0, cy: 0, hx: 1, hy: 1 };
let dpr = 1;
function syncCanvasSize() {
dpr = Math.min(window.devicePixelRatio || 1, 2);
const width = Math.max(1, Math.round(output.clientWidth * dpr));
const height = Math.max(1, Math.round(output.clientHeight * dpr));
if (output.width !== width || output.height !== height) {
output.width = width;
output.height = height;
}
const box = htmlInCanvas ? source : content;
const outRect = output.getBoundingClientRect();
const boxRect = box.getBoundingClientRect();
if (outRect.width > 0 && boxRect.width > 0) {
rect.cx = (boxRect.left + boxRect.right) / 2 - outRect.left;
rect.cy = outRect.bottom - (boxRect.top + boxRect.bottom) / 2;
rect.hx = boxRect.width / 2;
rect.hy = boxRect.height / 2;
}
if (htmlInCanvas) {
const cssWidth = Math.max(1, Math.round(source.clientWidth));
const cssHeight = Math.max(1, Math.round(source.clientHeight));
if (
source.width !== cssWidth * dpr ||
source.height !== cssHeight * dpr
) {
source.width = cssWidth * dpr;
source.height = cssHeight * dpr;
}
paintable.requestPaint!();
}
}
syncCanvasSize();
function uploadContent() {
if (!htmlInCanvas || !contentDirty) return;
contentDirty = false;
gl!.bindTexture(gl!.TEXTURE_2D, contentTexture);
gl!.texImage2D(
gl!.TEXTURE_2D,
0,
gl!.RGBA,
gl!.RGBA,
gl!.UNSIGNED_BYTE,
source,
);
sourceCtx!.clearRect(0, 0, source.width, source.height);
}
let time = 0;
function render() {
uploadContent();
gl!.useProgram(program);
gl!.activeTexture(gl!.TEXTURE0);
gl!.bindTexture(gl!.TEXTURE_2D, contentTexture);
gl!.uniform1i(uniforms.uContent, 0);
gl!.uniform2f(uniforms.uResolution, output.width, output.height);
gl!.uniform1f(uniforms.uTime, time);
gl!.uniform2f(uniforms.uRectCenter, rect.cx * dpr, rect.cy * dpr);
gl!.uniform2f(
uniforms.uRectHalf,
Math.max(rect.hx * dpr, 1),
Math.max(rect.hy * dpr, 1),
);
gl!.uniform1f(uniforms.uCorner, Math.max(config.radius, 0) * dpr);
gl!.uniform3f(
uniforms.uColor,
config.color[0],
config.color[1],
config.color[2],
);
gl!.uniform1f(uniforms.uIntensity, Math.max(config.intensity, 0));
gl!.uniform1f(uniforms.uHeight, Math.max(config.height, 24) * dpr);
gl!.uniform1f(uniforms.uSpread, Math.max(config.spread, 8) * dpr);
gl!.uniform1f(uniforms.uScale, Math.max(config.scale, 0.05));
gl!.uniform1f(uniforms.uTurbulence, Math.max(config.turbulence, 0));
gl!.uniform1f(uniforms.uTurbScale, Math.max(config.turbulenceScale, 0.2));
gl!.uniform1f(
uniforms.uTurbReach,
Math.max(config.turbulenceReach, 4) * dpr,
);
gl!.uniform1f(uniforms.uSparks, Math.max(config.sparks, 0));
gl!.uniform1f(uniforms.uSparkSize, Math.max(config.sparkSize, 0.2));
gl!.uniform1f(uniforms.uSparkDensity, Math.max(config.sparkDensity, 0.3));
gl!.uniform1f(uniforms.uSparkSpeed, Math.max(config.sparkSpeed, 0.05));
gl!.uniform1f(uniforms.uRim, Math.max(config.rim, 0));
gl!.uniform1f(uniforms.uMelt, Math.max(config.melt, 0) * dpr);
gl!.uniform1f(uniforms.uDistortion, Math.max(config.distortion, 0) * dpr);
gl!.uniform1f(uniforms.uSmoke, Math.max(config.smoke, 0));
gl!.uniform1f(uniforms.uEmber, Math.max(config.ember, 0));
gl!.uniform1f(uniforms.uScorch, Math.max(config.scorch, 0));
gl!.uniform1f(uniforms.uHasContent, htmlInCanvas ? 1 : 0);
gl!.bindFramebuffer(gl!.FRAMEBUFFER, null);
gl!.viewport(0, 0, output.width, output.height);
gl!.drawArrays(gl!.TRIANGLE_STRIP, 0, 4);
}
let raf = 0;
let lastTime = performance.now();
let destroyed = false;
let running = false;
let visible = true;
const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
let reducedMotion = motionQuery.matches;
function frame(now: number) {
if (destroyed) return;
if (!visible) {
running = false;
return;
}
const delta = Math.min((now - lastTime) / 1000, 1 / 30);
lastTime = now;
if (!reducedMotion) time += delta * config.speed;
render();
if (reducedMotion && !contentDirty) {
running = false;
return;
}
raf = requestAnimationFrame(frame);
}
function start() {
if (destroyed || running || !visible) return;
running = true;
lastTime = performance.now();
raf = requestAnimationFrame(frame);
}
wake = start;
start();
function onMotionChange() {
reducedMotion = motionQuery.matches;
start();
}
motionQuery.addEventListener("change", onMotionChange);
const observer = new ResizeObserver(() => {
syncCanvasSize();
start();
});
observer.observe(output);
observer.observe(content);
const intersection = new IntersectionObserver((entries) => {
visible = entries[entries.length - 1]?.isIntersecting ?? true;
if (visible) start();
});
intersection.observe(output);
return {
setOptions(next) {
let changed = false;
for (const [key, value] of Object.entries(next)) {
const prev = config[key as keyof typeof config];
if (Array.isArray(value) && Array.isArray(prev)) {
if (
value.length !== prev.length ||
value.some((item, i) => item !== prev[i])
) {
changed = true;
break;
}
} else if (prev !== value) {
changed = true;
break;
}
}
Object.assign(config, next);
if (!changed) return;
syncCanvasSize();
start();
},
resize() {
syncCanvasSize();
start();
},
destroy() {
destroyed = true;
cancelAnimationFrame(raf);
observer.disconnect();
intersection.disconnect();
motionQuery.removeEventListener("change", onMotionChange);
gl!.deleteTexture(contentTexture);
gl!.deleteProgram(program);
gl!.deleteShader(vertexShader);
gl!.deleteShader(fragmentShader);
gl!.deleteBuffer(quad);
if (htmlInCanvas) paintable.onpaint = null;
},
};
}
export interface FlameWrapProps extends FlameWrapOptions {
children: ReactNode;
className?: string;
style?: React.CSSProperties;
}
const emptySubscribe = () => () => {};
export function FlameWrap({
children,
className,
style,
...options
}: FlameWrapProps) {
const sourceRef = useRef<HTMLCanvasElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const outputRef = useRef<HTMLCanvasElement>(null);
const instanceRef = useRef<FlameWrapInstance | null>(null);
const [initialOptions] = useState(options);
const [failed, setFailed] = useState(false);
const supported = useSyncExternalStore(
emptySubscribe,
supportsHtmlInCanvas,
() => false,
);
const native = supported && !failed;
const reach = Math.round(Math.max(options.height ?? 170, 24) * 1.5) + 40;
const glow = Math.round(Math.max(options.spread ?? 8, 8) * 3) + 16;
useEffect(() => {
const source = sourceRef.current;
const content = contentRef.current;
const output = outputRef.current;
if (!source || !content || !output) return;
instanceRef.current = createFlameWrap(
{ source, content, output },
initialOptions,
);
if (native && !instanceRef.current) setFailed(true);
return () => {
instanceRef.current?.destroy();
instanceRef.current = null;
};
}, [initialOptions, native]);
useEffect(() => {
instanceRef.current?.setOptions(options);
});
return (
<div className={className} style={{ position: "relative", ...style }}>
<canvas
ref={sourceRef}
// @ts-expect-error experimental html-in-canvas attribute
layoutsubtree="true"
suppressHydrationWarning
style={
native
? { position: "absolute", inset: 0, width: "100%", height: "100%" }
: { display: "none" }
}
>
{native ? (
<div
ref={contentRef}
style={{
position: "relative",
width: "100%",
height: "100%",
overflow: "auto",
}}
>
{children}
</div>
) : null}
</canvas>
{!native ? (
<div
ref={contentRef}
style={{
position: "relative",
width: "100%",
height: "100%",
overflow: "auto",
}}
>
{children}
</div>
) : null}
<canvas
ref={outputRef}
aria-hidden
style={{
position: "absolute",
top: -reach,
right: -glow,
bottom: -glow,
left: -glow,
width: `calc(100% + ${glow * 2}px)`,
height: `calc(100% + ${reach + glow}px)`,
pointerEvents: "none",
}}
/>
</div>
);
}
export default FlameWrap;
API reference
| Property | Type | Default |
|---|---|---|
colorFlame color as [r, g, b] with each channel in 0 to 1. | [number, number, number] | [0.31, 0.54, 1] |
intensityOverall brightness of the fire. | number | 0.5 |
heightHow far the flames reach above the top edge, in pixels. | number | 170 |
spreadHow far the glow reaches past the sides and bottom, in pixels. | number | 8 |
radiusCorner radius of the burning outline in pixels. Match it to your content so the fire hugs the shape. | number | 40 |
speedAnimation speed multiplier for the whole effect. | number | 0.25 |
scaleFlame detail from 0 (broad, soft licks) to 1 (fine, busy licks). | number | 0.75 |
turbulenceAmplitude of the turbulence waves shaping the flames. | number | 0.5 |
turbulenceScaleFrequency multiplier of the turbulence waves. Lower is lazier, higher is choppier. | number | 0.5 |
turbulenceReachHow far from the edges the heat warps the content, in pixels. | number | 25 |
sparksBrightness of the spark highlights. 0 disables them. | number | 1.5 |
sparkSizeSize multiplier for individual sparks. | number | 0.35 |
sparkDensityHow many sparks fly at once. | number | 1 |
sparkSpeedHow fast sparks rise and flicker. | number | 1 |
rimStrength of the molten glow hugging the edges. | number | 2.5 |
meltHow far the fire eats into the content silhouette, in pixels, so the edges look like they are burning away. | number | 4.5 |
distortionHeat shimmer displacement of the content near the edges, in pixels. | number | 10 |
smokeAmount of smoke drifting off the flames. | number | 1.5 |
emberBrightness of the glowing ember line on the burnt edges. | number | 2 |
scorchDarkness of the charred band on the content edges. | number | 0 |