Электрическая рамка
ElectricBorder — декоративная рамка с электрическими нитями, короткими дугами и мягким свечением. Эффект рендерится через WebGL 2, адаптируется к размеру контейнера и реагирует на указатель.
Интерактивное демо
Настраивайте палитру и поведение рамки через панель управления. Наводите курсор и нажимайте внутри превью, чтобы вызвать локальные разряды и импульсы.
Карточка с электрическим контуром — эффект подчёркивает содержимое, не отвлекая от него.
Пропсы
<script module lang="ts">
import type { Snippet } from 'svelte';
export type ElectricBorderProps = {
children?: Snippet;
color?: string;
glowColor?: string;
speed?: number;
chaos?: number;
spread?: number;
thickness?: number;
strands?: number;
glow?: number;
intensity?: number;
arcs?: number;
flicker?: number;
borderRadius?: number;
offset?: number;
active?: boolean;
intro?: boolean;
interactive?: boolean;
cursorRadius?: number;
theme?: 'dark' | 'light';
class?: string;
/** Строка inline-стилей (в Svelte style — это строка, а не объект). */
style?: string;
};
type Settings = {
core: number[];
glowColor: number[];
speed: number;
chaos: number;
spread: number;
thickness: number;
strands: number;
glow: number;
intensity: number;
arcs: number;
flicker: number;
borderRadius: number;
offset: number;
active: boolean;
intro: boolean;
interactive: boolean;
cursorRadius: number;
light: boolean;
};
type Program = {
program: WebGLProgram;
uniforms: Record<string, WebGLUniformLocation | null>;
};
type BorderPath = {
hx: number;
hy: number;
r: number;
vr: number;
ax: number;
ay: number;
quarter: number;
perimeter: number;
};
type Spark = {
ax: number;
ay: number;
bx: number;
by: number;
bow: number;
seed: number;
born: number;
life: number;
};
type Pulse = {
s: number;
born: number;
};
const ARCS = 4;
const PULSES = 3;
const MARGIN = 64;
const PIXEL_BUDGET = 3e6;
const SURGE_SPEED = 560;
const HALF_PI = Math.PI / 2;
const VERTEX = `#version 300 es
const vec2 corners[3] = vec2[3](vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0));
void main() {
gl_Position = vec4(corners[gl_VertexID], 0.0, 1.0);
}`;
const FRAGMENT = `#version 300 es
precision highp float;
precision highp int;
uniform vec2 uSize;
uniform float uRatio;
uniform vec2 uCenter;
uniform vec2 uHalf;
uniform float uRadius;
uniform vec3 uParam;
uniform float uPerimeter;
uniform vec4 uCells;
uniform vec2 uSoft;
uniform float uTime;
uniform float uPower;
uniform float uReveal;
uniform vec3 uHover;
uniform float uHoverRadius;
uniform vec4 uPulses[${PULSES}];
uniform vec3 uCore;
uniform vec3 uGlowColor;
uniform float uIntensity;
uniform float uGlow;
uniform float uThickness;
uniform float uStrands;
uniform float uChaos;
uniform float uSpread;
uniform float uFlicker;
uniform float uInk;
uniform vec4 uArcEnds[${ARCS}];
uniform vec4 uArcShape[${ARCS}];
out vec4 fragColor;
uint scramble(uint x) {
x ^= x >> 16u;
x *= 0x7feb352du;
x ^= x >> 15u;
x *= 0x846ca68bu;
x ^= x >> 16u;
return x;
}
vec2 slant(uint x, uint y, uint seed) {
uint h = scramble(x * 0x8da6b343u + y * 0xd8163841u + seed * 0xcb1ab31fu);
return vec2(uvec2(h, h >> 8u) & 255u) / 127.5 - 1.0;
}
vec2 ring(vec2 p, float period, uint seed) {
vec2 i = floor(p);
vec2 f = p - i;
vec2 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);
float du = 30.0 * f.x * f.x * (f.x * (f.x - 2.0) + 1.0);
uint x0 = uint(mod(i.x, period));
uint x1 = uint(mod(i.x + 1.0, period));
uint y0 = uint(i.y + 65536.0);
vec2 ga = slant(x0, y0, seed);
vec2 gb = slant(x1, y0, seed);
vec2 gc = slant(x0, y0 + 1u, seed);
vec2 gd = slant(x1, y0 + 1u, seed);
float va = dot(ga, f);
float vb = dot(gb, f - vec2(1.0, 0.0));
float vc = dot(gc, f - vec2(0.0, 1.0));
float vd = dot(gd, f - vec2(1.0, 1.0));
float k = va - vb - vc + vd;
float value = va + u.x * (vb - va) + u.y * (vc - va) + u.x * u.y * k;
float gx = ga.x + u.x * (gb.x - ga.x) + u.y * (gc.x - ga.x) + u.x * u.y * (ga.x - gb.x - gc.x + gd.x);
return vec2(value, gx + du * (vb - va + u.y * k));
}
float wobble(float x, uint seed) {
return ring(vec2(x, 0.5), 1048576.0, seed).x;
}
vec2 frameAt(vec2 p) {
vec2 inner = max(uHalf - uRadius, 0.0);
vec2 q = abs(p) - inner;
float d = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - uRadius;
float quarter = uPerimeter * 0.25;
vec2 v = abs(p) - uParam.xy;
float t;
if (v.x > 0.0 && v.y > 0.0) t = uParam.x + uParam.z * atan(v.x, v.y);
else if (v.x > v.y) t = uParam.x + 1.5707963 * uParam.z + uParam.y - abs(p.y);
else t = abs(p.x);
t = clamp(t, 0.0, quarter);
float s = p.x >= 0.0 ? (p.y < 0.0 ? t : 2.0 * quarter - t) : (p.y < 0.0 ? 4.0 * quarter - t : 2.0 * quarter + t);
return vec2(s, d);
}
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 along = dot(rel, dir);
float side = dot(rel, vec2(-dir.y, dir.x));
float margin = abs(info.x) + (0.6 + uChaos) * (2.0 + len * 0.08) + uThickness * 12.0 + 10.0;
if (along < -margin || along > len + margin || abs(side) > margin) return;
float u = clamp(along / len, 0.0, 1.0);
float taper = sin(3.14159265 * u);
float settle = smoothstep(0.0, 0.15, u) * smoothstep(1.0, 0.85, u);
float bendSlope = 3.14159265 / len * cos(3.14159265 * u) * settle;
float beyond = max(-along, 0.0) + max(along - 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 = (0.6 + uChaos) * (1.5 + len * 0.05) * (c == 0 ? 1.0 : 1.5);
for (int o = 0; o < 3; o++) {
vec2 n = ring(vec2(along / wave + info.z * 3.0, t * (1.4 + float(o) * 1.1)), 1048576.0, seed + uint(o));
jag += n.x * weight;
jagSlope += n.y * weight / wave;
wave *= 0.42;
weight *= 0.4;
}
float offset = (info.x + jag) * taper;
float offsetSlope = (info.x + jag) * bendSlope + jagSlope * taper * settle;
float across = (side - 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 += (uCore * 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(gl_FragCoord.x / uRatio, uSize.y - gl_FragCoord.y / uRatio);
vec2 frame = frameAt(p - uCenter);
float s = frame.x;
float d = frame.y;
float t = uTime;
vec3 light = vec3(0.0);
float energy = 0.0;
float hot = 0.0;
float along = min(s, uPerimeter - s);
float front = uReveal * (uPerimeter * 0.5 + 80.0);
float shown = smoothstep(front, front - 70.0, along) * uPower;
float flare = uReveal < 1.0 ? exp(-pow((along - front + 30.0) / 26.0, 2.0)) * uPower : 0.0;
vec2 toHover = p - uHover.xy;
float heat = uHover.z * exp(-dot(toHover, toHover) / (uHoverRadius * uHoverRadius));
float surge = 0.0;
for (int i = 0; i < ${PULSES}; i++) {
vec4 pulse = uPulses[i];
if (pulse.z <= 0.0) continue;
float gap = abs(s - pulse.x);
gap = min(gap, uPerimeter - gap);
float wave = (gap - pulse.y * ${SURGE_SPEED}.0) / 46.0;
surge += exp(-wave * wave) * pulse.z * exp(-pulse.y * 1.2);
}
heat = min(heat * 0.7 + surge * 0.8 + flare * 0.6, 1.2);
vec2 inset = min(p, uSize - p);
float frameFade = smoothstep(0.0, 14.0, min(inset.x, inset.y));
float edge = abs(d);
float inward = d < 0.0 ? exp(d / 14.0) : 1.0;
float bloom = (exp(-edge / 3.0) * 0.1 + exp(-edge / 16.0) * 0.05 * inward) * uGlow * (1.0 + heat * 0.6) * shown;
light += uGlowColor * bloom;
energy += bloom;
float bendAmp = uSpread * 16.0 * (1.0 + heat * 0.35);
float rippleAmp = uSpread * 6.0 * (1.0 + heat * 0.3);
float crinkleAmp = uChaos * 4.5 * (1.0 + heat * 0.3);
float squeeze = min(1.0, 34.0 / max((bendAmp + rippleAmp) * 1.6 + crinkleAmp, 1e-3));
bendAmp *= squeeze;
rippleAmp *= squeeze;
crinkleAmp *= squeeze;
float reach = (bendAmp * 1.9 + rippleAmp + crinkleAmp * 1.6) * 1.3 + uThickness * 14.0 + 8.0;
if (edge < reach && shown > 0.0) {
float fade = smoothstep(reach, reach * 0.55, edge);
float count = min(uStrands + heat * 1.2, 6.0);
float slopeScale = 1.0 / uPerimeter;
for (int i = 0; i < 6; i++) {
float fi = float(i);
if (fi >= ceil(count)) break;
float present = clamp(count - fi, 0.0, 1.0);
uint seed = uint(i) * 7u + 3u;
float lead = i == 0 ? 1.0 : 0.0;
float range = mix(1.35 + fi * 0.18, 0.7, lead);
float x = s * slopeScale;
vec2 a = ring(vec2(x * uCells.x, t * 0.7 + fi * 3.1), uCells.x, seed);
vec2 b = ring(vec2(x * uCells.y, t * 1.9 + fi * 1.7), uCells.y, seed + 1u);
vec2 c = ring(vec2(x * uCells.z, t * 4.6 + fi * 0.9), uCells.z, seed + 2u);
vec2 e = ring(vec2(x * uCells.w, t * 6.8), uCells.w, seed + 3u);
float offset = (a.x * bendAmp + b.x * rippleAmp) * range + (c.x + e.x * 0.35) * crinkleAmp;
float slope =
((a.y * uCells.x * bendAmp + b.y * uCells.y * rippleAmp) * range +
(c.y * uCells.z + e.y * 0.35 * uCells.w) * crinkleAmp) *
slopeScale;
float drift = lead > 0.5 ? 0.0 : wobble(fi * 7.3, seed + 9u) * 2.5 * uSpread;
offset += drift;
float across = (d - offset) / sqrt(1.0 + slope * slope);
float spread = d - a.x * bendAmp * range - drift;
float swell = 0.5 + 0.5 * ring(vec2(x * uSoft.y, t * 2.0 + fi * 5.1), uSoft.y, seed + 8u).x;
float w = uThickness * mix(0.55, 1.0, lead) * (0.5 + swell);
float vis = mix(0.3 + 0.45 * smoothstep(-0.25, 0.2, ring(vec2(x * uSoft.x, t * 0.5 + fi * 3.7), uSoft.x, seed + 5u).x), 1.0, lead);
float spark = 1.0 - uFlicker * 0.3 * (0.5 + 0.5 * wobble(t * 13.0 + fi * 2.3, seed + 6u));
float weight = max(vis, heat * 0.6) * spark * fade * present * (0.7 + 0.6 * swell);
vec2 g = glowShape(across, spread, w) * weight;
g.y *= inward;
float haze = uGlow * (1.0 + heat * 0.5) * (1.0 - uInk * 0.7);
light += uCore * g.x + uGlowColor * g.y * haze;
energy += g.x + g.y * haze;
hot += exp(-across * across / (w * w * 0.16)) * lead * weight;
}
light *= shown;
energy *= shown;
hot *= shown;
}
for (int i = 0; i < ${ARCS}; i++) addArc(p, uArcEnds[i], uArcShape[i], t, light, energy, hot);
float breath = 1.0 + uFlicker * 0.5 * wobble(t * 4.6 + 7.0, 3u);
float gain = uIntensity * breath * (1.0 + heat * 0.25) * 1.4;
light *= frameFade;
energy *= frameFade;
hot *= frameFade;
float alpha = 1.0 - exp(-energy * gain);
vec3 color = mix(1.0 - exp(-light * gain), alpha * light / max(energy, 1e-4), uInk);
color = mix(color, vec3(alpha), clamp(hot, 0.0, 1.0) * uInk * 0.85);
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 clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
const parseColor = (value: string): number[] => {
const text = value.trim();
const hex = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(text);
if (hex) {
const full = hex[1].length === 3 ? hex[1].replace(/./g, (digit) => digit + digit) : hex[1];
return [0, 2, 4].map((index) => parseInt(full.slice(index, index + 2), 16) / 255);
}
const rgb = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/i.exec(text);
if (rgb) return [rgb[1], rgb[2], rgb[3]].map((channel) => clamp(parseFloat(channel) / 255, 0, 1));
if (typeof document !== 'undefined') {
const context = document.createElement('canvas').getContext('2d');
if (context) {
context.fillStyle = '#000000';
context.fillStyle = text;
const normal = context.fillStyle;
if (normal !== '#000000' || /^(#0{3}|#0{6}|black)$/i.test(text)) return parseColor(normal);
}
}
return [0.49, 0.98, 1];
};
const compile = (gl: WebGL2RenderingContext, type: number, source: string) => {
const shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error(gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
return shader;
};
const link = (gl: WebGL2RenderingContext): Program | null => {
const vertex = compile(gl, gl.VERTEX_SHADER, VERTEX);
const fragment = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT);
const program = vertex && fragment ? gl.createProgram() : null;
if (!vertex || !fragment || !program) {
if (vertex) gl.deleteShader(vertex);
if (fragment) gl.deleteShader(fragment);
return null;
}
gl.attachShader(program, vertex);
gl.attachShader(program, fragment);
gl.linkProgram(program);
gl.deleteShader(vertex);
gl.deleteShader(fragment);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error(gl.getProgramInfoLog(program));
gl.deleteProgram(program);
return null;
}
const uniforms: Program['uniforms'] = {};
const count = gl.getProgramParameter(program, gl.ACTIVE_UNIFORMS);
for (let index = 0; index < count; index++) {
const info = gl.getActiveUniform(program, index);
if (info) uniforms[info.name.replace(/\[0\]$/, '')] = gl.getUniformLocation(program, info.name);
}
return { program, uniforms };
};
const roundBox = (x: number, y: number, hx: number, hy: number, r: number) => {
const qx = Math.abs(x) - (hx - r);
const qy = Math.abs(y) - (hy - r);
return Math.hypot(Math.max(qx, 0), Math.max(qy, 0)) + Math.min(Math.max(qx, qy), 0) - r;
};
const pathOf = (width: number, height: number, radius: number, offset: number): BorderPath => {
const hx = Math.max(1, width / 2 + offset);
const hy = Math.max(1, height / 2 + offset);
const r = clamp(radius + offset, 0, Math.min(hx, hy));
const vr = Math.min(r + 48, Math.min(hx, hy));
const ax = hx - vr;
const ay = hy - vr;
const quarter = ax + ay + HALF_PI * vr;
return { hx, hy, r, vr, ax, ay, quarter, perimeter: quarter * 4 };
};
const pointOn = (path: BorderPath, s: number) => {
const { hx, hy, r, vr, ax, ay, quarter, perimeter } = path;
const u = ((s % perimeter) + perimeter) % perimeter;
const part = Math.min(3, Math.floor(u / quarter));
const rest = u - part * quarter;
const local = part % 2 ? quarter - rest : rest;
let x;
let y;
if (local <= ax) {
x = local;
y = -hy;
} else if (local <= ax + HALF_PI * vr) {
const angle = vr > 0 ? (local - ax) / vr : 0;
const dx = Math.sin(angle);
const dy = -Math.cos(angle);
let low = 0;
let high = vr + r + 2;
for (let step = 0; step < 18; step++) {
const mid = (low + high) / 2;
if (roundBox(ax + dx * mid, -ay + dy * mid, hx, hy, r) < 0) low = mid;
else high = mid;
}
x = ax + dx * low;
y = -ay + dy * low;
} else {
x = hx;
y = -ay + (local - ax - HALF_PI * vr);
}
const flipX = part >= 2 ? -1 : 1;
const flipY = part === 1 || part === 2 ? -1 : 1;
const px = x * flipX;
const py = y * flipY;
const nx = roundBox(px + 0.5, py, hx, hy, r) - roundBox(px - 0.5, py, hx, hy, r);
const ny = roundBox(px, py + 0.5, hx, hy, r) - roundBox(px, py - 0.5, hx, hy, r);
const length = Math.hypot(nx, ny) || 1;
return { x: px, y: py, nx: nx / length, ny: ny / length };
};
const nearestOn = (path: BorderPath, x: number, y: number) => {
const { ax, ay, vr, quarter } = path;
const qx = Math.abs(x) - ax;
const qy = Math.abs(y) - ay;
let t;
if (qx > 0 && qy > 0) t = ax + vr * Math.atan2(qx, qy);
else if (qx > qy) t = ax + HALF_PI * vr + ay - Math.abs(y);
else t = Math.abs(x);
t = clamp(t, 0, quarter);
if (x >= 0) return y < 0 ? t : 2 * quarter - t;
return y < 0 ? 4 * quarter - t : 2 * quarter + t;
};
</script>
<script lang="ts">
import { untrack } from 'svelte';
let {
children,
color = '#f43f5e',
glowColor,
speed = 1,
chaos = 0.5,
spread = 0.6,
thickness = 1.5,
strands = 3,
glow = 0.6,
intensity = 0.8,
arcs = 1,
flicker = 0.5,
borderRadius = 24,
offset = 0,
active = true,
intro = true,
interactive = true,
cursorRadius = 90,
theme = 'dark',
class: className = '',
style
}: ElectricBorderProps = $props();
let containerEl = $state<HTMLDivElement>();
let canvasEl = $state<HTMLCanvasElement>();
// Счётчик пересоздания контекста (после webglcontextrestored).
let generation = $state(0);
// Функция «разбудить» цикл рендера; выставляется внутри setup().
let wakeHandle: (() => void) | null = null;
const base = $derived(parseColor(color));
// Актуальные настройки. Колбэки внутри setup() читают их в момент вызова,
// поэтому смена пропсов подхватывается без пересоздания WebGL-контекста.
const settings: Settings = $derived({
core: base.map((channel) => channel + (1 - channel) * 0.55),
glowColor: glowColor ? parseColor(glowColor) : base,
speed: Math.max(0, speed),
chaos: clamp(chaos, 0, 2),
spread: clamp(spread, 0, 2),
thickness: Math.max(0.25, thickness),
strands: clamp(Math.round(strands), 1, 6),
glow: clamp(glow, 0, 3),
intensity: Math.max(0, intensity),
arcs: Math.max(0, arcs),
flicker: clamp(flicker, 0, 1),
borderRadius: Math.max(0, borderRadius),
offset,
active,
intro,
interactive,
cursorRadius: Math.max(1, cursorRadius),
light: theme === 'light'
});
// Аналог useEffect без зависимостей: при любом изменении настроек
// пересчитываем размеры и будим цикл рендера.
$effect(() => {
JSON.stringify(settings);
wakeHandle?.();
});
// Основной эффект. Перезапускается только при смене generation;
// всё остальное читается внутри untrack и не создаёт зависимостей.
$effect(() => {
const gen = generation;
return untrack(() => setup(gen));
});
function setup(gen: number): (() => void) | undefined {
const container = containerEl;
const canvas = canvasEl;
if (!container || !canvas) return undefined;
const gl = canvas.getContext('webgl2', {
alpha: true,
premultipliedAlpha: true,
antialias: false,
depth: false,
stencil: false,
powerPreference: 'default'
});
const program = gl ? link(gl) : null;
if (!gl || !program) return undefined;
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const vao = gl.createVertexArray();
const arcEnds = new Float32Array(ARCS * 4);
const arcShape = new Float32Array(ARCS * 4);
const pulseData = new Float32Array(PULSES * 4);
const size = { width: 1, height: 1, ratio: 1 };
const pointer = { x: 0, y: 0, over: false, near: false };
const hover = { x: 0, y: 0, vx: 0, vy: 0, power: 0 };
const sparks: Spark[] = [];
const pulses: Pulse[] = [];
const first = settings;
let power = first.active && !(first.intro && !reduced && gen === 0) ? 1 : 0;
let reveal = power;
let wasActive = first.active;
let ink = first.light ? 1 : 0;
let path = pathOf(1, 1, 0, 0);
let time = Math.random() * 100;
let raf = 0;
let last = 0;
let visible = true;
let pageVisible = document.visibilityState !== 'hidden';
let lost = false;
const resize = () => {
const s = settings;
const width = Math.max(1, container.offsetWidth);
const height = Math.max(1, container.offsetHeight);
const canvasWidth = width + MARGIN * 2;
const canvasHeight = height + MARGIN * 2;
const ratio = Math.min(
window.devicePixelRatio || 1,
2,
Math.sqrt(PIXEL_BUDGET / (canvasWidth * canvasHeight))
);
const pixelWidth = Math.max(1, Math.round(canvasWidth * ratio));
const pixelHeight = Math.max(1, Math.round(canvasHeight * ratio));
if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) {
canvas.width = pixelWidth;
canvas.height = pixelHeight;
}
canvas.style.width = `${canvasWidth}px`;
canvas.style.height = `${canvasHeight}px`;
Object.assign(size, { width, height, ratio: pixelWidth / canvasWidth });
path = pathOf(width, height, s.borderRadius, s.offset);
};
const spawnArc = (now: number, near: number | null): Spark => {
const span = Math.min(path.perimeter * 0.3, 60 + Math.random() * 90);
const start = near ?? Math.random() * path.perimeter;
const from = start - span * (0.2 + Math.random() * 0.6);
const to = from + span;
const a = pointOn(path, from);
const b = pointOn(path, to);
const mid = pointOn(path, (from + to) / 2);
const dx = b.x - a.x;
const dy = b.y - a.y;
const length = Math.hypot(dx, dy) || 1;
const side = (-dy / length) * mid.nx + (dx / length) * mid.ny;
const bow = Math.min(MARGIN - 30, length * (0.14 + Math.random() * 0.16) + 5);
return {
ax: a.x,
ay: a.y,
bx: b.x,
by: b.y,
bow: side >= 0 ? bow : -bow,
seed: 1 + Math.random() * 60,
born: now,
life: 0.35 + Math.random() * 0.45
};
};
const render = (s: Settings, heatPower: number) => {
const u = program.uniforms;
const width = size.width + MARGIN * 2;
const height = size.height + MARGIN * 2;
gl.viewport(0, 0, canvas.width, canvas.height);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
if (power <= 0.001 && !sparks.length) return;
gl.useProgram(program.program);
gl.uniform2f(u.uSize, width, height);
gl.uniform1f(u.uRatio, size.ratio);
gl.uniform2f(u.uCenter, width / 2, height / 2);
gl.uniform2f(u.uHalf, path.hx, path.hy);
gl.uniform1f(u.uRadius, path.r);
gl.uniform3f(u.uParam, path.ax, path.ay, path.vr);
gl.uniform1f(u.uPerimeter, path.perimeter);
gl.uniform4f(
u.uCells,
Math.max(1, Math.round(path.perimeter / 75)),
Math.max(2, Math.round(path.perimeter / 25)),
Math.max(3, Math.round(path.perimeter / 9)),
Math.max(4, Math.round(path.perimeter / 4))
);
gl.uniform2f(
u.uSoft,
Math.max(1, Math.round(path.perimeter / 150)),
Math.max(1, Math.round(path.perimeter / 50))
);
gl.uniform1f(u.uTime, time);
gl.uniform1f(u.uPower, power * power * (3 - 2 * power));
gl.uniform1f(u.uReveal, reveal);
gl.uniform3f(u.uHover, hover.x + MARGIN, hover.y + MARGIN, heatPower);
gl.uniform1f(u.uHoverRadius, s.cursorRadius);
gl.uniform4fv(u.uPulses, pulseData);
gl.uniform3fv(u.uCore, s.core);
gl.uniform3fv(u.uGlowColor, s.glowColor);
gl.uniform1f(u.uIntensity, s.intensity);
gl.uniform1f(u.uGlow, s.glow);
gl.uniform1f(u.uThickness, s.thickness);
gl.uniform1f(u.uStrands, s.strands);
gl.uniform1f(u.uChaos, s.chaos);
gl.uniform1f(u.uSpread, s.spread);
gl.uniform1f(u.uFlicker, reduced ? 0 : s.flicker);
gl.uniform1f(u.uInk, ink);
gl.uniform4fv(u.uArcEnds, arcEnds);
gl.uniform4fv(u.uArcShape, arcShape);
gl.bindVertexArray(vao);
gl.drawArrays(gl.TRIANGLES, 0, 3);
};
const frame = (now: number) => {
raf = 0;
if (lost) return;
const s = settings;
const dt = clamp((now - last) / 1000 || 0, 0, 0.05);
last = Math.max(last, now);
const motion = reduced ? 0.2 : 1;
time += dt * s.speed * motion;
if (s.active && !wasActive) reveal = 0;
wasActive = s.active;
power += ((s.active ? 1 : 0) - power) * (1 - Math.exp(-dt / (s.active ? 0.28 : 0.4)));
if (s.active) reveal = reduced ? 1 : Math.min(1, reveal + dt / 0.9);
ink += ((s.light ? 1 : 0) - ink) * (1 - Math.exp(-dt / 0.25));
const engaged = s.interactive && pointer.near && s.active;
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)));
for (let index = sparks.length - 1; index >= 0; index--) {
if (time - sparks[index].born > sparks[index].life) sparks.splice(index, 1);
}
if (!reduced && s.active && reveal >= 1 && s.arcs > 0 && sparks.length < ARCS) {
const chance = dt * s.speed * s.arcs;
if (Math.random() < chance * 5 * hover.power) {
sparks.push(
spawnArc(time, nearestOn(path, hover.x - size.width / 2, hover.y - size.height / 2))
);
} else if (Math.random() < chance * (0.6 + path.perimeter / 2400)) {
sparks.push(spawnArc(time, null));
}
}
const fit = power * power * (3 - 2 * power);
for (let index = 0; index < ARCS; index++) {
const o = index * 4;
const spark = sparks[index];
if (!spark) {
arcShape[o + 1] = 0;
continue;
}
const k = clamp((time - spark.born) / spark.life, 0, 1);
arcEnds[o] = spark.ax + size.width / 2 + MARGIN;
arcEnds[o + 1] = spark.ay + size.height / 2 + MARGIN;
arcEnds[o + 2] = spark.bx + size.width / 2 + MARGIN;
arcEnds[o + 3] = spark.by + size.height / 2 + MARGIN;
arcShape[o] = spark.bow;
arcShape[o + 1] = Math.sin(Math.PI * k) * fit;
arcShape[o + 2] = spark.seed;
}
for (let index = pulses.length - 1; index >= 0; index--) {
if (now - pulses[index].born > 2200) pulses.splice(index, 1);
}
for (let index = 0; index < PULSES; index++) {
const o = index * 4;
const pulse = pulses[index];
if (!pulse) {
pulseData[o + 2] = 0;
continue;
}
pulseData[o] = pulse.s;
pulseData[o + 1] = (now - pulse.born) / 1000;
pulseData[o + 2] = 1;
}
render(s, hover.power * (s.interactive ? 1 : 0));
const busy = power > 0.002 || s.active || sparks.length > 0;
if (busy && visible && pageVisible) raf = requestAnimationFrame(frame);
};
const wake = () => {
if (raf || lost || !visible || !pageVisible) return;
last = performance.now();
raf = requestAnimationFrame(frame);
};
wakeHandle = () => {
resize();
wake();
};
const locate = (event: PointerEvent) => {
const rect = container.getBoundingClientRect();
pointer.x = event.clientX - rect.left;
pointer.y = event.clientY - rect.top;
pointer.over =
pointer.x >= 0 && pointer.y >= 0 && pointer.x <= rect.width && pointer.y <= rect.height;
pointer.near =
pointer.x >= -MARGIN / 2 &&
pointer.y >= -MARGIN / 2 &&
pointer.x <= rect.width + MARGIN / 2 &&
pointer.y <= rect.height + MARGIN / 2;
};
const onPointerMove = (event: PointerEvent) => {
locate(event);
if (pointer.near) wake();
};
const onPointerDown = (event: PointerEvent) => {
locate(event);
const s = settings;
if (!pointer.over || !s.interactive || !s.active || reduced) return;
const along = nearestOn(path, pointer.x - size.width / 2, pointer.y - size.height / 2);
pulses.push({ s: along, born: performance.now() });
if (pulses.length > PULSES) pulses.shift();
if (sparks.length < ARCS) sparks.push(spawnArc(time, along));
wake();
};
const onLeave = () => {
pointer.over = false;
pointer.near = false;
};
window.addEventListener('pointermove', onPointerMove, { passive: true });
window.addEventListener('pointerdown', onPointerDown, { passive: true });
document.documentElement.addEventListener('pointerleave', onLeave);
const onLost = (event: Event) => {
event.preventDefault();
lost = true;
cancelAnimationFrame(raf);
raf = 0;
};
const onRestored = () => {
generation += 1;
};
canvas.addEventListener('webglcontextlost', onLost);
canvas.addEventListener('webglcontextrestored', onRestored);
const resizeObserver = new ResizeObserver(() => {
resize();
wake();
});
resizeObserver.observe(container);
const intersection = new IntersectionObserver((entries) => {
visible = entries.some((entry) => entry.isIntersecting);
wake();
});
intersection.observe(container);
const onVisibility = () => {
pageVisible = document.visibilityState !== 'hidden';
wake();
};
document.addEventListener('visibilitychange', onVisibility);
resize();
wake();
return () => {
cancelAnimationFrame(raf);
wakeHandle = null;
resizeObserver.disconnect();
intersection.disconnect();
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerdown', onPointerDown);
document.documentElement.removeEventListener('pointerleave', onLeave);
document.removeEventListener('visibilitychange', onVisibility);
canvas.removeEventListener('webglcontextlost', onLost);
canvas.removeEventListener('webglcontextrestored', onRestored);
if (!gl.isContextLost()) {
gl.deleteVertexArray(vao);
gl.deleteProgram(program.program);
}
};
}
</script>
<div
bind:this={containerEl}
class={['relative isolate', className]}
style="border-radius: {borderRadius}px; {style ?? ''}"
>
{@render children?.()}
<canvas
bind:this={canvasEl}
class="pointer-events-none absolute z-[1] block"
style:top="{-MARGIN}px"
style:left="{-MARGIN}px"
aria-hidden="true"
></canvas>
</div><script module lang="ts">
import type { Snippet } from 'svelte';
export type ElectricBorderProps = {
children?: Snippet;
color?: string;
glowColor?: string;
speed?: number;
chaos?: number;
spread?: number;
thickness?: number;
strands?: number;
glow?: number;
intensity?: number;
arcs?: number;
flicker?: number;
borderRadius?: number;
offset?: number;
active?: boolean;
intro?: boolean;
interactive?: boolean;
cursorRadius?: number;
theme?: 'dark' | 'light';
class?: string;
/** Строка inline-стилей (в Svelte style — это строка, а не объект). */
style?: string;
};
type Settings = {
core: number[];
glowColor: number[];
speed: number;
chaos: number;
spread: number;
thickness: number;
strands: number;
glow: number;
intensity: number;
arcs: number;
flicker: number;
borderRadius: number;
offset: number;
active: boolean;
intro: boolean;
interactive: boolean;
cursorRadius: number;
light: boolean;
};
type Program = {
program: WebGLProgram;
uniforms: Record<string, WebGLUniformLocation | null>;
};
type BorderPath = {
hx: number;
hy: number;
r: number;
vr: number;
ax: number;
ay: number;
quarter: number;
perimeter: number;
};
type Spark = {
ax: number;
ay: number;
bx: number;
by: number;
bow: number;
seed: number;
born: number;
life: number;
};
type Pulse = {
s: number;
born: number;
};
const ARCS = 4;
const PULSES = 3;
const MARGIN = 64;
const PIXEL_BUDGET = 3e6;
const SURGE_SPEED = 560;
const HALF_PI = Math.PI / 2;
const VERTEX = `#version 300 es
const vec2 corners[3] = vec2[3](vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0));
void main() {
gl_Position = vec4(corners[gl_VertexID], 0.0, 1.0);
}`;
const FRAGMENT = `#version 300 es
precision highp float;
precision highp int;
uniform vec2 uSize;
uniform float uRatio;
uniform vec2 uCenter;
uniform vec2 uHalf;
uniform float uRadius;
uniform vec3 uParam;
uniform float uPerimeter;
uniform vec4 uCells;
uniform vec2 uSoft;
uniform float uTime;
uniform float uPower;
uniform float uReveal;
uniform vec3 uHover;
uniform float uHoverRadius;
uniform vec4 uPulses[${PULSES}];
uniform vec3 uCore;
uniform vec3 uGlowColor;
uniform float uIntensity;
uniform float uGlow;
uniform float uThickness;
uniform float uStrands;
uniform float uChaos;
uniform float uSpread;
uniform float uFlicker;
uniform float uInk;
uniform vec4 uArcEnds[${ARCS}];
uniform vec4 uArcShape[${ARCS}];
out vec4 fragColor;
uint scramble(uint x) {
x ^= x >> 16u;
x *= 0x7feb352du;
x ^= x >> 15u;
x *= 0x846ca68bu;
x ^= x >> 16u;
return x;
}
vec2 slant(uint x, uint y, uint seed) {
uint h = scramble(x * 0x8da6b343u + y * 0xd8163841u + seed * 0xcb1ab31fu);
return vec2(uvec2(h, h >> 8u) & 255u) / 127.5 - 1.0;
}
vec2 ring(vec2 p, float period, uint seed) {
vec2 i = floor(p);
vec2 f = p - i;
vec2 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);
float du = 30.0 * f.x * f.x * (f.x * (f.x - 2.0) + 1.0);
uint x0 = uint(mod(i.x, period));
uint x1 = uint(mod(i.x + 1.0, period));
uint y0 = uint(i.y + 65536.0);
vec2 ga = slant(x0, y0, seed);
vec2 gb = slant(x1, y0, seed);
vec2 gc = slant(x0, y0 + 1u, seed);
vec2 gd = slant(x1, y0 + 1u, seed);
float va = dot(ga, f);
float vb = dot(gb, f - vec2(1.0, 0.0));
float vc = dot(gc, f - vec2(0.0, 1.0));
float vd = dot(gd, f - vec2(1.0, 1.0));
float k = va - vb - vc + vd;
float value = va + u.x * (vb - va) + u.y * (vc - va) + u.x * u.y * k;
float gx = ga.x + u.x * (gb.x - ga.x) + u.y * (gc.x - ga.x) + u.x * u.y * (ga.x - gb.x - gc.x + gd.x);
return vec2(value, gx + du * (vb - va + u.y * k));
}
float wobble(float x, uint seed) {
return ring(vec2(x, 0.5), 1048576.0, seed).x;
}
vec2 frameAt(vec2 p) {
vec2 inner = max(uHalf - uRadius, 0.0);
vec2 q = abs(p) - inner;
float d = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - uRadius;
float quarter = uPerimeter * 0.25;
vec2 v = abs(p) - uParam.xy;
float t;
if (v.x > 0.0 && v.y > 0.0) t = uParam.x + uParam.z * atan(v.x, v.y);
else if (v.x > v.y) t = uParam.x + 1.5707963 * uParam.z + uParam.y - abs(p.y);
else t = abs(p.x);
t = clamp(t, 0.0, quarter);
float s = p.x >= 0.0 ? (p.y < 0.0 ? t : 2.0 * quarter - t) : (p.y < 0.0 ? 4.0 * quarter - t : 2.0 * quarter + t);
return vec2(s, d);
}
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 along = dot(rel, dir);
float side = dot(rel, vec2(-dir.y, dir.x));
float margin = abs(info.x) + (0.6 + uChaos) * (2.0 + len * 0.08) + uThickness * 12.0 + 10.0;
if (along < -margin || along > len + margin || abs(side) > margin) return;
float u = clamp(along / len, 0.0, 1.0);
float taper = sin(3.14159265 * u);
float settle = smoothstep(0.0, 0.15, u) * smoothstep(1.0, 0.85, u);
float bendSlope = 3.14159265 / len * cos(3.14159265 * u) * settle;
float beyond = max(-along, 0.0) + max(along - 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 = (0.6 + uChaos) * (1.5 + len * 0.05) * (c == 0 ? 1.0 : 1.5);
for (int o = 0; o < 3; o++) {
vec2 n = ring(vec2(along / wave + info.z * 3.0, t * (1.4 + float(o) * 1.1)), 1048576.0, seed + uint(o));
jag += n.x * weight;
jagSlope += n.y * weight / wave;
wave *= 0.42;
weight *= 0.4;
}
float offset = (info.x + jag) * taper;
float offsetSlope = (info.x + jag) * bendSlope + jagSlope * taper * settle;
float across = (side - 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 += (uCore * 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(gl_FragCoord.x / uRatio, uSize.y - gl_FragCoord.y / uRatio);
vec2 frame = frameAt(p - uCenter);
float s = frame.x;
float d = frame.y;
float t = uTime;
vec3 light = vec3(0.0);
float energy = 0.0;
float hot = 0.0;
float along = min(s, uPerimeter - s);
float front = uReveal * (uPerimeter * 0.5 + 80.0);
float shown = smoothstep(front, front - 70.0, along) * uPower;
float flare = uReveal < 1.0 ? exp(-pow((along - front + 30.0) / 26.0, 2.0)) * uPower : 0.0;
vec2 toHover = p - uHover.xy;
float heat = uHover.z * exp(-dot(toHover, toHover) / (uHoverRadius * uHoverRadius));
float surge = 0.0;
for (int i = 0; i < ${PULSES}; i++) {
vec4 pulse = uPulses[i];
if (pulse.z <= 0.0) continue;
float gap = abs(s - pulse.x);
gap = min(gap, uPerimeter - gap);
float wave = (gap - pulse.y * ${SURGE_SPEED}.0) / 46.0;
surge += exp(-wave * wave) * pulse.z * exp(-pulse.y * 1.2);
}
heat = min(heat * 0.7 + surge * 0.8 + flare * 0.6, 1.2);
vec2 inset = min(p, uSize - p);
float frameFade = smoothstep(0.0, 14.0, min(inset.x, inset.y));
float edge = abs(d);
float inward = d < 0.0 ? exp(d / 14.0) : 1.0;
float bloom = (exp(-edge / 3.0) * 0.1 + exp(-edge / 16.0) * 0.05 * inward) * uGlow * (1.0 + heat * 0.6) * shown;
light += uGlowColor * bloom;
energy += bloom;
float bendAmp = uSpread * 16.0 * (1.0 + heat * 0.35);
float rippleAmp = uSpread * 6.0 * (1.0 + heat * 0.3);
float crinkleAmp = uChaos * 4.5 * (1.0 + heat * 0.3);
float squeeze = min(1.0, 34.0 / max((bendAmp + rippleAmp) * 1.6 + crinkleAmp, 1e-3));
bendAmp *= squeeze;
rippleAmp *= squeeze;
crinkleAmp *= squeeze;
float reach = (bendAmp * 1.9 + rippleAmp + crinkleAmp * 1.6) * 1.3 + uThickness * 14.0 + 8.0;
if (edge < reach && shown > 0.0) {
float fade = smoothstep(reach, reach * 0.55, edge);
float count = min(uStrands + heat * 1.2, 6.0);
float slopeScale = 1.0 / uPerimeter;
for (int i = 0; i < 6; i++) {
float fi = float(i);
if (fi >= ceil(count)) break;
float present = clamp(count - fi, 0.0, 1.0);
uint seed = uint(i) * 7u + 3u;
float lead = i == 0 ? 1.0 : 0.0;
float range = mix(1.35 + fi * 0.18, 0.7, lead);
float x = s * slopeScale;
vec2 a = ring(vec2(x * uCells.x, t * 0.7 + fi * 3.1), uCells.x, seed);
vec2 b = ring(vec2(x * uCells.y, t * 1.9 + fi * 1.7), uCells.y, seed + 1u);
vec2 c = ring(vec2(x * uCells.z, t * 4.6 + fi * 0.9), uCells.z, seed + 2u);
vec2 e = ring(vec2(x * uCells.w, t * 6.8), uCells.w, seed + 3u);
float offset = (a.x * bendAmp + b.x * rippleAmp) * range + (c.x + e.x * 0.35) * crinkleAmp;
float slope =
((a.y * uCells.x * bendAmp + b.y * uCells.y * rippleAmp) * range +
(c.y * uCells.z + e.y * 0.35 * uCells.w) * crinkleAmp) *
slopeScale;
float drift = lead > 0.5 ? 0.0 : wobble(fi * 7.3, seed + 9u) * 2.5 * uSpread;
offset += drift;
float across = (d - offset) / sqrt(1.0 + slope * slope);
float spread = d - a.x * bendAmp * range - drift;
float swell = 0.5 + 0.5 * ring(vec2(x * uSoft.y, t * 2.0 + fi * 5.1), uSoft.y, seed + 8u).x;
float w = uThickness * mix(0.55, 1.0, lead) * (0.5 + swell);
float vis = mix(0.3 + 0.45 * smoothstep(-0.25, 0.2, ring(vec2(x * uSoft.x, t * 0.5 + fi * 3.7), uSoft.x, seed + 5u).x), 1.0, lead);
float spark = 1.0 - uFlicker * 0.3 * (0.5 + 0.5 * wobble(t * 13.0 + fi * 2.3, seed + 6u));
float weight = max(vis, heat * 0.6) * spark * fade * present * (0.7 + 0.6 * swell);
vec2 g = glowShape(across, spread, w) * weight;
g.y *= inward;
float haze = uGlow * (1.0 + heat * 0.5) * (1.0 - uInk * 0.7);
light += uCore * g.x + uGlowColor * g.y * haze;
energy += g.x + g.y * haze;
hot += exp(-across * across / (w * w * 0.16)) * lead * weight;
}
light *= shown;
energy *= shown;
hot *= shown;
}
for (int i = 0; i < ${ARCS}; i++) addArc(p, uArcEnds[i], uArcShape[i], t, light, energy, hot);
float breath = 1.0 + uFlicker * 0.5 * wobble(t * 4.6 + 7.0, 3u);
float gain = uIntensity * breath * (1.0 + heat * 0.25) * 1.4;
light *= frameFade;
energy *= frameFade;
hot *= frameFade;
float alpha = 1.0 - exp(-energy * gain);
vec3 color = mix(1.0 - exp(-light * gain), alpha * light / max(energy, 1e-4), uInk);
color = mix(color, vec3(alpha), clamp(hot, 0.0, 1.0) * uInk * 0.85);
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 clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
const parseColor = (value: string): number[] => {
const text = value.trim();
const hex = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(text);
if (hex) {
const full = hex[1].length === 3 ? hex[1].replace(/./g, (digit) => digit + digit) : hex[1];
return [0, 2, 4].map((index) => parseInt(full.slice(index, index + 2), 16) / 255);
}
const rgb = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/i.exec(text);
if (rgb) return [rgb[1], rgb[2], rgb[3]].map((channel) => clamp(parseFloat(channel) / 255, 0, 1));
if (typeof document !== 'undefined') {
const context = document.createElement('canvas').getContext('2d');
if (context) {
context.fillStyle = '#000000';
context.fillStyle = text;
const normal = context.fillStyle;
if (normal !== '#000000' || /^(#0{3}|#0{6}|black)$/i.test(text)) return parseColor(normal);
}
}
return [0.49, 0.98, 1];
};
const compile = (gl: WebGL2RenderingContext, type: number, source: string) => {
const shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error(gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
return shader;
};
const link = (gl: WebGL2RenderingContext): Program | null => {
const vertex = compile(gl, gl.VERTEX_SHADER, VERTEX);
const fragment = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT);
const program = vertex && fragment ? gl.createProgram() : null;
if (!vertex || !fragment || !program) {
if (vertex) gl.deleteShader(vertex);
if (fragment) gl.deleteShader(fragment);
return null;
}
gl.attachShader(program, vertex);
gl.attachShader(program, fragment);
gl.linkProgram(program);
gl.deleteShader(vertex);
gl.deleteShader(fragment);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error(gl.getProgramInfoLog(program));
gl.deleteProgram(program);
return null;
}
const uniforms: Program['uniforms'] = {};
const count = gl.getProgramParameter(program, gl.ACTIVE_UNIFORMS);
for (let index = 0; index < count; index++) {
const info = gl.getActiveUniform(program, index);
if (info) uniforms[info.name.replace(/\[0\]$/, '')] = gl.getUniformLocation(program, info.name);
}
return { program, uniforms };
};
const roundBox = (x: number, y: number, hx: number, hy: number, r: number) => {
const qx = Math.abs(x) - (hx - r);
const qy = Math.abs(y) - (hy - r);
return Math.hypot(Math.max(qx, 0), Math.max(qy, 0)) + Math.min(Math.max(qx, qy), 0) - r;
};
const pathOf = (width: number, height: number, radius: number, offset: number): BorderPath => {
const hx = Math.max(1, width / 2 + offset);
const hy = Math.max(1, height / 2 + offset);
const r = clamp(radius + offset, 0, Math.min(hx, hy));
const vr = Math.min(r + 48, Math.min(hx, hy));
const ax = hx - vr;
const ay = hy - vr;
const quarter = ax + ay + HALF_PI * vr;
return { hx, hy, r, vr, ax, ay, quarter, perimeter: quarter * 4 };
};
const pointOn = (path: BorderPath, s: number) => {
const { hx, hy, r, vr, ax, ay, quarter, perimeter } = path;
const u = ((s % perimeter) + perimeter) % perimeter;
const part = Math.min(3, Math.floor(u / quarter));
const rest = u - part * quarter;
const local = part % 2 ? quarter - rest : rest;
let x;
let y;
if (local <= ax) {
x = local;
y = -hy;
} else if (local <= ax + HALF_PI * vr) {
const angle = vr > 0 ? (local - ax) / vr : 0;
const dx = Math.sin(angle);
const dy = -Math.cos(angle);
let low = 0;
let high = vr + r + 2;
for (let step = 0; step < 18; step++) {
const mid = (low + high) / 2;
if (roundBox(ax + dx * mid, -ay + dy * mid, hx, hy, r) < 0) low = mid;
else high = mid;
}
x = ax + dx * low;
y = -ay + dy * low;
} else {
x = hx;
y = -ay + (local - ax - HALF_PI * vr);
}
const flipX = part >= 2 ? -1 : 1;
const flipY = part === 1 || part === 2 ? -1 : 1;
const px = x * flipX;
const py = y * flipY;
const nx = roundBox(px + 0.5, py, hx, hy, r) - roundBox(px - 0.5, py, hx, hy, r);
const ny = roundBox(px, py + 0.5, hx, hy, r) - roundBox(px, py - 0.5, hx, hy, r);
const length = Math.hypot(nx, ny) || 1;
return { x: px, y: py, nx: nx / length, ny: ny / length };
};
const nearestOn = (path: BorderPath, x: number, y: number) => {
const { ax, ay, vr, quarter } = path;
const qx = Math.abs(x) - ax;
const qy = Math.abs(y) - ay;
let t;
if (qx > 0 && qy > 0) t = ax + vr * Math.atan2(qx, qy);
else if (qx > qy) t = ax + HALF_PI * vr + ay - Math.abs(y);
else t = Math.abs(x);
t = clamp(t, 0, quarter);
if (x >= 0) return y < 0 ? t : 2 * quarter - t;
return y < 0 ? 4 * quarter - t : 2 * quarter + t;
};
</script>
<script lang="ts">
import { untrack } from 'svelte';
let {
children,
color = '#f43f5e',
glowColor,
speed = 1,
chaos = 0.5,
spread = 0.6,
thickness = 1.5,
strands = 3,
glow = 0.6,
intensity = 0.8,
arcs = 1,
flicker = 0.5,
borderRadius = 24,
offset = 0,
active = true,
intro = true,
interactive = true,
cursorRadius = 90,
theme = 'dark',
class: className = '',
style
}: ElectricBorderProps = $props();
let containerEl = $state<HTMLDivElement>();
let canvasEl = $state<HTMLCanvasElement>();
// Счётчик пересоздания контекста (после webglcontextrestored).
let generation = $state(0);
// Функция «разбудить» цикл рендера; выставляется внутри setup().
let wakeHandle: (() => void) | null = null;
const base = $derived(parseColor(color));
// Актуальные настройки. Колбэки внутри setup() читают их в момент вызова,
// поэтому смена пропсов подхватывается без пересоздания WebGL-контекста.
const settings: Settings = $derived({
core: base.map((channel) => channel + (1 - channel) * 0.55),
glowColor: glowColor ? parseColor(glowColor) : base,
speed: Math.max(0, speed),
chaos: clamp(chaos, 0, 2),
spread: clamp(spread, 0, 2),
thickness: Math.max(0.25, thickness),
strands: clamp(Math.round(strands), 1, 6),
glow: clamp(glow, 0, 3),
intensity: Math.max(0, intensity),
arcs: Math.max(0, arcs),
flicker: clamp(flicker, 0, 1),
borderRadius: Math.max(0, borderRadius),
offset,
active,
intro,
interactive,
cursorRadius: Math.max(1, cursorRadius),
light: theme === 'light'
});
// Аналог useEffect без зависимостей: при любом изменении настроек
// пересчитываем размеры и будим цикл рендера.
$effect(() => {
JSON.stringify(settings);
wakeHandle?.();
});
// Основной эффект. Перезапускается только при смене generation;
// всё остальное читается внутри untrack и не создаёт зависимостей.
$effect(() => {
const gen = generation;
return untrack(() => setup(gen));
});
function setup(gen: number): (() => void) | undefined {
const container = containerEl;
const canvas = canvasEl;
if (!container || !canvas) return undefined;
const gl = canvas.getContext('webgl2', {
alpha: true,
premultipliedAlpha: true,
antialias: false,
depth: false,
stencil: false,
powerPreference: 'default'
});
const program = gl ? link(gl) : null;
if (!gl || !program) return undefined;
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const vao = gl.createVertexArray();
const arcEnds = new Float32Array(ARCS * 4);
const arcShape = new Float32Array(ARCS * 4);
const pulseData = new Float32Array(PULSES * 4);
const size = { width: 1, height: 1, ratio: 1 };
const pointer = { x: 0, y: 0, over: false, near: false };
const hover = { x: 0, y: 0, vx: 0, vy: 0, power: 0 };
const sparks: Spark[] = [];
const pulses: Pulse[] = [];
const first = settings;
let power = first.active && !(first.intro && !reduced && gen === 0) ? 1 : 0;
let reveal = power;
let wasActive = first.active;
let ink = first.light ? 1 : 0;
let path = pathOf(1, 1, 0, 0);
let time = Math.random() * 100;
let raf = 0;
let last = 0;
let visible = true;
let pageVisible = document.visibilityState !== 'hidden';
let lost = false;
const resize = () => {
const s = settings;
const width = Math.max(1, container.offsetWidth);
const height = Math.max(1, container.offsetHeight);
const canvasWidth = width + MARGIN * 2;
const canvasHeight = height + MARGIN * 2;
const ratio = Math.min(
window.devicePixelRatio || 1,
2,
Math.sqrt(PIXEL_BUDGET / (canvasWidth * canvasHeight))
);
const pixelWidth = Math.max(1, Math.round(canvasWidth * ratio));
const pixelHeight = Math.max(1, Math.round(canvasHeight * ratio));
if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) {
canvas.width = pixelWidth;
canvas.height = pixelHeight;
}
canvas.style.width = `${canvasWidth}px`;
canvas.style.height = `${canvasHeight}px`;
Object.assign(size, { width, height, ratio: pixelWidth / canvasWidth });
path = pathOf(width, height, s.borderRadius, s.offset);
};
const spawnArc = (now: number, near: number | null): Spark => {
const span = Math.min(path.perimeter * 0.3, 60 + Math.random() * 90);
const start = near ?? Math.random() * path.perimeter;
const from = start - span * (0.2 + Math.random() * 0.6);
const to = from + span;
const a = pointOn(path, from);
const b = pointOn(path, to);
const mid = pointOn(path, (from + to) / 2);
const dx = b.x - a.x;
const dy = b.y - a.y;
const length = Math.hypot(dx, dy) || 1;
const side = (-dy / length) * mid.nx + (dx / length) * mid.ny;
const bow = Math.min(MARGIN - 30, length * (0.14 + Math.random() * 0.16) + 5);
return {
ax: a.x,
ay: a.y,
bx: b.x,
by: b.y,
bow: side >= 0 ? bow : -bow,
seed: 1 + Math.random() * 60,
born: now,
life: 0.35 + Math.random() * 0.45
};
};
const render = (s: Settings, heatPower: number) => {
const u = program.uniforms;
const width = size.width + MARGIN * 2;
const height = size.height + MARGIN * 2;
gl.viewport(0, 0, canvas.width, canvas.height);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
if (power <= 0.001 && !sparks.length) return;
gl.useProgram(program.program);
gl.uniform2f(u.uSize, width, height);
gl.uniform1f(u.uRatio, size.ratio);
gl.uniform2f(u.uCenter, width / 2, height / 2);
gl.uniform2f(u.uHalf, path.hx, path.hy);
gl.uniform1f(u.uRadius, path.r);
gl.uniform3f(u.uParam, path.ax, path.ay, path.vr);
gl.uniform1f(u.uPerimeter, path.perimeter);
gl.uniform4f(
u.uCells,
Math.max(1, Math.round(path.perimeter / 75)),
Math.max(2, Math.round(path.perimeter / 25)),
Math.max(3, Math.round(path.perimeter / 9)),
Math.max(4, Math.round(path.perimeter / 4))
);
gl.uniform2f(
u.uSoft,
Math.max(1, Math.round(path.perimeter / 150)),
Math.max(1, Math.round(path.perimeter / 50))
);
gl.uniform1f(u.uTime, time);
gl.uniform1f(u.uPower, power * power * (3 - 2 * power));
gl.uniform1f(u.uReveal, reveal);
gl.uniform3f(u.uHover, hover.x + MARGIN, hover.y + MARGIN, heatPower);
gl.uniform1f(u.uHoverRadius, s.cursorRadius);
gl.uniform4fv(u.uPulses, pulseData);
gl.uniform3fv(u.uCore, s.core);
gl.uniform3fv(u.uGlowColor, s.glowColor);
gl.uniform1f(u.uIntensity, s.intensity);
gl.uniform1f(u.uGlow, s.glow);
gl.uniform1f(u.uThickness, s.thickness);
gl.uniform1f(u.uStrands, s.strands);
gl.uniform1f(u.uChaos, s.chaos);
gl.uniform1f(u.uSpread, s.spread);
gl.uniform1f(u.uFlicker, reduced ? 0 : s.flicker);
gl.uniform1f(u.uInk, ink);
gl.uniform4fv(u.uArcEnds, arcEnds);
gl.uniform4fv(u.uArcShape, arcShape);
gl.bindVertexArray(vao);
gl.drawArrays(gl.TRIANGLES, 0, 3);
};
const frame = (now: number) => {
raf = 0;
if (lost) return;
const s = settings;
const dt = clamp((now - last) / 1000 || 0, 0, 0.05);
last = Math.max(last, now);
const motion = reduced ? 0.2 : 1;
time += dt * s.speed * motion;
if (s.active && !wasActive) reveal = 0;
wasActive = s.active;
power += ((s.active ? 1 : 0) - power) * (1 - Math.exp(-dt / (s.active ? 0.28 : 0.4)));
if (s.active) reveal = reduced ? 1 : Math.min(1, reveal + dt / 0.9);
ink += ((s.light ? 1 : 0) - ink) * (1 - Math.exp(-dt / 0.25));
const engaged = s.interactive && pointer.near && s.active;
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)));
for (let index = sparks.length - 1; index >= 0; index--) {
if (time - sparks[index].born > sparks[index].life) sparks.splice(index, 1);
}
if (!reduced && s.active && reveal >= 1 && s.arcs > 0 && sparks.length < ARCS) {
const chance = dt * s.speed * s.arcs;
if (Math.random() < chance * 5 * hover.power) {
sparks.push(
spawnArc(time, nearestOn(path, hover.x - size.width / 2, hover.y - size.height / 2))
);
} else if (Math.random() < chance * (0.6 + path.perimeter / 2400)) {
sparks.push(spawnArc(time, null));
}
}
const fit = power * power * (3 - 2 * power);
for (let index = 0; index < ARCS; index++) {
const o = index * 4;
const spark = sparks[index];
if (!spark) {
arcShape[o + 1] = 0;
continue;
}
const k = clamp((time - spark.born) / spark.life, 0, 1);
arcEnds[o] = spark.ax + size.width / 2 + MARGIN;
arcEnds[o + 1] = spark.ay + size.height / 2 + MARGIN;
arcEnds[o + 2] = spark.bx + size.width / 2 + MARGIN;
arcEnds[o + 3] = spark.by + size.height / 2 + MARGIN;
arcShape[o] = spark.bow;
arcShape[o + 1] = Math.sin(Math.PI * k) * fit;
arcShape[o + 2] = spark.seed;
}
for (let index = pulses.length - 1; index >= 0; index--) {
if (now - pulses[index].born > 2200) pulses.splice(index, 1);
}
for (let index = 0; index < PULSES; index++) {
const o = index * 4;
const pulse = pulses[index];
if (!pulse) {
pulseData[o + 2] = 0;
continue;
}
pulseData[o] = pulse.s;
pulseData[o + 1] = (now - pulse.born) / 1000;
pulseData[o + 2] = 1;
}
render(s, hover.power * (s.interactive ? 1 : 0));
const busy = power > 0.002 || s.active || sparks.length > 0;
if (busy && visible && pageVisible) raf = requestAnimationFrame(frame);
};
const wake = () => {
if (raf || lost || !visible || !pageVisible) return;
last = performance.now();
raf = requestAnimationFrame(frame);
};
wakeHandle = () => {
resize();
wake();
};
const locate = (event: PointerEvent) => {
const rect = container.getBoundingClientRect();
pointer.x = event.clientX - rect.left;
pointer.y = event.clientY - rect.top;
pointer.over =
pointer.x >= 0 && pointer.y >= 0 && pointer.x <= rect.width && pointer.y <= rect.height;
pointer.near =
pointer.x >= -MARGIN / 2 &&
pointer.y >= -MARGIN / 2 &&
pointer.x <= rect.width + MARGIN / 2 &&
pointer.y <= rect.height + MARGIN / 2;
};
const onPointerMove = (event: PointerEvent) => {
locate(event);
if (pointer.near) wake();
};
const onPointerDown = (event: PointerEvent) => {
locate(event);
const s = settings;
if (!pointer.over || !s.interactive || !s.active || reduced) return;
const along = nearestOn(path, pointer.x - size.width / 2, pointer.y - size.height / 2);
pulses.push({ s: along, born: performance.now() });
if (pulses.length > PULSES) pulses.shift();
if (sparks.length < ARCS) sparks.push(spawnArc(time, along));
wake();
};
const onLeave = () => {
pointer.over = false;
pointer.near = false;
};
window.addEventListener('pointermove', onPointerMove, { passive: true });
window.addEventListener('pointerdown', onPointerDown, { passive: true });
document.documentElement.addEventListener('pointerleave', onLeave);
const onLost = (event: Event) => {
event.preventDefault();
lost = true;
cancelAnimationFrame(raf);
raf = 0;
};
const onRestored = () => {
generation += 1;
};
canvas.addEventListener('webglcontextlost', onLost);
canvas.addEventListener('webglcontextrestored', onRestored);
const resizeObserver = new ResizeObserver(() => {
resize();
wake();
});
resizeObserver.observe(container);
const intersection = new IntersectionObserver((entries) => {
visible = entries.some((entry) => entry.isIntersecting);
wake();
});
intersection.observe(container);
const onVisibility = () => {
pageVisible = document.visibilityState !== 'hidden';
wake();
};
document.addEventListener('visibilitychange', onVisibility);
resize();
wake();
return () => {
cancelAnimationFrame(raf);
wakeHandle = null;
resizeObserver.disconnect();
intersection.disconnect();
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerdown', onPointerDown);
document.documentElement.removeEventListener('pointerleave', onLeave);
document.removeEventListener('visibilitychange', onVisibility);
canvas.removeEventListener('webglcontextlost', onLost);
canvas.removeEventListener('webglcontextrestored', onRestored);
if (!gl.isContextLost()) {
gl.deleteVertexArray(vao);
gl.deleteProgram(program.program);
}
};
}
</script>
<div
bind:this={containerEl}
class={['relative isolate', className]}
style="border-radius: {borderRadius}px; {style ?? ''}"
>
{@render children?.()}
<canvas
bind:this={canvasEl}
class="pointer-events-none absolute z-[1] block"
style:top="{-MARGIN}px"
style:left="{-MARGIN}px"
aria-hidden="true"
></canvas>
</div>Использование
<script lang="ts">
import ElectricBorder from '$lib/components/ui-registry/ElectricBorder.svelte';
</script>
<ElectricBorder
color="#f43f5e"
glowColor="#f43f5e"
speed={1}
chaos={0.5}
spread={0.6}
thickness={1.5}
strands={3}
glow={0.6}
intensity={0.8}
arcs={1}
flicker={0.5}
borderRadius={24}
active
intro
interactive
theme="dark"
class="block w-full"
>
<div class="rounded-[inherit] border border-current/10 bg-background p-8">
Контент внутри электрической рамки
</div>
</ElectricBorder><script lang="ts">
import ElectricBorder from '$lib/components/ui-registry/ElectricBorder.svelte';
</script>
<ElectricBorder
color="#f43f5e"
glowColor="#f43f5e"
speed={1}
chaos={0.5}
spread={0.6}
thickness={1.5}
strands={3}
glow={0.6}
intensity={0.8}
arcs={1}
flicker={0.5}
borderRadius={24}
active
intro
interactive
theme="dark"
class="block w-full"
>
<div class="rounded-[inherit] border border-current/10 bg-background p-8">
Контент внутри электрической рамки
</div>
</ElectricBorder>Пропсы
| Пропс | Тип | По умолчанию | Описание |
|---|---|---|---|
children | Snippet | — | Контент, который отображается внутри рамки. |
color | string | '#f43f5e' | Основной цвет электрического контура в HEX. |
glowColor | string | Цвет color | Цвет ореола; если не задан, используется основной цвет. |
speed | number | 1 | Множитель скорости анимации. |
chaos | number | 0.5 | Неровность и хаотичность разрядов (внутренний диапазон 0–2). |
spread | number | 0.6 | Насколько далеко нити отклоняются от границы (0–2). |
thickness | number | 1.5 | Толщина электрических нитей. |
strands | number | 3 | Количество одновременно отображаемых нитей (1–6). |
glow | number | 0.6 | Интенсивность внешнего свечения (0–3). |
intensity | number | 0.8 | Общая интенсивность светового эффекта. |
arcs | number | 1 | Частота появления коротких электрических дуг; 0 отключает их. |
flicker | number | 0.5 | Интенсивность мерцания (0–1). |
borderRadius | number | 24 | Радиус скругления рамки в пикселях. |
offset | number | 0 | Смещение пути рамки относительно контейнера. |
active | boolean | true | Включает эффект. При отключении свечение плавно затухает. |
intro | boolean | true | Включает анимацию появления контура при инициализации. |
interactive | boolean | true | Включает реакцию на движение указателя и клики. |
cursorRadius | number | 90 | Радиус реакции на указатель в пикселях. |
theme | 'dark' 'light' | 'dark' | Вариант цветового рендеринга контура. |
class | string | '' | Дополнительные CSS-классы корневого контейнера. |
style | string | — | Inline-стили корневого контейнера. |
| Остальные атрибуты | HTMLAttributes<HTMLDivElement> | — | Передаются корневому контейнеру. |
Производительность и доступность
Компонент использует WebGL 2 и ограничивает разрешение canvas бюджетом пикселей. Анимация приостанавливается, когда компонент не виден или вкладка скрыта, и учитывает системную настройку prefers-reduced-motion. Для браузеров без WebGL 2 электрический эффект не отобразится; содержимое внутри рамки остаётся доступным.
Исходный код
Полный исходник ElectricBorder.svelte доступен во вкладке кода интерактивного демо.
