ElectricLogo
Turns any SVG or PNG into a living lightning outline, with flowing strands, arcs that leap off the edges and a charge that follows the cursor.
Live preview
Loading demo…
Installation
npx shadcn@latest add https://reactbits.dev/r/ElectricLogo-TS-TW.json
Dependencies: ogl
Source
'use client';
import { useEffect, useRef } from 'react';
import type { CSSProperties } from 'react';
import { Renderer, Program, Mesh, Triangle, Texture } from 'ogl';
const BOLT = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64"><path d="M39 3 12 37h17l-4 24 27-34H35z" fill="#fff"/></svg>'
)}`;
const RASTER = 560;
const CELL = 4;
const FAR = 1e20;
const ARCS = 5;
const PULSES = 3;
const PIXEL_BUDGET = 4e6;
type Rgb = [number, number, number];
export interface ElectricLogoProps {
src?: string;
color?: string;
glowColor?: string;
scale?: number;
intensity?: number;
glow?: number;
thickness?: number;
strands?: number;
bend?: number;
crackle?: number;
arcs?: number;
flicker?: number;
fill?: number;
speed?: number;
interactive?: boolean;
cursorIntensity?: number;
cursorRadius?: number;
theme?: 'dark' | 'light';
onRender?: (canvas: HTMLCanvasElement) => void;
className?: string;
style?: CSSProperties;
}
type Settings = Required<Omit<ElectricLogoProps, 'src' | 'className' | 'style' | 'onRender'>> &
Pick<ElectricLogoProps, 'onRender'>;
interface Shape {
field: Float32Array;
edges: number[];
width: number;
height: number;
pad: number;
logoWidth: number;
logoHeight: number;
glow: Float32Array;
glowWidth: number;
glowHeight: number;
glowOffset: number;
}
interface Slot {
shape: Shape | null;
field: Texture;
glow: Texture;
}
interface Pulse {
x: number;
y: number;
born: number;
}
interface Point {
x: number;
y: number;
}
interface Focus {
x: number;
y: number;
radius: number;
}
interface Spark {
ax: number;
ay: number;
bx: number;
by: number;
bow: number;
seed: number;
born: number;
life: number;
}
const hexToRgb = (hex: string): Rgb => {
let h = String(hex || '').replace('#', '');
if (h.length === 3) h = h.replace(/./g, c => c + c);
const n = parseInt(h.slice(0, 6), 16);
return Number.isNaN(n) ? [1, 1, 1] : [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
};
const transformLine = (f: Float64Array, d: Float64Array, v: Int32Array, z: Float64Array, n: number) => {
let k = 0;
v[0] = 0;
z[0] = -FAR;
z[1] = FAR;
for (let q = 1; q < n; q++) {
let s = (f[q] + q * q - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]);
while (s <= z[k]) {
k--;
s = (f[q] + q * q - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]);
}
k++;
v[k] = q;
z[k] = s;
z[k + 1] = FAR;
}
k = 0;
for (let q = 0; q < n; q++) {
while (z[k + 1] < q) k++;
d[q] = (q - v[k]) * (q - v[k]) + f[v[k]];
}
};
const transformGrid = (grid: Float32Array, w: number, h: number) => {
const n = Math.max(w, h);
const f = new Float64Array(n);
const d = new Float64Array(n);
const v = new Int32Array(n);
const z = new Float64Array(n + 1);
for (let x = 0; x < w; x++) {
for (let y = 0; y < h; y++) f[y] = grid[y * w + x];
transformLine(f, d, v, z, h);
for (let y = 0; y < h; y++) grid[y * w + x] = d[y];
}
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) f[x] = grid[y * w + x];
transformLine(f, d, v, z, w);
for (let x = 0; x < w; x++) grid[y * w + x] = d[x];
}
};
const blurLine = (src: Float32Array, dst: Float32Array, offset: number, stride: number, n: number, r: number) => {
const scale = 1 / (2 * r + 1);
let sum = 0;
for (let i = 0; i <= r && i < n; i++) sum += src[offset + i * stride];
for (let i = 0; i < n; i++) {
dst[offset + i * stride] = sum * scale;
if (i + r + 1 < n) sum += src[offset + (i + r + 1) * stride];
if (i - r >= 0) sum -= src[offset + (i - r) * stride];
}
};
const blurGrid = (grid: Float32Array, w: number, h: number, r: number) => {
const tmp = new Float32Array(w * h);
for (let pass = 0; pass < 3; pass++) {
for (let y = 0; y < h; y++) blurLine(grid, tmp, y * w, 1, w, r);
for (let x = 0; x < w; x++) blurLine(tmp, grid, x, w, h, r);
}
};
const readCoverage = (data: Uint8ClampedArray, w: number, h: number) => {
const coverage = new Float32Array(w * h);
let clear = 0;
for (let i = 0; i < w * h; i++) if (data[i * 4 + 3] < 250) clear++;
if (clear > w * h * 0.01) {
for (let i = 0; i < w * h; i++) coverage[i] = data[i * 4 + 3] / 255;
return coverage;
}
let r = 0;
let g = 0;
let b = 0;
let n = 0;
const sample = (i: number) => {
r += data[i * 4];
g += data[i * 4 + 1];
b += data[i * 4 + 2];
n++;
};
for (let x = 0; x < w; x++) {
sample(x);
sample((h - 1) * w + x);
}
for (let y = 0; y < h; y++) {
sample(y * w);
sample(y * w + w - 1);
}
r /= n;
g /= n;
b /= n;
for (let i = 0; i < w * h; i++) {
const diff = Math.max(Math.abs(data[i * 4] - r), Math.abs(data[i * 4 + 1] - g), Math.abs(data[i * 4 + 2] - b));
coverage[i] = Math.min(1, Math.max(0, (diff - 24) / 48));
}
return coverage;
};
const sampleField = (shape: Shape, x: number, y: number) => {
const { field, width, height } = shape;
const cx = Math.min(Math.max(x, 0.5), width - 0.5);
const cy = Math.min(Math.max(y, 0.5), height - 0.5);
const x0 = Math.min(Math.floor(cx - 0.5), width - 2);
const y0 = Math.min(Math.floor(cy - 0.5), height - 2);
const tx = cx - 0.5 - x0;
const ty = cy - 0.5 - y0;
const i = y0 * width + x0;
const top = field[i] + (field[i + 1] - field[i]) * tx;
const bottom = field[i + width] + (field[i + width + 1] - field[i + width]) * tx;
return top + (bottom - top) * ty + Math.hypot(x - cx, y - cy);
};
const traceShape = (image: HTMLImageElement): Shape | null => {
const iw = image.naturalWidth || image.width;
const ih = image.naturalHeight || image.height;
if (!iw || !ih) return null;
const fit = RASTER / Math.max(iw, ih);
const w = Math.max(2, Math.round(iw * fit));
const h = Math.max(2, Math.round(ih * fit));
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) return null;
ctx.drawImage(image, 0, 0, w, h);
const coverage = readCoverage(ctx.getImageData(0, 0, w, h).data, w, h);
let left = w;
let top = h;
let right = -1;
let bottom = -1;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
if (coverage[y * w + x] <= 0.01) continue;
if (x < left) left = x;
if (x > right) right = x;
if (y < top) top = y;
if (y > bottom) bottom = y;
}
}
if (right < 0) return null;
const logoWidth = right - left + 1;
const logoHeight = bottom - top + 1;
const pad = Math.ceil(Math.max(logoWidth, logoHeight) * 0.25) + 2;
const width = logoWidth + pad * 2;
const height = logoHeight + pad * 2;
const outer = new Float32Array(width * height);
const inner = new Float32Array(width * height);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const sx = x - pad + left;
const sy = y - pad + top;
const a = sx >= 0 && sy >= 0 && sx < w && sy < h ? coverage[sy * w + sx] : 0;
const i = y * width + x;
if (a >= 1) {
outer[i] = 0;
inner[i] = FAR;
} else if (a <= 0) {
outer[i] = FAR;
inner[i] = 0;
} else {
const e = 0.5 - a;
outer[i] = e > 0 ? e * e : 0;
inner[i] = e < 0 ? e * e : 0;
}
}
}
transformGrid(outer, width, height);
transformGrid(inner, width, height);
const field = new Float32Array(width * height);
for (let i = 0; i < width * height; i++) field[i] = Math.sqrt(outer[i]) - Math.sqrt(inner[i]);
const points: number[] = [];
for (let y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) {
const i = y * width + x;
const d = field[i];
if (d > 0) continue;
if (field[i - 1] <= 0 && field[i + 1] <= 0 && field[i - width] <= 0 && field[i + width] <= 0) continue;
const gx = field[i + 1] - field[i - 1];
const gy = field[i + width] - field[i - width];
const len = Math.hypot(gx, gy) || 1;
points.push(x + 0.5 - (d * gx) / len, y + 0.5 - (d * gy) / len);
}
}
const stride = Math.max(1, Math.ceil(points.length / 2 / 3000)) * 2;
const edges: number[] = [];
for (let i = 0; i < points.length; i += stride) edges.push(points[i], points[i + 1]);
const size = Math.max(logoWidth, logoHeight);
const glowPad = Math.ceil((size * 0.7) / CELL);
const glowWidth = Math.ceil(logoWidth / CELL) + glowPad * 2;
const glowHeight = Math.ceil(logoHeight / CELL) + glowPad * 2;
const tight = new Float32Array(glowWidth * glowHeight);
for (let y = 0; y < height; y++) {
const gy = Math.floor((y - pad) / CELL) + glowPad;
for (let x = 0; x < width; x++) {
const gx = Math.floor((x - pad) / CELL) + glowPad;
tight[gy * glowWidth + gx] += Math.exp(-Math.abs(field[y * width + x]) / 1.5) / (CELL * CELL);
}
}
const wide = tight.slice();
const tightRadius = Math.max(1, Math.round((size * 0.035) / CELL));
const wideRadius = Math.max(2, Math.round((size * 0.13) / CELL));
blurGrid(tight, glowWidth, glowHeight, tightRadius);
blurGrid(wide, glowWidth, glowHeight, wideRadius);
const tightNorm = (Math.sqrt(2 * Math.PI * (tightRadius * tightRadius + tightRadius)) * CELL) / 3;
const wideNorm = (Math.sqrt(2 * Math.PI * (wideRadius * wideRadius + wideRadius)) * CELL) / 3;
const glow = new Float32Array(glowWidth * glowHeight * 2);
for (let i = 0; i < glowWidth * glowHeight; i++) {
glow[i * 2] = tight[i] * tightNorm;
glow[i * 2 + 1] = wide[i] * wideNorm;
}
return {
field,
edges,
width,
height,
pad,
logoWidth,
logoHeight,
glow,
glowWidth,
glowHeight,
glowOffset: pad - glowPad * CELL
};
};
const spawnArc = (shape: Shape, time: number, focus: Focus | null): Spark | null => {
const { edges, logoWidth, logoHeight } = shape;
const count = edges.length / 2;
if (count < 2) return null;
const size = Math.max(logoWidth, logoHeight);
let i = Math.floor(Math.random() * count);
if (focus) {
let found = false;
for (let attempt = 0; attempt < 40 && !found; attempt++) {
const j = Math.floor(Math.random() * count);
if (Math.hypot(edges[j * 2] - focus.x, edges[j * 2 + 1] - focus.y) < focus.radius) {
i = j;
found = true;
}
}
if (!found) return null;
}
const ax = edges[i * 2];
const ay = edges[i * 2 + 1];
for (let attempt = 0; attempt < 24; attempt++) {
const j = Math.floor(Math.random() * count);
const bx = edges[j * 2];
const by = edges[j * 2 + 1];
const len = Math.hypot(bx - ax, by - ay);
if (len < size * 0.08 || len > size * 0.3) continue;
const nx = -(by - ay) / len;
const ny = (bx - ax) / len;
const bow = len * (0.2 + Math.random() * 0.3);
const mx = (ax + bx) / 2;
const my = (ay + by) / 2;
const left = sampleField(shape, mx + nx * bow, my + ny * bow);
const right = sampleField(shape, mx - nx * bow, my - ny * bow);
if (Math.max(left, right) <= 0) continue;
return {
ax,
ay,
bx,
by,
bow: left >= right ? bow : -bow,
seed: 1 + Math.random() * 60,
born: time,
life: 0.35 + Math.random() * 0.45
};
}
return null;
};
const vertex = `#version 300 es
in vec2 position;
in vec2 uv;
out vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position, 0.0, 1.0);
}
`;
const fragment = `#version 300 es
precision highp float;
precision highp int;
uniform sampler2D tFieldFrom;
uniform sampler2D tGlowFrom;
uniform sampler2D tFieldTo;
uniform sampler2D tGlowTo;
uniform vec4 uMapFrom;
uniform vec4 uSizeFrom;
uniform vec4 uMapTo;
uniform vec4 uSizeTo;
uniform float uMorph;
uniform vec2 uResolution;
uniform float uUnit;
uniform float uTime;
uniform float uPresence;
uniform vec3 uHover;
uniform float uHoverRadius;
uniform vec4 uPulses[${PULSES}];
uniform float uPulseBoost;
uniform float uFlash;
uniform vec3 uColor;
uniform vec3 uGlowColor;
uniform float uIntensity;
uniform float uGlow;
uniform float uThickness;
uniform float uStrands;
uniform float uBend;
uniform float uCrackle;
uniform float uFlicker;
uniform float uFill;
uniform float uInk;
uniform vec4 uArcEnds[${ARCS}];
uniform vec4 uArcShape[${ARCS}];
in vec2 vUv;
out vec4 fragColor;
uint scramble(uint x) {
x ^= x >> 16u;
x *= 0x7feb352du;
x ^= x >> 15u;
x *= 0x846ca68bu;
x ^= x >> 16u;
return x;
}
float fieldAt(sampler2D tex, vec4 map, vec4 size, vec2 p) {
vec2 f = (p - map.xy) / map.z;
vec2 c = clamp(f, vec2(0.5), size.xy - 0.5);
return (textureLod(tex, c / size.xy, 0.0).r + length(f - c)) * map.z;
}
vec2 glowAt(sampler2D tex, vec4 map, vec4 size, vec2 p) {
vec2 f = (p - map.xy) / map.z - map.w;
return textureLod(tex, f / (size.zw * ${CELL}.0), 0.0).rg;
}
float shape(vec2 p, float k) {
float to = fieldAt(tFieldTo, uMapTo, uSizeTo, p);
if (k >= 1.0) return to;
return mix(fieldAt(tFieldFrom, uMapFrom, uSizeFrom, p), to, k);
}
vec2 aura(vec2 p, float k) {
vec2 to = glowAt(tGlowTo, uMapTo, uSizeTo, p);
if (k >= 1.0) return to;
return mix(glowAt(tGlowFrom, uMapFrom, uSizeFrom, p), to, k);
}
vec4 corner(ivec2 c, uint seed) {
uint h = scramble(uint(c.x) * 0x8da6b343u + uint(c.y) * 0xd8163841u + seed * 0xcb1ab31fu);
return vec4(uvec4(h, h >> 8u, h >> 16u, h >> 24u) & 255u) / 127.5 - 1.0;
}
vec2 drift(vec2 p, uint seed, out mat2 jac) {
vec2 i = floor(p);
vec2 f = p - i;
vec2 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);
vec2 du = 30.0 * f * f * (f * (f - 2.0) + 1.0);
ivec2 c = ivec2(i);
vec4 ga = corner(c, seed);
vec4 gb = corner(c + ivec2(1, 0), seed);
vec4 gc = corner(c + ivec2(0, 1), seed);
vec4 gd = corner(c + ivec2(1, 1), seed);
vec2 fb = f - vec2(1.0, 0.0);
vec2 fc = f - vec2(0.0, 1.0);
vec2 fd = f - vec2(1.0);
vec2 va = vec2(dot(ga.xy, f), dot(ga.zw, f));
vec2 vb = vec2(dot(gb.xy, fb), dot(gb.zw, fb));
vec2 vc = vec2(dot(gc.xy, fc), dot(gc.zw, fc));
vec2 vd = vec2(dot(gd.xy, fd), dot(gd.zw, fd));
vec2 k = va - vb - vc + vd;
vec4 g = ga + u.x * (gb - ga) + u.y * (gc - ga) + u.x * u.y * (ga - gb - gc + gd);
jac = mat2(
g.xy + du * (u.yx * k.x + vec2(vb.x - va.x, vc.x - va.x)),
g.zw + du * (u.yx * k.y + vec2(vb.y - va.y, vc.y - va.y))
);
return va + u.x * (vb - va) + u.y * (vc - va) + u.x * u.y * k;
}
float wobble(vec2 p, uint seed) {
mat2 jac;
return drift(p, seed, jac).x;
}
vec2 ripple(vec2 p, out float surge) {
vec2 push = vec2(0.0);
surge = 0.0;
float width = uUnit * 10.0;
for (int i = 0; i < ${PULSES}; i++) {
vec4 pulse = uPulses[i];
if (pulse.w <= 0.0) continue;
vec2 d = p - pulse.xy;
float dist = length(d);
float front = (dist - pulse.z * uUnit * 150.0) / width;
float env = exp(-front * front) * pulse.w * exp(-pulse.z * 1.7) * smoothstep(0.0, uUnit * 8.0, dist);
push += d / max(dist, 1.0) * env * cos(front * 2.2) * uUnit * 7.5;
surge += env;
}
return push;
}
vec2 wander(vec2 p, float t, uint seed, float reachScale, out mat2 jac, out vec2 sway) {
vec2 q = p / uUnit;
mat2 ja;
mat2 jb;
mat2 jc;
mat2 jd;
vec2 a = drift(q * 0.028 + vec2(t * 0.29, t * 0.21), seed, ja);
vec2 b = drift(q * 0.085 + a * 0.4 + vec2(t * 0.83, -t * 0.61) + 17.0, seed + 1u, jb);
vec2 c = drift(p / 9.0 + b * 0.6 + vec2(t * 1.9, t * 1.3) + 5.0, seed + 2u, jc);
vec2 d = drift(p / 4.1 + vec2(-t * 2.7, t * 2.2) + 11.0, seed + 3u, jd);
float bendAmp = uBend * 8.0 * reachScale;
float rippleAmp = uBend * 3.2 * reachScale;
float crinkleAmp = uCrackle * 1.5 * reachScale;
jac = ja * (0.028 * bendAmp) + jb * (0.085 * rippleAmp) + jc * (crinkleAmp / 9.0) + jd * (crinkleAmp * 0.35 / 4.1);
sway = (a * bendAmp + b * rippleAmp) * uUnit;
return sway + (c + d * 0.35) * crinkleAmp;
}
vec2 glowShape(float line, float spread, float w) {
float x = abs(line);
float y = abs(spread);
return vec2(exp(-x * x / (w * w * 0.5)) + exp(-y / (w * 2.2)) * 0.6, exp(-y / (w * 4.5)) * 0.5);
}
void addArc(vec2 p, vec4 ends, vec4 info, float t, inout vec3 light, inout float energy, inout float hot) {
if (info.y < 0.002) return;
vec2 ab = ends.zw - ends.xy;
float len = max(length(ab), 1.0);
vec2 dir = ab / len;
vec2 rel = p - ends.xy;
float s = dot(rel, dir);
float h = dot(rel, vec2(-dir.y, dir.x));
float margin = abs(info.x) + uCrackle * (2.0 + len * 0.08) + uThickness * 12.0 + 10.0;
if (s < -margin || s > len + margin || abs(h) > margin) return;
float u = clamp(s / len, 0.0, 1.0);
float taper = sin(3.14159265 * u);
float bendSlope = s > 0.0 && s < len ? 3.14159265 / len * cos(3.14159265 * u) : 0.0;
float beyond = max(-s, 0.0) + max(s - len, 0.0);
for (int c = 0; c < 2; c++) {
uint seed = uint(info.z * 131.0) + uint(c) * 29u + 7u;
float jag = 0.0;
float jagSlope = 0.0;
float wave = max(len * 0.3, 14.0);
float weight = uCrackle * (1.5 + len * 0.05) * (c == 0 ? 1.0 : 1.5);
for (int o = 0; o < 3; o++) {
mat2 jac;
float n = drift(vec2(s / wave + info.z * 3.0, t * (1.4 + float(o) * 1.1)), seed + uint(o), jac).x;
jag += n * weight;
jagSlope += jac[0].x * weight / wave;
wave *= 0.42;
weight *= 0.4;
}
float offset = (info.x + jag) * taper;
float offsetSlope = (info.x + jag) * bendSlope + jagSlope * taper;
float across = (h - offset) / sqrt(1.0 + offsetSlope * offsetSlope);
float gap = length(vec2(beyond, across));
float w = uThickness * (c == 0 ? 0.9 : 0.6);
vec2 g = glowShape(gap, gap, w);
float k = info.y * (c == 0 ? 1.0 : 0.45);
light += (uColor * g.x + uGlowColor * g.y * uGlow) * k;
energy += (g.x + g.y * uGlow) * k;
hot += exp(-gap * gap / (w * w * 0.16)) * k * (c == 0 ? 1.0 : 0.0);
}
}
void main() {
vec2 p = vec2(vUv.x, 1.0 - vUv.y) * uResolution;
float t = uTime;
vec3 light = vec3(0.0);
float energy = 0.0;
float hot = 0.0;
float surge;
vec2 pr = p - ripple(p, surge);
float k = uMorph >= 1.0 ? 1.0 : smoothstep(0.0, 1.0, clamp(uMorph * 1.7 - 0.35 + 0.35 * wobble(p / uUnit * 0.018, 41u), 0.0, 1.0));
float transit = uMorph >= 1.0 ? 0.0 : sin(3.14159265 * uMorph);
float base = shape(pr, k);
vec2 toHover = p - uHover.xy;
float heat = min(uHover.z * exp(-dot(toHover, toHover) / (uHoverRadius * uHoverRadius)) + surge * 1.4 + transit * 0.5, 2.0);
float heatCap = min(uHover.z + uPulseBoost * 1.4 + transit * 0.5, 2.0);
float breath = 1.0 + uFlicker * 0.6 * wobble(vec2(t * 2.1, 7.0), 3u);
float grow = uPresence;
float edge = abs(base);
vec2 halo = aura(pr, k);
float ink = uInk;
float bloom = (halo.x * 0.16 + halo.y * 0.08) * (1.0 - ink * 0.65) * uGlow * (1.0 + heat * 1.2);
float body = smoothstep(0.75, -0.75, base) * uFill * (0.06 + 1.2 * min(halo.x, 1.0)) * (1.0 + heat * 0.5);
light += uGlowColor * bloom * grow * grow;
energy += bloom * grow * grow;
float reachScale = mix(0.15, 1.0, grow) * (1.0 + heat * 0.9);
float reach = (uUnit * uBend * 16.0 + uCrackle * 3.0) * (1.0 + heatCap * 0.9) + uThickness * 20.0 + 8.0;
if (edge < reach && grow > 0.0) {
float fade = smoothstep(reach, reach * 0.55, edge);
vec2 q = pr / uUnit;
float count = min(uStrands + heat * 2.5, 6.0);
float limit = min(uStrands + heatCap * 2.5, 6.0);
for (int i = 0; i < 6; i++) {
float fi = float(i);
if (fi >= limit) break;
float present = clamp(count - fi, 0.0, 1.0);
if (present <= 0.0) continue;
uint seed = uint(i) * 7u + 3u;
mat2 jac;
vec2 sway;
float lead = i == 0 ? 1.0 : 0.0;
vec2 warped = pr + wander(pr, t * (1.0 + fi * 0.19), seed, reachScale * mix(0.6 + fi * 0.2, 0.7, lead), jac, sway);
float dw = shape(warped, k);
vec2 slope = vec2(shape(warped + vec2(1.0, 0.0), k), shape(warped + vec2(0.0, 1.0), k)) - dw;
float d = dw / max(length(slope + jac * slope), 0.3);
float spread = shape(pr + sway, k);
float swell = 0.5 + 0.5 * wobble(q * 0.06 + vec2(t * 0.9, fi * 5.1 - t * 0.6), seed + 8u);
float w = uThickness * mix(0.5, 1.0, lead) * (0.5 + swell);
float vis = mix(0.3 + 0.45 * smoothstep(-0.25, 0.2, wobble(q * 0.035 + vec2(t * 0.21, fi * 3.7), seed + 5u)), 1.0, lead);
float spark = 1.0 - uFlicker * 0.3 * (0.5 + 0.5 * wobble(vec2(t * 6.0, fi * 2.3), seed + 6u));
float weight = max(vis, heat * 0.85) * spark * fade * present * (0.7 + 0.6 * swell);
vec2 g = glowShape(d, spread, w) * weight;
float soft = mix(1.0, mix(0.5, 1.0, lead), ink);
vec3 stroke = mix(uColor, uGlowColor, ink * (1.0 - lead) * 0.65);
float haze = uGlow * (1.0 + heat) * (1.0 - ink * 0.7);
light += stroke * g.x * soft + uGlowColor * g.y * haze;
energy += g.x * soft + g.y * haze;
hot += exp(-d * d / (w * w * 0.16)) * lead * weight;
}
light *= grow;
energy *= grow;
}
for (int i = 0; i < ${ARCS}; i++) addArc(pr, uArcEnds[i], uArcShape[i], t, light, energy, hot);
float gain = uIntensity * breath * (1.0 + heat * 0.45) * (1.0 + uFlash * 0.3) * 1.4;
float alpha = 1.0 - exp(-energy * gain);
vec3 color = mix(1.0 - exp(-light * gain), alpha * light / max(energy, 1e-4), ink);
color = mix(color, vec3(alpha), clamp(hot * grow, 0.0, 1.0) * ink * 0.85);
float tint = (1.0 - exp(-body * gain * 1.2)) * grow * grow * (1.0 - ink * 0.82);
color += uGlowColor * tint * (1.0 - alpha);
alpha += tint * (1.0 - alpha);
float grain = (fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715)))) - 0.5) / 255.0;
alpha = clamp(alpha + grain, 0.0, 1.0);
fragColor = vec4(clamp(color + grain, 0.0, alpha), alpha);
}
`;
const ElectricLogo = ({
src = BOLT,
color = '#ecc7ff',
glowColor = '#ad6dff',
scale = 0.7,
intensity = 1,
glow = 1,
thickness = 1.5,
strands = 4,
bend = 0.6,
crackle = 1.5,
arcs = 1,
flicker = 0.6,
fill = 0,
speed = 2.5,
interactive = true,
cursorIntensity = 0.75,
cursorRadius = 100,
theme = 'dark',
onRender,
className = '',
style
}: ElectricLogoProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const settingsRef = useRef<Settings | null>(null);
const shapeRef = useRef<Shape | null>(null);
useEffect(() => {
settingsRef.current = {
color,
glowColor,
scale,
intensity,
glow,
thickness,
strands,
bend,
crackle,
arcs,
flicker,
fill,
speed,
interactive,
cursorIntensity,
cursorRadius,
theme,
onRender
};
});
useEffect(() => {
let alive = true;
const image = new Image();
image.crossOrigin = 'anonymous';
image.decoding = 'async';
image.onload = () => {
if (!alive) return;
let traced: Shape | null = null;
try {
traced = traceShape(image);
} catch {
traced = null;
}
if (traced) shapeRef.current = traced;
};
image.src = src || BOLT;
return () => {
alive = false;
image.onload = null;
};
}, [src]);
useEffect(() => {
const container = containerRef.current;
if (!container) return undefined;
const renderer = new Renderer({
dpr: Math.min(window.devicePixelRatio || 1, 2),
alpha: true,
premultipliedAlpha: true,
antialias: false
});
const gl = renderer.gl;
const gl2 = gl as WebGL2RenderingContext;
if (!renderer.isWebgl2) {
gl.getExtension('WEBGL_lose_context')?.loseContext();
return undefined;
}
gl.clearColor(0, 0, 0, 0);
const canvas = gl.canvas;
canvas.style.display = 'block';
canvas.style.width = '100%';
canvas.style.height = '100%';
container.appendChild(canvas);
const makeSlot = (): Slot => ({
shape: null,
field: new Texture(gl, {
image: new Float32Array([1000]),
width: 1,
height: 1,
internalFormat: gl2.R16F,
format: gl2.RED,
type: gl.FLOAT,
minFilter: gl.LINEAR,
magFilter: gl.LINEAR,
generateMipmaps: false,
flipY: false,
unpackAlignment: 1
}),
glow: new Texture(gl, {
image: new Float32Array([0, 0]),
width: 1,
height: 1,
internalFormat: gl2.RG16F,
format: gl2.RG,
type: gl.FLOAT,
minFilter: gl.LINEAR,
magFilter: gl.LINEAR,
generateMipmaps: false,
flipY: false,
unpackAlignment: 1
})
});
const slots = [makeSlot(), makeSlot()];
const arcEnds = Array.from({ length: ARCS * 4 }, () => 0);
const arcShape = Array.from({ length: ARCS * 4 }, () => 0);
const pulseData = Array.from({ length: PULSES * 4 }, () => 0);
const uniforms = {
tFieldFrom: { value: slots[1].field },
tGlowFrom: { value: slots[1].glow },
tFieldTo: { value: slots[0].field },
tGlowTo: { value: slots[0].glow },
uMapFrom: { value: [0, 0, 1, 0] },
uSizeFrom: { value: [1, 1, 1, 1] },
uMapTo: { value: [0, 0, 1, 0] },
uSizeTo: { value: [1, 1, 1, 1] },
uMorph: { value: 1 },
uResolution: { value: [1, 1] },
uUnit: { value: 1 },
uTime: { value: 0 },
uPresence: { value: 0 },
uHover: { value: [0, 0, 0] },
uHoverRadius: { value: 120 },
uPulses: { value: pulseData },
uPulseBoost: { value: 0 },
uFlash: { value: 0 },
uColor: { value: [1, 1, 1] },
uGlowColor: { value: [0.43, 0.48, 1] },
uIntensity: { value: 1 },
uGlow: { value: 1 },
uThickness: { value: 1.8 },
uStrands: { value: 3 },
uBend: { value: 1 },
uCrackle: { value: 1 },
uFlicker: { value: 0.4 },
uFill: { value: 0.5 },
uInk: { value: 0 },
uArcEnds: { value: arcEnds },
uArcShape: { value: arcShape }
};
const mesh = new Mesh(gl, {
geometry: new Triangle(gl),
program: new Program(gl, { vertex, fragment, uniforms, depthTest: false, depthWrite: false })
});
const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
const pointer = { x: 0, y: 0, over: false };
const hover = { x: 0, y: 0, vx: 0, vy: 0, power: 0 };
const sparks: Spark[] = [];
const pulses: Pulse[] = [];
let target = 0;
let pending: Shape | null = null;
let morph = 1;
let burst: Point | null = null;
let progress = 0;
let ink = 0;
const hues = [
[1, 1, 1],
[1, 1, 1]
];
let settled = false;
let time = 0;
let width = 1;
let height = 1;
let raf = 0;
let last = performance.now();
let visible = true;
const load = (slot: Slot, next: Shape) => {
slot.shape = next;
slot.field.image = next.field;
slot.field.width = next.width;
slot.field.height = next.height;
slot.field.needsUpdate = true;
slot.glow.image = next.glow;
slot.glow.width = next.glowWidth;
slot.glow.height = next.glowHeight;
slot.glow.needsUpdate = true;
};
const place = (shape: Shape, s: Settings) => {
const fit = Math.max(1e-4, Math.min((width * s.scale) / shape.logoWidth, (height * s.scale) / shape.logoHeight));
return {
fit,
ox: width / 2 - (shape.pad + shape.logoWidth / 2) * fit,
oy: height / 2 - (shape.pad + shape.logoHeight / 2) * fit,
unit: (Math.max(shape.logoWidth, shape.logoHeight) * fit) / 100
};
};
const resize = () => {
width = Math.max(1, container.clientWidth);
height = Math.max(1, container.clientHeight);
renderer.dpr = Math.min(window.devicePixelRatio || 1, 2, Math.sqrt(PIXEL_BUDGET / (width * height)));
renderer.setSize(width, height);
uniforms.uResolution.value = [width, height];
};
const frame = (now: number) => {
raf = 0;
const s = settingsRef.current;
const dt = Math.min(0.05, Math.max(0, (now - last) / 1000));
last = now;
const incoming = shapeRef.current;
if (incoming && incoming !== slots[target].shape) pending = incoming;
if (pending && morph >= 1) {
if (slots[target].shape) {
target = 1 - target;
morph = 0;
sparks.length = 0;
}
load(slots[target], pending);
pending = null;
}
if (morph < 1) morph = Math.min(1, morph + dt / (pending ? 0.3 : 1.6));
const to = slots[target].shape;
const from = morph < 1 ? slots[1 - target].shape : null;
if (to) progress = Math.min(1, progress + dt / 1.4);
const presence = progress * progress * (3 - 2 * progress);
if (to && s) {
const near = place(to, s);
const far = from ? place(from, s) : near;
const blend = morph * morph * (3 - 2 * morph);
const unit = far.unit + (near.unit - far.unit) * blend;
const engaged = s.interactive && pointer.over;
if (engaged && hover.power < 0.01) {
hover.x = pointer.x;
hover.y = pointer.y;
hover.vx = 0;
hover.vy = 0;
}
hover.vx += ((pointer.x - hover.x) * 120 - hover.vx * 19) * dt;
hover.vy += ((pointer.y - hover.y) * 120 - hover.vy * 19) * dt;
hover.x += hover.vx * dt;
hover.y += hover.vy * dt;
hover.power += ((engaged ? 1 : 0) - hover.power) * (1 - Math.exp(-dt / (engaged ? 0.3 : 0.55)));
const motion = reducedMotion ? 0.2 : 1;
time += dt * s.speed * motion;
for (let i = sparks.length - 1; i >= 0; i--) {
if (time - sparks[i].born > sparks[i].life) sparks.splice(i, 1);
}
const focus = (spot: Point): Focus => ({
x: (spot.x - near.ox) / near.fit,
y: (spot.y - near.oy) / near.fit,
radius: Math.max(1, s.cursorRadius) / near.fit
});
if (burst && morph >= 1 && s.arcs > 0) {
for (let i = 0; i < 3 && sparks.length < ARCS; i++) {
const spark = spawnArc(to, time, focus(burst));
if (spark) sparks.push(spark);
}
}
burst = null;
if (!reducedMotion && presence > 0.8 && morph >= 1 && sparks.length < ARCS) {
const chance = dt * s.speed * s.arcs;
if (Math.random() < chance * 6 * hover.power * s.cursorIntensity) {
const spark = spawnArc(to, time, focus(hover));
if (spark) sparks.push(spark);
} else if (Math.random() < chance * 2.2) {
const spark = spawnArc(to, time, null);
if (spark) sparks.push(spark);
}
}
for (let i = 0; i < ARCS; i++) {
const o = i * 4;
const spark = sparks[i];
if (!spark) {
arcShape[o + 1] = 0;
continue;
}
const k = (time - spark.born) / spark.life;
arcEnds[o] = near.ox + spark.ax * near.fit;
arcEnds[o + 1] = near.oy + spark.ay * near.fit;
arcEnds[o + 2] = near.ox + spark.bx * near.fit;
arcEnds[o + 3] = near.oy + spark.by * near.fit;
arcShape[o] = spark.bow * near.fit;
arcShape[o + 1] = Math.sin(Math.PI * Math.min(1, Math.max(0, k))) * presence;
arcShape[o + 2] = spark.seed;
}
let boost = 0;
let flash = 0;
for (let i = pulses.length - 1; i >= 0; i--) {
if ((now - pulses[i].born) / 1000 > 2) pulses.splice(i, 1);
}
for (let i = 0; i < PULSES; i++) {
const o = i * 4;
const pulse = pulses[i];
if (!pulse) {
pulseData[o + 3] = 0;
continue;
}
const age = (now - pulse.born) / 1000;
pulseData[o] = pulse.x;
pulseData[o + 1] = pulse.y;
pulseData[o + 2] = age;
pulseData[o + 3] = 1;
boost = Math.max(boost, Math.exp(-age * 1.7));
flash += Math.exp(-age * 7);
}
const fromSlot = slots[1 - target];
uniforms.tFieldTo.value = slots[target].field;
uniforms.tGlowTo.value = slots[target].glow;
uniforms.tFieldFrom.value = fromSlot.field;
uniforms.tGlowFrom.value = fromSlot.glow;
uniforms.uMapTo.value = [near.ox, near.oy, near.fit, to.glowOffset];
uniforms.uSizeTo.value = [to.width, to.height, to.glowWidth, to.glowHeight];
if (from) {
uniforms.uMapFrom.value = [far.ox, far.oy, far.fit, from.glowOffset];
uniforms.uSizeFrom.value = [from.width, from.height, from.glowWidth, from.glowHeight];
}
uniforms.uMorph.value = morph;
uniforms.uUnit.value = unit;
uniforms.uTime.value = time;
uniforms.uPresence.value = presence;
uniforms.uHover.value = [hover.x, hover.y, hover.power * Math.max(0, s.cursorIntensity)];
uniforms.uHoverRadius.value = Math.max(1, s.cursorRadius);
uniforms.uPulseBoost.value = boost;
uniforms.uFlash.value = flash;
const targets = [hexToRgb(s.color), hexToRgb(s.glowColor)];
const shift = settled ? 1 - Math.exp(-dt / 0.35) : 1;
settled = true;
for (let i = 0; i < 2; i++) {
for (let c = 0; c < 3; c++) hues[i][c] += (targets[i][c] - hues[i][c]) * shift;
}
uniforms.uColor.value = hues[0].slice();
uniforms.uGlowColor.value = hues[1].slice();
uniforms.uIntensity.value = s.intensity;
uniforms.uGlow.value = s.glow;
uniforms.uThickness.value = s.thickness;
uniforms.uStrands.value = Math.max(1, Math.min(6, Math.round(s.strands)));
uniforms.uBend.value = s.bend;
uniforms.uCrackle.value = s.crackle;
uniforms.uFlicker.value = reducedMotion ? 0 : s.flicker;
ink += ((s.theme === 'light' ? 1 : 0) - ink) * (1 - Math.exp(-dt / 0.25));
uniforms.uFill.value = s.fill;
uniforms.uInk.value = ink;
renderer.render({ scene: mesh });
s.onRender?.(canvas);
}
if (visible) raf = requestAnimationFrame(frame);
};
const start = () => {
if (raf || !visible) return;
last = performance.now();
raf = requestAnimationFrame(frame);
};
const onMove = (e: PointerEvent) => {
const rect = container.getBoundingClientRect();
pointer.x = e.clientX - rect.left;
pointer.y = e.clientY - rect.top;
pointer.over = true;
};
const onDown = (e: PointerEvent) => {
onMove(e);
if (!settingsRef.current?.interactive || reducedMotion) return;
pulses.push({ x: pointer.x, y: pointer.y, born: performance.now() });
if (pulses.length > PULSES) pulses.shift();
burst = { x: pointer.x, y: pointer.y };
};
const onLeave = () => {
pointer.over = false;
};
container.addEventListener('pointermove', onMove);
container.addEventListener('pointerdown', onDown);
container.addEventListener('pointerleave', onLeave);
container.addEventListener('pointercancel', onLeave);
const resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(container);
const intersectionObserver = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
start();
});
intersectionObserver.observe(container);
resize();
start();
return () => {
visible = false;
cancelAnimationFrame(raf);
resizeObserver.disconnect();
intersectionObserver.disconnect();
container.removeEventListener('pointermove', onMove);
container.removeEventListener('pointerdown', onDown);
container.removeEventListener('pointerleave', onLeave);
container.removeEventListener('pointercancel', onLeave);
gl.getExtension('WEBGL_lose_context')?.loseContext();
if (canvas.parentNode) canvas.parentNode.removeChild(canvas);
};
}, []);
return (
<div ref={containerRef} className={`relative w-full h-full overflow-hidden ${className}`.trim()} style={style} />
);
};
export default ElectricLogo;