perf: optimize animated backgrounds for mobile — reduce GPU load and memory pressure

- Add useAnimationLoop hook with FPS throttling (30fps mobile/60fps desktop),
  Page Visibility API pause, and Telegram WebApp activated/deactivated events
- Add useAnimationPause hook for CSS/Framer Motion components
- Validate animation config with type allowlist, numeric clamping, JSON size guard
- Move ctx.setTransform from per-frame to init/resize across all canvas backgrounds
- Move ctx.lineCap to init/resize in vortex, replace Float32Array.set with direct writes
- Pre-compute shooting star delay instead of per-frame Math.random
- Cap DPR to 1.5 on mobile, reduce particle counts, cap blur to 4px
- Disable aurora CSS animation on mobile, remove blur on mobile spotlight
- Remove mix-blend-hard-light on mobile gradient-animation
- Add NaN/Infinity guard to clampNumber, remove dead CSS variables
- Add conditional unmount for Framer Motion backgrounds when paused
- Add animationPlayState pause for CSS-animated backgrounds
This commit is contained in:
Fringg
2026-03-01 22:36:57 +03:00
parent 430b703bbe
commit a933f661e4
18 changed files with 926 additions and 450 deletions

View File

@@ -7,19 +7,57 @@ import { DEFAULT_ANIMATION_CONFIG } from '@/components/ui/backgrounds/types';
import { backgroundComponents, prefetchBackground } from '@/components/ui/backgrounds/registry';
const ANIMATION_CACHE_KEY = 'cabinet_animation_config';
const MAX_CONFIG_JSON_LENGTH = 10_000;
const VALID_TYPES: ReadonlySet<string> = new Set<BackgroundType>([
'aurora',
'sparkles',
'vortex',
'shooting-stars',
'background-beams',
'background-beams-collision',
'gradient-animation',
'wavy',
'background-lines',
'boxes',
'meteors',
'grid',
'dots',
'spotlight',
'ripple',
'none',
]);
function validateConfig(parsed: unknown): AnimationConfig | null {
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return null;
const obj = parsed as Record<string, unknown>;
if (typeof obj.enabled !== 'boolean') return null;
if (typeof obj.type !== 'string' || !VALID_TYPES.has(obj.type)) return null;
if (typeof obj.opacity !== 'number') return null;
if (typeof obj.blur !== 'number') return null;
if (obj.settings != null && (typeof obj.settings !== 'object' || Array.isArray(obj.settings)))
return null;
return {
enabled: obj.enabled,
type: obj.type as BackgroundType,
opacity: Math.max(0, Math.min(1, obj.opacity)),
blur: Math.max(0, Math.min(50, obj.blur)),
settings: (obj.settings as Record<string, unknown>) ?? {},
reducedOnMobile: typeof obj.reducedOnMobile === 'boolean' ? obj.reducedOnMobile : true,
};
}
function getCachedConfig(): AnimationConfig | null {
try {
const cached = localStorage.getItem(ANIMATION_CACHE_KEY);
return cached ? JSON.parse(cached) : null;
if (!cached || cached.length > MAX_CONFIG_JSON_LENGTH) return null;
return validateConfig(JSON.parse(cached));
} catch {
return null;
}
}
// Prefetch the background JS chunk immediately based on localStorage cache.
// This starts the download before React even renders, so by the time
// Suspense needs the component, the chunk is already loaded.
const cachedConfig = getCachedConfig();
if (cachedConfig?.enabled && cachedConfig.type && cachedConfig.type !== 'none') {
prefetchBackground(cachedConfig.type);
@@ -34,18 +72,27 @@ function setCachedConfig(config: AnimationConfig) {
}
export function setCachedAnimationConfig(config: AnimationConfig) {
setCachedConfig(config);
const validated = validateConfig(config);
if (validated) setCachedConfig(validated);
}
function reduceMobileSettings(settings: Record<string, unknown>): Record<string, unknown> {
const reduced = { ...settings };
// 75% reduction (divide by 4) instead of 50% — much less GPU work
if (typeof reduced.particleCount === 'number')
reduced.particleCount = Math.floor((reduced.particleCount as number) / 2);
reduced.particleCount = Math.max(20, Math.floor(reduced.particleCount / 4));
if (typeof reduced.particleDensity === 'number')
reduced.particleDensity = Math.floor((reduced.particleDensity as number) / 2);
reduced.particleDensity = Math.max(50, Math.floor(reduced.particleDensity / 4));
if (typeof reduced.number === 'number')
reduced.number = Math.floor((reduced.number as number) / 2);
reduced.number = Math.max(5, Math.floor(reduced.number / 4));
if ('interactive' in reduced) reduced.interactive = false;
if (typeof reduced.lineCount === 'number')
reduced.lineCount = Math.max(5, Math.floor(reduced.lineCount / 2));
if (typeof reduced.rippleCount === 'number')
reduced.rippleCount = Math.max(2, Math.floor(reduced.rippleCount / 2));
if (typeof reduced.count === 'number') reduced.count = Math.max(3, Math.floor(reduced.count / 2));
if (typeof reduced.rows === 'number') reduced.rows = Math.max(4, Math.floor(reduced.rows * 0.6));
if (typeof reduced.cols === 'number') reduced.cols = Math.max(4, Math.floor(reduced.cols * 0.6));
return reduced;
}
@@ -58,7 +105,9 @@ export function BackgroundRenderer() {
const { data: config } = useQuery({
queryKey: ['animation-config'],
queryFn: async () => {
const result = await brandingApi.getAnimationConfig();
const raw = await brandingApi.getAnimationConfig();
// Validate and clamp API response same as localStorage
const result = validateConfig(raw) ?? DEFAULT_ANIMATION_CONFIG;
setCachedConfig(result);
return result;
},
@@ -84,17 +133,16 @@ export function BackgroundRenderer() {
? reduceMobileSettings(effectiveConfig.settings)
: effectiveConfig.settings;
// Render via portal on document.body with z-index: -1.
// This places the animated background BELOW the root stacking context,
// preventing Chrome's implicit compositing from promoting every
// overlapping element to its own GPU layer (the root cause of flickering).
// On mobile, cap blur to 4px max — full blur is extremely GPU-heavy
const effectiveBlur = isMobile ? Math.min(effectiveConfig.blur, 4) : effectiveConfig.blur;
return createPortal(
<div
className="pointer-events-none fixed inset-0"
style={{
zIndex: -2,
opacity: effectiveConfig.opacity,
filter: effectiveConfig.blur > 0 ? `blur(${effectiveConfig.blur}px)` : undefined,
filter: effectiveBlur > 0 ? `blur(${effectiveBlur}px)` : undefined,
contain: 'strict',
backfaceVisibility: 'hidden',
}}

View File

@@ -1,24 +1,31 @@
import { cn } from '@/lib/utils';
import { safeBoolean } from './types';
import { useAnimationPause } from '@/hooks/useAnimationLoop';
interface Props {
settings: Record<string, unknown>;
}
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
export default function AuroraBackground({ settings }: Props) {
const showRadialGradient = (settings.showRadialGradient as boolean) ?? true;
const showRadialGradient = safeBoolean(settings.showRadialGradient, true);
const paused = useAnimationPause();
return (
<div className="absolute inset-0 overflow-hidden">
<div
className={cn(
'pointer-events-none absolute -inset-[10px] animate-aurora opacity-50 blur-[10px] will-change-transform',
'pointer-events-none absolute -inset-[10px] opacity-50',
!isMobile && 'animate-aurora',
showRadialGradient &&
'[mask-image:radial-gradient(ellipse_at_100%_0%,black_10%,transparent_70%)]',
)}
style={{
backgroundImage:
'repeating-linear-gradient(100deg, #000 0%, #000 7%, transparent 10%, transparent 12%, #000 16%), repeating-linear-gradient(100deg, #3b82f6 10%, #a5b4fc 15%, #93c5fd 20%, #ddd6fe 25%, #60a5fa 30%)',
backgroundSize: '300%, 200%',
backgroundSize: isMobile ? '100%, 100%' : '300%, 200%',
animationPlayState: paused ? 'paused' : 'running',
}}
/>
</div>

View File

@@ -1,6 +1,7 @@
import React, { useRef, useState, useEffect } from 'react';
import React, { useRef, useState, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { cn } from '@/lib/utils';
import { useAnimationPause } from '@/hooks/useAnimationLoop';
interface Props {
settings: Record<string, unknown>;
@@ -71,38 +72,60 @@ function CollisionMechanism({
const [beamKey, setBeamKey] = useState(0);
const [cycleCollisionDetected, setCycleCollisionDetected] = useState(false);
const checkRef = useRef({ cycleDetected: false });
checkRef.current.cycleDetected = cycleCollisionDetected;
const checkCollision = useCallback(() => {
if (checkRef.current.cycleDetected) return;
if (!beamRef.current || !containerRef.current || !parentRef.current) return;
const beamRect = beamRef.current.getBoundingClientRect();
const containerRect = containerRef.current.getBoundingClientRect();
const parentRect = parentRef.current.getBoundingClientRect();
if (beamRect.bottom >= containerRect.top) {
const relativeX = beamRect.left - parentRect.left + beamRect.width / 2;
const relativeY = beamRect.bottom - parentRect.top;
setCollision({ detected: true, coordinates: { x: relativeX, y: relativeY } });
setCycleCollisionDetected(true);
}
}, [containerRef, parentRef]);
// Throttled collision detection loop.
// Parent unmounts this component when paused, so no visibility handling needed here.
useEffect(() => {
const checkCollision = () => {
if (beamRef.current && containerRef.current && parentRef.current && !cycleCollisionDetected) {
const beamRect = beamRef.current.getBoundingClientRect();
const containerRect = containerRef.current.getBoundingClientRect();
const parentRect = parentRef.current.getBoundingClientRect();
let animId = 0;
let lastCheck = 0;
const CHECK_INTERVAL = 100;
if (beamRect.bottom >= containerRect.top) {
const relativeX = beamRect.left - parentRect.left + beamRect.width / 2;
const relativeY = beamRect.bottom - parentRect.top;
setCollision({ detected: true, coordinates: { x: relativeX, y: relativeY } });
setCycleCollisionDetected(true);
}
const loop = (time: number) => {
if (time - lastCheck >= CHECK_INTERVAL) {
lastCheck = time;
checkCollision();
}
animId = requestAnimationFrame(loop);
};
const interval = setInterval(checkCollision, 50);
return () => clearInterval(interval);
}, [cycleCollisionDetected, containerRef, parentRef]);
animId = requestAnimationFrame(loop);
return () => {
if (animId) cancelAnimationFrame(animId);
};
}, [checkCollision]);
// Collision reset with proper timeout cleanup
useEffect(() => {
if (collision.detected && collision.coordinates) {
setTimeout(() => {
setCollision({ detected: false, coordinates: null });
setCycleCollisionDetected(false);
}, 2000);
setTimeout(() => {
setBeamKey((prev) => prev + 1);
}, 2000);
}
}, [collision]);
if (!collision.detected || !collision.coordinates) return;
const timer = setTimeout(() => {
setCollision({ detected: false, coordinates: null });
setCycleCollisionDetected(false);
setBeamKey((prev) => prev + 1);
}, 2000);
return () => clearTimeout(timer);
}, [collision.detected]);
return (
<>
@@ -154,17 +177,19 @@ function CollisionMechanism({
export default function BackgroundBeamsCollision({ settings: _settings }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const parentRef = useRef<HTMLDivElement>(null);
const paused = useAnimationPause();
return (
<div ref={parentRef} className="absolute inset-0 overflow-hidden">
{BEAMS.map((beam) => (
<CollisionMechanism
key={`${beam.initialX}-beam`}
beamOptions={beam}
containerRef={containerRef}
parentRef={parentRef}
/>
))}
{!paused &&
BEAMS.map((beam) => (
<CollisionMechanism
key={`${beam.initialX}-beam`}
beamOptions={beam}
containerRef={containerRef}
parentRef={parentRef}
/>
))}
{/* Bottom collision line */}
<div
ref={containerRef}

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { useAnimationPause } from '@/hooks/useAnimationLoop';
interface Props {
settings: Record<string, unknown>;
@@ -99,16 +100,15 @@ function ensureStyles() {
}
export default React.memo(function BackgroundBeams({ settings: _settings }: Props) {
const paused = useAnimationPause();
// Inject CSS once on first render
React.useEffect(() => {
ensureStyles();
}, []);
return (
<div
className="absolute inset-0 overflow-hidden"
style={{ contain: 'strict', willChange: 'transform' }}
>
<div className="absolute inset-0 overflow-hidden" style={{ contain: 'strict' }}>
<svg
className="pointer-events-none absolute inset-0 h-full w-full"
width="100%"
@@ -138,6 +138,7 @@ export default React.memo(function BackgroundBeams({ settings: _settings }: Prop
strokeDashoffset="1"
style={{
animation: `beamDash ${beamParams[paramIndex].duration}s ease-in-out ${beamParams[paramIndex].delay}s infinite`,
animationPlayState: paused ? 'paused' : 'running',
}}
/>
))}

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useRef, useMemo } from 'react';
import { sanitizeColor, clampNumber } from './types';
import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop';
interface Props {
settings: Record<string, unknown>;
@@ -27,23 +28,19 @@ interface CellData {
period: number;
}
// Pre-computed transform constants for skewX(-48deg) skewY(14deg) scale(0.675)
const SKEW_X_TAN = Math.tan((-48 * Math.PI) / 180);
const SKEW_Y_TAN = Math.tan((14 * Math.PI) / 180);
const GRID_SCALE = 0.675;
/**
* Animated boxes background rendered on a single <canvas> element.
*
* Previous implementation used 225 DOM <div> elements with CSS @keyframes.
* Chrome's compositor created a separate paint region per div; any hover
* state change on page content forced Chrome to re-composite all 225 regions,
* causing visible flickering. A single canvas bypasses the DOM style/layout/paint
* pipeline entirely — hover interactions on other elements cannot trigger
* repaints of canvas content.
*/
interface BoxesState {
ctx: CanvasRenderingContext2D;
w: number;
h: number;
}
export default React.memo(function BackgroundBoxes({ settings }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const stateRef = useRef<BoxesState | null>(null);
const rows = clampNumber(settings.rows, 4, 30, 15);
const cols = clampNumber(settings.cols, 4, 30, 15);
const boxColor = sanitizeColor(settings.boxColor, '#818cf8');
@@ -61,44 +58,51 @@ export default React.memo(function BackgroundBoxes({ settings }: Props) {
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const dpr = getMobileDpr();
const parent = canvas.parentElement;
if (!parent) return;
const rect = parent.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
const ctx = canvas.getContext('2d');
if (!ctx) return;
let animId = 0;
stateRef.current = { ctx, w: canvas.width, h: canvas.height };
const resize = () => {
const dpr = devicePixelRatio || 1;
const parent = canvas.parentElement;
if (!parent) return;
const rect = parent.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
const onResize = () => {
const r = parent.getBoundingClientRect();
canvas.width = r.width * dpr;
canvas.height = r.height * dpr;
if (stateRef.current) {
stateRef.current.w = canvas.width;
stateRef.current.h = canvas.height;
}
};
resize();
window.addEventListener('resize', resize);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
const draw = (now: number) => {
useAnimationLoop(
(now) => {
const state = stateRef.current;
if (!state) return;
const { ctx, w, h } = state;
const t = now * 0.001;
const w = canvas.width;
const h = canvas.height;
ctx.clearRect(0, 0, w, h);
ctx.save();
// Transform origin: center of viewport
const cx = w / 2;
const cy = h / 2;
ctx.translate(cx, cy);
// Replicate CSS: skewX(-48deg) skewY(14deg) scale(0.675)
ctx.transform(1, 0, SKEW_X_TAN, 1, 0, 0);
ctx.transform(1, SKEW_Y_TAN, 0, 1, 0, 0);
ctx.scale(GRID_SCALE, GRID_SCALE);
ctx.translate(-cx, -cy);
// Grid: 300% of viewport, offset by -100% (matches the original CSS layout)
const gw = w * 3;
const gh = h * 3;
const ox = -w;
@@ -106,7 +110,6 @@ export default React.memo(function BackgroundBoxes({ settings }: Props) {
const cellW = gw / cols;
const cellH = gh / rows;
// Draw colored cells
for (let i = 0; i < cells.length; i++) {
const cell = cells[i];
const cycleT = ((t + cell.phase) % cell.period) / cell.period;
@@ -121,7 +124,6 @@ export default React.memo(function BackgroundBoxes({ settings }: Props) {
ctx.fillRect(ox + col * cellW, oy + row * cellH, cellW, cellH);
}
// Draw grid lines as a single batch (much cheaper than 225 individual strokeRects)
ctx.strokeStyle = 'rgba(51,65,85,0.5)';
ctx.lineWidth = 1;
ctx.beginPath();
@@ -139,17 +141,9 @@ export default React.memo(function BackgroundBoxes({ settings }: Props) {
ctx.stroke();
ctx.restore();
animId = requestAnimationFrame(draw);
};
animId = requestAnimationFrame(draw);
return () => {
cancelAnimationFrame(animId);
window.removeEventListener('resize', resize);
};
}, [cells, rows, cols]);
},
[cells, rows, cols],
);
return (
<canvas

View File

@@ -1,11 +1,14 @@
import { useEffect, useRef } from 'react';
import { cn } from '@/lib/utils';
import { sanitizeColor } from './types';
import { sanitizeColor, safeBoolean, safeSelect } from './types';
import { useAnimationPause } from '@/hooks/useAnimationLoop';
interface Props {
settings: Record<string, unknown>;
}
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
function hexToRgbString(hex: string): string {
hex = hex.replace('#', '');
if (hex.length === 3)
@@ -22,23 +25,29 @@ function hexToRgbString(hex: string): string {
export default function BackgroundGradientAnimation({ settings }: Props) {
const interactiveRef = useRef<HTMLDivElement>(null);
const paused = useAnimationPause();
const firstColor = hexToRgbString(sanitizeColor(settings.firstColor, '#1271FF'));
const secondColor = hexToRgbString(sanitizeColor(settings.secondColor, '#DD4AFF'));
const thirdColor = hexToRgbString(sanitizeColor(settings.thirdColor, '#64DCFF'));
const fourthColor = hexToRgbString(sanitizeColor(settings.fourthColor, '#C83232'));
const fifthColor = hexToRgbString(sanitizeColor(settings.fifthColor, '#B4B432'));
const interactive = (settings.interactive as boolean) ?? true;
const size = (settings.size as string) ?? '80%';
const interactive = safeBoolean(settings.interactive, true);
const size = safeSelect(settings.size, ['60%', '80%', '100%'] as const, '80%');
useEffect(() => {
if (!interactive || !interactiveRef.current) return;
// Interactive mouse tracking — DESKTOP ONLY
if (!interactive || !interactiveRef.current || isMobile) return;
let curX = 0;
let curY = 0;
let tgX = 0;
let tgY = 0;
let animId = 0;
let docHidden = document.hidden;
let tgInactive = false;
const isHidden = () => docHidden || tgInactive;
const move = () => {
curX += (tgX - curX) / 20;
@@ -49,20 +58,62 @@ export default function BackgroundGradientAnimation({ settings }: Props) {
animId = requestAnimationFrame(move);
};
const start = () => {
if (animId) return;
animId = requestAnimationFrame(move);
};
const stop = () => {
if (animId) {
cancelAnimationFrame(animId);
animId = 0;
}
};
const handleMouse = (e: MouseEvent) => {
tgX = e.clientX;
tgY = e.clientY;
};
const onVisibility = () => {
docHidden = document.hidden;
if (isHidden()) stop();
else start();
};
const tg = window.Telegram?.WebApp;
const onActivated = () => {
tgInactive = false;
if (!isHidden()) start();
};
const onDeactivated = () => {
tgInactive = true;
stop();
};
window.addEventListener('mousemove', handleMouse);
animId = requestAnimationFrame(move);
document.addEventListener('visibilitychange', onVisibility);
if (tg) {
tg.onEvent?.('activated', onActivated);
tg.onEvent?.('deactivated', onDeactivated);
}
if (!isHidden()) start();
return () => {
stop();
window.removeEventListener('mousemove', handleMouse);
cancelAnimationFrame(animId);
document.removeEventListener('visibilitychange', onVisibility);
if (tg) {
tg.offEvent?.('activated', onActivated);
tg.offEvent?.('deactivated', onDeactivated);
}
};
}, [interactive]);
// Mobile: use simpler blur (just CSS blur, no SVG filter)
// Desktop: keep the goo SVG filter + CSS blur for full effect
const filterStyle = isMobile ? 'blur(20px)' : 'url(#blurMe) blur(40px)';
return (
<div
className="absolute inset-0 overflow-hidden"
@@ -74,26 +125,28 @@ export default function BackgroundGradientAnimation({ settings }: Props) {
'--fourth-color': fourthColor,
'--fifth-color': fifthColor,
'--size': size,
'--blending': 'hard-light',
background: `linear-gradient(40deg, rgb(${firstColor}), rgb(${fifthColor}))`,
} as React.CSSProperties
}
>
<svg className="hidden">
<defs>
<filter id="blurMe">
<feGaussianBlur in="SourceGraphic" stdDeviation="10" result="blur" />
<feColorMatrix
in="blur"
mode="matrix"
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 18 -8"
result="goo"
/>
<feBlend in="SourceGraphic" in2="goo" />
</filter>
</defs>
</svg>
<div className="absolute inset-0" style={{ filter: 'url(#blurMe) blur(40px)' }}>
{/* SVG filter only rendered on desktop */}
{!isMobile && (
<svg className="hidden">
<defs>
<filter id="blurMe">
<feGaussianBlur in="SourceGraphic" stdDeviation="10" result="blur" />
<feColorMatrix
in="blur"
mode="matrix"
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 18 -8"
result="goo"
/>
<feBlend in="SourceGraphic" in2="goo" />
</filter>
</defs>
</svg>
)}
<div className="absolute inset-0" style={{ filter: filterStyle }}>
{[
{ color: firstColor, anim: 'animate-move-vertical' },
{ color: secondColor, anim: 'animate-move-in-circle' },
@@ -104,15 +157,17 @@ export default function BackgroundGradientAnimation({ settings }: Props) {
<div
key={i}
className={cn(
'absolute left-[calc(50%-var(--size)/2)] top-[calc(50%-var(--size)/2)] h-[var(--size)] w-[var(--size)] rounded-full opacity-100 mix-blend-hard-light',
'absolute left-[calc(50%-var(--size)/2)] top-[calc(50%-var(--size)/2)] h-[var(--size)] w-[var(--size)] rounded-full',
isMobile ? 'opacity-50' : 'opacity-100 mix-blend-hard-light',
blob.anim,
)}
style={{
background: `radial-gradient(circle at center, rgba(${blob.color}, 0.8) 0, rgba(${blob.color}, 0) 50%) no-repeat`,
animationPlayState: paused ? 'paused' : 'running',
}}
/>
))}
{interactive && (
{interactive && !isMobile && (
<div
ref={interactiveRef}
className="absolute left-[calc(50%-var(--size)/2)] top-[calc(50%-var(--size)/2)] h-[var(--size)] w-[var(--size)] rounded-full opacity-70 mix-blend-hard-light"

View File

@@ -1,73 +1,105 @@
import { useEffect, useRef } from 'react';
import { createNoise3D } from 'simplex-noise';
import { sanitizeColor, clampNumber } from './types';
import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop';
interface Props {
settings: Record<string, unknown>;
}
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
interface LinesState {
ctx: CanvasRenderingContext2D;
noise3D: ReturnType<typeof createNoise3D>;
nt: number;
w: number;
h: number;
dpr: number;
}
export default function BackgroundLines({ settings }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const animationRef = useRef<number>(0);
const stateRef = useRef<LinesState | null>(null);
const lineCount = clampNumber(settings.lineCount, 5, 100, 40);
const lineColor = sanitizeColor(settings.lineColor, '#818cf8');
const speed = clampNumber(settings.speed, 0.0005, 0.01, 0.002);
const strokeWidth = clampNumber(settings.strokeWidth, 0.5, 5, 1);
const effectiveLineCount = isMobile ? Math.min(lineCount, 20) : lineCount;
const step = isMobile ? 8 : 4;
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const dpr = getMobileDpr();
const parent = canvas.parentElement;
const w = parent?.offsetWidth ?? window.innerWidth;
const h = parent?.offsetHeight ?? window.innerHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const noise3D = createNoise3D();
let nt = 0;
stateRef.current = { ctx, noise3D: createNoise3D(), nt: 0, w, h, dpr };
const resize = () => {
canvas.width = canvas.parentElement?.offsetWidth ?? window.innerWidth;
canvas.height = canvas.parentElement?.offsetHeight ?? window.innerHeight;
const onResize = () => {
const nw = parent?.offsetWidth ?? window.innerWidth;
const nh = parent?.offsetHeight ?? window.innerHeight;
canvas.width = nw * dpr;
canvas.height = nh * dpr;
canvas.style.width = `${nw}px`;
canvas.style.height = `${nh}px`;
if (stateRef.current) {
stateRef.current.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
stateRef.current.w = nw;
stateRef.current.h = nh;
}
};
const animate = () => {
nt += speed;
ctx.clearRect(0, 0, canvas.width, canvas.height);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
for (let i = 0; i < lineCount; i++) {
const yBase = (i / lineCount) * canvas.height;
const opacity = 0.1 + 0.15 * Math.sin((i / lineCount) * Math.PI);
useAnimationLoop(() => {
const state = stateRef.current;
if (!state) return;
ctx.beginPath();
ctx.strokeStyle = lineColor;
ctx.globalAlpha = opacity;
ctx.lineWidth = strokeWidth;
const { ctx, noise3D, w, h } = state;
state.nt += speed;
for (let x = 0; x < canvas.width; x += 4) {
const y = yBase + noise3D(x / 600, i * 0.3, nt) * 40;
if (x === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
ctx.clearRect(0, 0, w, h);
ctx.strokeStyle = lineColor;
ctx.lineWidth = strokeWidth;
for (let i = 0; i < effectiveLineCount; i++) {
const yBase = (i / effectiveLineCount) * h;
const opacity = 0.1 + 0.15 * Math.sin((i / effectiveLineCount) * Math.PI);
ctx.globalAlpha = opacity;
ctx.beginPath();
for (let x = 0; x < w; x += step) {
const y = yBase + noise3D(x / 600, i * 0.3, state.nt) * 40;
if (x === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
ctx.stroke();
}
ctx.globalAlpha = 1;
animationRef.current = requestAnimationFrame(animate);
};
ctx.stroke();
}
resize();
animationRef.current = requestAnimationFrame(animate);
window.addEventListener('resize', resize);
return () => {
cancelAnimationFrame(animationRef.current);
window.removeEventListener('resize', resize);
};
}, [lineCount, lineColor, speed, strokeWidth]);
ctx.globalAlpha = 1;
}, [effectiveLineCount, lineColor, speed, strokeWidth]);
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
}

View File

@@ -1,13 +1,22 @@
import { useEffect, useRef } from 'react';
import { sanitizeColor, clampNumber } from './types';
import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop';
interface Props {
settings: Record<string, unknown>;
}
interface RippleState {
ctx: CanvasRenderingContext2D;
w: number;
h: number;
dpr: number;
ripples: { phase: number }[];
}
export default function BackgroundRipple({ settings }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const animationRef = useRef<number>(0);
const stateRef = useRef<RippleState | null>(null);
const rippleColor = sanitizeColor(settings.rippleColor, '#818cf8');
const rippleCount = clampNumber(settings.rippleCount, 1, 20, 5);
@@ -17,24 +26,53 @@ export default function BackgroundRipple({ settings }: Props) {
const canvas = canvasRef.current;
if (!canvas) return;
const dpr = getMobileDpr();
const parent = canvas.parentElement;
const w = parent?.offsetWidth ?? window.innerWidth;
const h = parent?.offsetHeight ?? window.innerHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const resize = () => {
canvas.width = canvas.parentElement?.offsetWidth ?? window.innerWidth;
canvas.height = canvas.parentElement?.offsetHeight ?? window.innerHeight;
};
const ripples = Array.from({ length: rippleCount }, (_, i) => ({
phase: (i / rippleCount) * Math.PI * 2,
maxRadius: 0,
}));
const animate = (time: number) => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
stateRef.current = { ctx, w, h, dpr, ripples };
const cx = canvas.width / 2;
const cy = canvas.height / 2;
const onResize = () => {
const nw = parent?.offsetWidth ?? window.innerWidth;
const nh = parent?.offsetHeight ?? window.innerHeight;
canvas.width = nw * dpr;
canvas.height = nh * dpr;
canvas.style.width = `${nw}px`;
canvas.style.height = `${nh}px`;
if (stateRef.current) {
stateRef.current.w = nw;
stateRef.current.h = nh;
}
};
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, [rippleCount]);
useAnimationLoop(
(time) => {
const state = stateRef.current;
if (!state) return;
const { ctx, w, h, dpr, ripples } = state;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
const cx = w / 2;
const cy = h / 2;
const maxR = Math.sqrt(cx * cx + cy * cy);
for (let i = 0; i < ripples.length; i++) {
@@ -52,18 +90,9 @@ export default function BackgroundRipple({ settings }: Props) {
}
ctx.globalAlpha = 1;
animationRef.current = requestAnimationFrame(animate);
};
resize();
animationRef.current = requestAnimationFrame(animate);
window.addEventListener('resize', resize);
return () => {
cancelAnimationFrame(animationRef.current);
window.removeEventListener('resize', resize);
};
}, [rippleColor, rippleCount, speed]);
},
[rippleColor, rippleCount, speed],
);
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
}

View File

@@ -1,11 +1,11 @@
import { sanitizeColor, clampNumber } from './types';
import { sanitizeColor, clampNumber, safeSelect } from './types';
interface Props {
settings: Record<string, unknown>;
}
export default function GridBackground({ settings }: Props) {
const variant = (settings.variant as string) ?? 'grid';
const variant = safeSelect(settings.variant, ['grid', 'dots'] as const, 'grid');
const gridColor = sanitizeColor(settings.gridColor, 'rgba(255,255,255,0.05)');
const gridSize = clampNumber(settings.gridSize, 10, 200, 40);
const dotSize = clampNumber(settings.dotSize, 0.5, 10, 1.5);

View File

@@ -1,5 +1,6 @@
import { useMemo } from 'react';
import { sanitizeColor, clampNumber } from './types';
import { useAnimationPause } from '@/hooks/useAnimationLoop';
interface Props {
settings: Record<string, unknown>;
@@ -8,6 +9,7 @@ interface Props {
export default function Meteors({ settings }: Props) {
const count = clampNumber(settings.count, 1, 50, 20);
const meteorColor = sanitizeColor(settings.meteorColor, '#ffffff');
const paused = useAnimationPause();
const meteors = useMemo(
() =>
@@ -31,6 +33,7 @@ export default function Meteors({ settings }: Props) {
left: meteor.left,
animationDelay: meteor.delay,
animationDuration: meteor.duration,
animationPlayState: paused ? 'paused' : 'running',
width: meteor.size,
height: meteor.size,
boxShadow: `0 0 0 1px rgba(255,255,255,0.05), 0 0 2px 1px ${meteorColor}20, 0 0 20px 2px ${meteorColor}40`,

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef } from 'react';
import { sanitizeColor, clampNumber } from './types';
import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop';
interface Props {
settings: Record<string, unknown>;
@@ -23,9 +24,20 @@ interface BgStar {
twinkleSpeed: number | null;
}
interface ShootingState {
ctx: CanvasRenderingContext2D;
shootingStars: Star[];
bgStars: BgStar[];
lastShootingTime: number;
nextShootingDelay: number;
w: number;
h: number;
dpr: number;
}
export default function ShootingStarsBackground({ settings }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const animationRef = useRef<number>(0);
const stateRef = useRef<ShootingState | null>(null);
const starColor = sanitizeColor(settings.starColor, '#9E00FF');
const trailColor = sanitizeColor(settings.trailColor, '#2EB9DF');
@@ -37,46 +49,67 @@ export default function ShootingStarsBackground({ settings }: Props) {
const canvas = canvasRef.current;
if (!canvas) return;
const dpr = getMobileDpr();
const parent = canvas.parentElement;
const w = parent?.offsetWidth ?? window.innerWidth;
const h = parent?.offsetHeight ?? window.innerHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
const ctx = canvas.getContext('2d');
if (!ctx) return;
let shootingStars: Star[] = [];
let bgStars: BgStar[] = [];
let lastShootingTime = 0;
const count = Math.floor(w * h * starDensity);
const bgStars: BgStar[] = Array.from({ length: count }, () => ({
x: Math.random() * w,
y: Math.random() * h,
radius: Math.random() * 1.2 + 0.3,
opacity: Math.random(),
twinkleSpeed: Math.random() > 0.3 ? 0.5 + Math.random() * 0.5 : null,
}));
const resize = () => {
canvas.width = canvas.parentElement?.offsetWidth ?? window.innerWidth;
canvas.height = canvas.parentElement?.offsetHeight ?? window.innerHeight;
initBgStars();
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
stateRef.current = {
ctx,
shootingStars: [],
bgStars,
lastShootingTime: 0,
nextShootingDelay: 4200 + Math.random() * 4500,
w,
h,
dpr,
};
const initBgStars = () => {
const count = Math.floor(canvas.width * canvas.height * starDensity);
bgStars = Array.from({ length: count }, () => ({
x: Math.random() * canvas.width,
y: Math.random() * canvas.height,
radius: Math.random() * 1.2 + 0.3,
opacity: Math.random(),
twinkleSpeed: Math.random() > 0.3 ? 0.5 + Math.random() * 0.5 : null,
}));
const onResize = () => {
const nw = parent?.offsetWidth ?? window.innerWidth;
const nh = parent?.offsetHeight ?? window.innerHeight;
canvas.width = nw * dpr;
canvas.height = nh * dpr;
canvas.style.width = `${nw}px`;
canvas.style.height = `${nh}px`;
if (stateRef.current) {
stateRef.current.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
stateRef.current.w = nw;
stateRef.current.h = nh;
}
};
const spawnShootingStar = () => {
shootingStars.push({
x: Math.random() * canvas.width,
y: Math.random() * canvas.height * 0.5,
angle: Math.PI / 4 + (Math.random() - 0.5) * 0.3,
scale: 0.5 + Math.random() * 0.5,
speed: minSpeed + Math.random() * (maxSpeed - minSpeed),
distance: 0,
opacity: 1,
});
};
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, [starDensity]);
const animate = (time: number) => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
useAnimationLoop(
(time) => {
const state = stateRef.current;
if (!state) return;
const { ctx, bgStars, w, h } = state;
ctx.clearRect(0, 0, w, h);
// Background stars
for (const s of bgStars) {
let opacity = s.opacity;
if (s.twinkleSpeed) {
@@ -88,14 +121,21 @@ export default function ShootingStarsBackground({ settings }: Props) {
ctx.fill();
}
// Spawn shooting stars
if (time - lastShootingTime > 4200 + Math.random() * 4500) {
spawnShootingStar();
lastShootingTime = time;
if (time - state.lastShootingTime > state.nextShootingDelay) {
state.shootingStars.push({
x: Math.random() * w,
y: Math.random() * h * 0.5,
angle: Math.PI / 4 + (Math.random() - 0.5) * 0.3,
scale: 0.5 + Math.random() * 0.5,
speed: minSpeed + Math.random() * (maxSpeed - minSpeed),
distance: 0,
opacity: 1,
});
state.lastShootingTime = time;
state.nextShootingDelay = 4200 + Math.random() * 4500;
}
// Draw shooting stars
shootingStars = shootingStars.filter((star) => {
state.shootingStars = state.shootingStars.filter((star) => {
star.distance += star.speed;
star.opacity = Math.max(0, 1 - star.distance / 500);
@@ -106,36 +146,28 @@ export default function ShootingStarsBackground({ settings }: Props) {
const tailX = star.x + Math.cos(star.angle) * Math.max(0, star.distance - 80);
const tailY = star.y + Math.sin(star.angle) * Math.max(0, star.distance - 80);
const gradient = ctx.createLinearGradient(tailX, tailY, x2, y2);
gradient.addColorStop(0, 'transparent');
gradient.addColorStop(0.5, trailColor);
gradient.addColorStop(1, starColor);
ctx.save();
ctx.strokeStyle = gradient;
// Draw trail with flat color (avoids per-frame gradient allocation)
ctx.lineWidth = star.scale * 2;
ctx.globalAlpha = star.opacity;
ctx.globalAlpha = star.opacity * 0.4;
ctx.strokeStyle = trailColor;
ctx.beginPath();
ctx.moveTo(tailX, tailY);
ctx.lineTo(x2, y2);
ctx.stroke();
ctx.restore();
// Draw head dot
ctx.globalAlpha = star.opacity;
ctx.fillStyle = starColor;
ctx.beginPath();
ctx.arc(x2, y2, star.scale * 1.5, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
return true;
});
animationRef.current = requestAnimationFrame(animate);
};
resize();
animationRef.current = requestAnimationFrame(animate);
window.addEventListener('resize', resize);
return () => {
cancelAnimationFrame(animationRef.current);
window.removeEventListener('resize', resize);
};
}, [starColor, trailColor, starDensity, minSpeed, maxSpeed]);
},
[starColor, trailColor, starDensity, minSpeed, maxSpeed],
);
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
}

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef, useCallback } from 'react';
import { useEffect, useRef } from 'react';
import { sanitizeColor, clampNumber } from './types';
import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop';
interface Props {
settings: Record<string, unknown>;
@@ -16,10 +17,32 @@ interface Particle {
opacitySpeed: number;
}
interface SparklesState {
ctx: CanvasRenderingContext2D;
particles: Particle[];
rgb: { r: number; g: number; b: number };
w: number;
h: number;
dpr: number;
}
function hexToRgb(hex: string) {
hex = hex.replace('#', '');
if (hex.length === 3)
hex = hex
.split('')
.map((c) => c + c)
.join('');
return {
r: parseInt(hex.substring(0, 2), 16),
g: parseInt(hex.substring(2, 4), 16),
b: parseInt(hex.substring(4, 6), 16),
};
}
export default function Sparkles({ settings }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const animationRef = useRef<number>(0);
const particlesRef = useRef<Particle[]>([]);
const stateRef = useRef<SparklesState | null>(null);
const particleDensity = clampNumber(settings.particleDensity, 50, 5000, 800);
const minSize = clampNumber(settings.minSize, 0.1, 5, 0.4);
@@ -27,89 +50,88 @@ export default function Sparkles({ settings }: Props) {
const speed = clampNumber(settings.speed, 0.1, 10, 2);
const particleColor = sanitizeColor(settings.particleColor, '#FFFFFF');
const hexToRgb = useCallback((hex: string) => {
hex = hex.replace('#', '');
if (hex.length === 3)
hex = hex
.split('')
.map((c) => c + c)
.join('');
return {
r: parseInt(hex.substring(0, 2), 16),
g: parseInt(hex.substring(2, 4), 16),
b: parseInt(hex.substring(4, 6), 16),
};
}, []);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const dpr = getMobileDpr();
const parent = canvas.parentElement;
const w = parent?.offsetWidth ?? window.innerWidth;
const h = parent?.offsetHeight ?? window.innerHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const resize = () => {
canvas.width = canvas.parentElement?.offsetWidth ?? window.innerWidth;
canvas.height = canvas.parentElement?.offsetHeight ?? window.innerHeight;
initParticles();
const area = w * h;
const count = Math.floor((area / 1000000) * particleDensity);
const particles: Particle[] = Array.from({ length: count }, () => ({
x: Math.random() * w,
y: Math.random() * h,
size: minSize + Math.random() * (maxSize - minSize),
speedX: (Math.random() - 0.5) * speed * 0.2,
speedY: (Math.random() - 0.5) * speed * 0.2,
opacity: Math.random(),
opacityDirection: Math.random() > 0.5 ? 1 : -1,
opacitySpeed: 0.005 + Math.random() * 0.01 * speed,
}));
stateRef.current = { ctx, particles, rgb: hexToRgb(particleColor), w, h, dpr };
const onResize = () => {
const nw = parent?.offsetWidth ?? window.innerWidth;
const nh = parent?.offsetHeight ?? window.innerHeight;
canvas.width = nw * dpr;
canvas.height = nh * dpr;
canvas.style.width = `${nw}px`;
canvas.style.height = `${nh}px`;
if (stateRef.current) {
stateRef.current.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
stateRef.current.w = nw;
stateRef.current.h = nh;
}
};
const initParticles = () => {
const area = canvas.width * canvas.height;
const count = Math.floor((area / 1000000) * particleDensity);
particlesRef.current = Array.from({ length: count }, () => ({
x: Math.random() * canvas.width,
y: Math.random() * canvas.height,
size: minSize + Math.random() * (maxSize - minSize),
speedX: (Math.random() - 0.5) * speed * 0.2,
speedY: (Math.random() - 0.5) * speed * 0.2,
opacity: Math.random(),
opacityDirection: Math.random() > 0.5 ? 1 : -1,
opacitySpeed: 0.005 + Math.random() * 0.01 * speed,
}));
};
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, [particleDensity, minSize, maxSize, speed, particleColor]);
const rgb = hexToRgb(particleColor);
useAnimationLoop(() => {
const state = stateRef.current;
if (!state) return;
const animate = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
const { ctx, particles, rgb, w, h } = state;
for (const p of particlesRef.current) {
p.x += p.speedX;
p.y += p.speedY;
p.opacity += p.opacityDirection * p.opacitySpeed;
ctx.clearRect(0, 0, w, h);
if (p.opacity <= 0) {
p.opacity = 0;
p.opacityDirection = 1;
} else if (p.opacity >= 1) {
p.opacity = 1;
p.opacityDirection = -1;
}
for (const p of particles) {
p.x += p.speedX;
p.y += p.speedY;
p.opacity += p.opacityDirection * p.opacitySpeed;
if (p.x < 0) p.x = canvas.width;
if (p.x > canvas.width) p.x = 0;
if (p.y < 0) p.y = canvas.height;
if (p.y > canvas.height) p.y = 0;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fillStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},${p.opacity})`;
ctx.fill();
if (p.opacity <= 0) {
p.opacity = 0;
p.opacityDirection = 1;
} else if (p.opacity >= 1) {
p.opacity = 1;
p.opacityDirection = -1;
}
animationRef.current = requestAnimationFrame(animate);
};
if (p.x < 0) p.x = w;
if (p.x > w) p.x = 0;
if (p.y < 0) p.y = h;
if (p.y > h) p.y = 0;
resize();
animationRef.current = requestAnimationFrame(animate);
window.addEventListener('resize', resize);
return () => {
cancelAnimationFrame(animationRef.current);
window.removeEventListener('resize', resize);
};
}, [particleDensity, minSize, maxSize, speed, particleColor, hexToRgb]);
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fillStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},${p.opacity})`;
ctx.fill();
}
}, [particleDensity, minSize, maxSize, speed, particleColor]);
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
}

View File

@@ -1,13 +1,24 @@
import { motion } from 'framer-motion';
import { sanitizeColor, clampNumber } from './types';
import { useAnimationPause } from '@/hooks/useAnimationLoop';
interface Props {
settings: Record<string, unknown>;
}
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
export default function SpotlightBg({ settings }: Props) {
const spotlightColor = sanitizeColor(settings.spotlightColor, '#818cf8');
const spotlightSize = clampNumber(settings.spotlightSize, 100, 1000, 400);
const paused = useAnimationPause();
const blur1 = isMobile ? 0 : 60;
const blur2 = isMobile ? 0 : 80;
if (paused) {
return <div className="absolute inset-0 overflow-hidden" />;
}
return (
<div className="absolute inset-0 overflow-hidden">
@@ -27,7 +38,7 @@ export default function SpotlightBg({ settings }: Props) {
height: spotlightSize,
background: `radial-gradient(circle, ${spotlightColor}40 0%, transparent 70%)`,
borderRadius: '50%',
filter: 'blur(60px)',
filter: `blur(${blur1}px)`,
}}
/>
<motion.div
@@ -46,7 +57,7 @@ export default function SpotlightBg({ settings }: Props) {
height: spotlightSize * 0.8,
background: `radial-gradient(circle, ${spotlightColor}30 0%, transparent 70%)`,
borderRadius: '50%',
filter: 'blur(80px)',
filter: `blur(${blur2}px)`,
}}
/>
</div>

View File

@@ -42,17 +42,30 @@ export function sanitizeColor(value: unknown, fallback: string): string {
if (/^#[0-9a-fA-F]{3,8}$/.test(trimmed)) return trimmed;
// Allow rgb/rgba/hsl/hsla with numbers, commas, spaces, dots, %
if (/^(rgb|hsl)a?\([0-9,.\s/%]+\)$/.test(trimmed)) return trimmed;
// Allow CSS named colors (alphanumeric only)
if (/^[a-zA-Z]{3,30}$/.test(trimmed)) return trimmed;
// Allow CSS named colors (alphanumeric only, exclude CSS-wide keywords)
if (/^[a-zA-Z]{3,30}$/.test(trimmed) && !CSS_WIDE_KEYWORDS.has(trimmed.toLowerCase()))
return trimmed;
return fallback;
}
/** Clamp a numeric value within bounds */
export function clampNumber(value: unknown, min: number, max: number, fallback: number): number {
const n = typeof value === 'number' ? value : fallback;
const n = typeof value === 'number' && Number.isFinite(value) ? value : fallback;
return Math.max(min, Math.min(max, n));
}
/** Safely extract a boolean setting with fallback */
export function safeBoolean(value: unknown, fallback: boolean): boolean {
return typeof value === 'boolean' ? value : fallback;
}
/** Safely extract a string setting from an allowed set of values */
export function safeSelect(value: unknown, allowed: readonly string[], fallback: string): string {
return typeof value === 'string' && allowed.includes(value) ? value : fallback;
}
const CSS_WIDE_KEYWORDS = new Set(['inherit', 'initial', 'unset', 'revert', 'revert-layer']);
export interface SettingDefinition {
key: string;
label: string;

View File

@@ -1,14 +1,37 @@
import { useEffect, useRef } from 'react';
import { createNoise3D } from 'simplex-noise';
import { sanitizeColor, clampNumber } from './types';
import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop';
interface Props {
settings: Record<string, unknown>;
}
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
const TAU = 2 * Math.PI;
const lerp = (n1: number, n2: number, speed: number) => (1 - speed) * n1 + speed * n2;
const fadeInOut = (t: number, m: number) => {
const hm = 0.5 * m;
return Math.abs(((t + hm) % m) - hm) / hm;
};
const rand = (n: number) => n * Math.random();
const randRange = (n: number) => n - rand(2 * n);
interface VortexState {
ctx: CanvasRenderingContext2D;
noise3D: ReturnType<typeof createNoise3D>;
particleProps: Float32Array;
tick: number;
center: [number, number];
w: number;
h: number;
dpr: number;
}
export default function VortexBackground({ settings }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const animationRef = useRef<number>(0);
const stateRef = useRef<VortexState | null>(null);
const particleCount = clampNumber(settings.particleCount, 50, 2000, 500);
const rangeY = clampNumber(settings.rangeY, 10, 500, 100);
@@ -16,128 +39,127 @@ export default function VortexBackground({ settings }: Props) {
const rangeSpeed = clampNumber(settings.rangeSpeed, 0.1, 5, 1.5);
const backgroundColor = sanitizeColor(settings.backgroundColor, '#000000');
const particlePropCount = 9;
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const dpr = getMobileDpr();
const parent = canvas.parentElement;
const w = parent?.offsetWidth ?? window.innerWidth;
const h = parent?.offsetHeight ?? window.innerHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.lineCap = 'round';
const noise3D = createNoise3D();
const particlePropCount = 9;
const particlePropsLength = particleCount * particlePropCount;
let particleProps = new Float32Array(particlePropsLength);
let tick = 0;
let center: [number, number] = [0, 0];
const particleProps = new Float32Array(particlePropsLength);
const center: [number, number] = [0.5 * w, 0.5 * h];
const rand = (n: number) => n * Math.random();
const randRange = (n: number) => n - rand(2 * n);
const TAU = 2 * Math.PI;
const fadeInOut = (t: number, m: number) => {
const hm = 0.5 * m;
return Math.abs(((t + hm) % m) - hm) / hm;
};
const lerp = (n1: number, n2: number, speed: number) => (1 - speed) * n1 + speed * n2;
for (let i = 0; i < particlePropsLength; i += particlePropCount) {
particleProps[i] = rand(w);
particleProps[i + 1] = center[1] + randRange(rangeY);
particleProps[i + 2] = 0;
particleProps[i + 3] = 0;
particleProps[i + 4] = 0;
particleProps[i + 5] = 50 + rand(150);
particleProps[i + 6] = rand(rangeSpeed);
particleProps[i + 7] = 1 + rand(2);
particleProps[i + 8] = baseHue + rand(100);
}
const resize = () => {
canvas.width = canvas.parentElement?.offsetWidth ?? window.innerWidth;
canvas.height = canvas.parentElement?.offsetHeight ?? window.innerHeight;
center = [0.5 * canvas.width, 0.5 * canvas.height];
};
stateRef.current = { ctx, noise3D, particleProps, tick: 0, center, w, h, dpr };
const initParticle = (i: number) => {
const x = rand(canvas.width);
const y = center[1] + randRange(rangeY);
const life = 0;
const ttl = 50 + rand(150);
const speed = rand(rangeSpeed);
const radius = 1 + rand(2);
const hue = baseHue + rand(100);
particleProps.set([x, y, 0, 0, life, ttl, speed, radius, hue], i);
};
const initParticles = () => {
tick = 0;
particleProps = new Float32Array(particlePropsLength);
for (let i = 0; i < particlePropsLength; i += particlePropCount) {
initParticle(i);
const onResize = () => {
const nw = parent?.offsetWidth ?? window.innerWidth;
const nh = parent?.offsetHeight ?? window.innerHeight;
canvas.width = nw * dpr;
canvas.height = nh * dpr;
canvas.style.width = `${nw}px`;
canvas.style.height = `${nh}px`;
if (stateRef.current) {
stateRef.current.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
stateRef.current.ctx.lineCap = 'round';
stateRef.current.center = [0.5 * nw, 0.5 * nh];
stateRef.current.w = nw;
stateRef.current.h = nh;
}
};
const drawParticle = (
x: number,
y: number,
x2: number,
y2: number,
life: number,
ttl: number,
radius: number,
hue: number,
) => {
ctx.save();
ctx.lineCap = 'round';
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, [particleCount, rangeY, baseHue, rangeSpeed]);
useAnimationLoop(() => {
const state = stateRef.current;
if (!state) return;
const { ctx, noise3D, particleProps, w, h } = state;
const particlePropsLength = particleCount * particlePropCount;
state.tick++;
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = backgroundColor;
ctx.fillRect(0, 0, w, h);
for (let i = 0; i < particlePropsLength; i += particlePropCount) {
const x = particleProps[i];
const y = particleProps[i + 1];
const n = noise3D(x * 0.00125, y * 0.00125, state.tick * 0.0005) * 3 * TAU;
const vx = lerp(particleProps[i + 2], Math.cos(n), 0.5);
const vy = lerp(particleProps[i + 3], Math.sin(n), 0.5);
const life = particleProps[i + 4];
const ttl = particleProps[i + 5];
const speed = particleProps[i + 6];
const x2 = x + vx * speed;
const y2 = y + vy * speed;
const radius = particleProps[i + 7];
const hue = particleProps[i + 8];
ctx.lineWidth = radius;
ctx.strokeStyle = `hsla(${hue},100%,60%,${fadeInOut(life, ttl)})`;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x2, y2);
ctx.stroke();
ctx.closePath();
ctx.restore();
};
const draw = () => {
tick++;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = backgroundColor;
ctx.fillRect(0, 0, canvas.width, canvas.height);
particleProps[i] = x2;
particleProps[i + 1] = y2;
particleProps[i + 2] = vx;
particleProps[i + 3] = vy;
particleProps[i + 4] = life + 1;
for (let i = 0; i < particlePropsLength; i += particlePropCount) {
const x = particleProps[i];
const y = particleProps[i + 1];
const n = noise3D(x * 0.00125, y * 0.00125, tick * 0.0005) * 3 * TAU;
const vx = lerp(particleProps[i + 2], Math.cos(n), 0.5);
const vy = lerp(particleProps[i + 3], Math.sin(n), 0.5);
const life = particleProps[i + 4];
const ttl = particleProps[i + 5];
const speed = particleProps[i + 6];
const x2 = x + vx * speed;
const y2 = y + vy * speed;
const radius = particleProps[i + 7];
const hue = particleProps[i + 8];
drawParticle(x, y, x2, y2, life, ttl, radius, hue);
particleProps[i] = x2;
particleProps[i + 1] = y2;
particleProps[i + 2] = vx;
particleProps[i + 3] = vy;
particleProps[i + 4] = life + 1;
if (x2 > canvas.width || x2 < 0 || y2 > canvas.height || y2 < 0 || life + 1 > ttl) {
initParticle(i);
}
if (x2 > w || x2 < 0 || y2 > h || y2 < 0 || life + 1 > ttl) {
particleProps[i] = rand(w);
particleProps[i + 1] = state.center[1] + randRange(rangeY);
particleProps[i + 2] = 0;
particleProps[i + 3] = 0;
particleProps[i + 4] = 0;
particleProps[i + 5] = 50 + rand(150);
particleProps[i + 6] = rand(rangeSpeed);
particleProps[i + 7] = 1 + rand(2);
particleProps[i + 8] = baseHue + rand(100);
}
}
// Glow effect
// Glow effect — DESKTOP ONLY
if (!isMobile) {
ctx.save();
ctx.filter = 'blur(8px) brightness(200%)';
ctx.globalCompositeOperation = 'lighter';
ctx.drawImage(canvas, 0, 0);
const canvas = canvasRef.current;
if (canvas) ctx.drawImage(canvas, 0, 0, w, h);
ctx.restore();
animationRef.current = requestAnimationFrame(draw);
};
resize();
initParticles();
animationRef.current = requestAnimationFrame(draw);
window.addEventListener('resize', resize);
return () => {
cancelAnimationFrame(animationRef.current);
window.removeEventListener('resize', resize);
};
}
}, [particleCount, rangeY, baseHue, rangeSpeed, backgroundColor]);
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;

View File

@@ -1,16 +1,28 @@
import { useEffect, useRef } from 'react';
import { createNoise3D } from 'simplex-noise';
import { sanitizeColor, clampNumber } from './types';
import { sanitizeColor, clampNumber, safeSelect } from './types';
import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop';
interface Props {
settings: Record<string, unknown>;
}
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
interface WavyState {
ctx: CanvasRenderingContext2D;
noise3D: ReturnType<typeof createNoise3D>;
nt: number;
w: number;
h: number;
dpr: number;
}
export default function WavyBackground({ settings }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const animationRef = useRef<number>(0);
const stateRef = useRef<WavyState | null>(null);
const speed = (settings.speed as string) ?? 'fast';
const speed = safeSelect(settings.speed, ['slow', 'fast'] as const, 'fast');
const waveWidth = clampNumber(settings.waveWidth, 5, 200, 50);
const blur = clampNumber(settings.blur, 0, 50, 10);
const waveOpacity = clampNumber(settings.waveOpacity, 0.05, 1, 0.5);
@@ -21,55 +33,73 @@ export default function WavyBackground({ settings }: Props) {
const canvas = canvasRef.current;
if (!canvas) return;
const dpr = getMobileDpr();
const parent = canvas.parentElement;
const w = parent?.offsetWidth ?? window.innerWidth;
const h = parent?.offsetHeight ?? window.innerHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const noise3D = createNoise3D();
let nt = 0;
const speedVal = speed === 'slow' ? 0.001 : 0.002;
const waveCount = 5;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const effectiveBlur = isMobile ? Math.min(blur, 4) : blur;
ctx.filter = effectiveBlur > 0 ? `blur(${effectiveBlur}px)` : 'none';
const resize = () => {
canvas.width = canvas.parentElement?.offsetWidth ?? window.innerWidth;
canvas.height = canvas.parentElement?.offsetHeight ?? window.innerHeight;
ctx.filter = `blur(${blur}px)`;
};
stateRef.current = { ctx, noise3D: createNoise3D(), nt: 0, w, h, dpr };
const drawWave = (_n: number) => {
nt += speedVal;
for (let i = 0; i < waveCount; i++) {
ctx.beginPath();
ctx.lineWidth = waveWidth;
ctx.strokeStyle = colors[i % colors.length];
ctx.globalAlpha = waveOpacity;
for (let x = 0; x < canvas.width; x += 5) {
const y = noise3D(x / 800, 0.3 * i, nt) * 100;
ctx.lineTo(x, y + canvas.height * 0.5);
}
ctx.stroke();
ctx.closePath();
const onResize = () => {
const nw = parent?.offsetWidth ?? window.innerWidth;
const nh = parent?.offsetHeight ?? window.innerHeight;
canvas.width = nw * dpr;
canvas.height = nh * dpr;
canvas.style.width = `${nw}px`;
canvas.style.height = `${nh}px`;
if (stateRef.current) {
stateRef.current.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const eb = isMobile ? Math.min(blur, 4) : blur;
stateRef.current.ctx.filter = eb > 0 ? `blur(${eb}px)` : 'none';
stateRef.current.w = nw;
stateRef.current.h = nh;
}
};
const animate = () => {
ctx.globalAlpha = 1;
ctx.fillStyle = backgroundFill;
ctx.fillRect(0, 0, canvas.width, canvas.height);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, [blur]);
drawWave(5);
animationRef.current = requestAnimationFrame(animate);
};
const speedVal = speed === 'slow' ? 0.001 : 0.002;
const waveCount = isMobile ? 3 : 5;
const step = isMobile ? 8 : 5;
resize();
animationRef.current = requestAnimationFrame(animate);
window.addEventListener('resize', resize);
useAnimationLoop(() => {
const state = stateRef.current;
if (!state) return;
return () => {
cancelAnimationFrame(animationRef.current);
window.removeEventListener('resize', resize);
};
const { ctx, noise3D, w, h } = state;
state.nt += speedVal;
ctx.globalAlpha = 1;
ctx.fillStyle = backgroundFill;
ctx.fillRect(0, 0, w, h);
for (let i = 0; i < waveCount; i++) {
ctx.beginPath();
ctx.lineWidth = waveWidth;
ctx.strokeStyle = colors[i % colors.length];
ctx.globalAlpha = waveOpacity;
for (let x = 0; x < w; x += step) {
const y = noise3D(x / 800, 0.3 * i, state.nt) * 100;
ctx.lineTo(x, y + h * 0.5);
}
ctx.stroke();
ctx.closePath();
}
}, [speed, waveWidth, blur, waveOpacity, backgroundFill]);
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;

View File

@@ -0,0 +1,140 @@
import { useEffect, useRef, useState } from 'react';
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
const TARGET_FPS = isMobile ? 30 : 60;
const FRAME_INTERVAL = 1000 / TARGET_FPS;
/**
* Shared animation loop hook with built-in performance safeguards:
* - FPS throttling: 30 FPS on mobile, 60 FPS on desktop
* - Page Visibility API: fully stops RAF when tab/app is hidden
* - Telegram Mini App: fully stops RAF when isActive = false
* - Auto-cleanup on unmount
*/
export function useAnimationLoop(
callback: (time: number, delta: number) => void,
deps: React.DependencyList,
) {
const callbackRef = useRef(callback);
callbackRef.current = callback;
useEffect(() => {
let animId = 0;
let lastTime = 0;
let docHidden = document.hidden;
let tgInactive = false;
const isPaused = () => docHidden || tgInactive;
const loop = (time: number) => {
const delta = time - lastTime;
if (delta >= FRAME_INTERVAL) {
lastTime = time - (delta % FRAME_INTERVAL);
callbackRef.current(time, delta);
}
animId = requestAnimationFrame(loop);
};
const start = () => {
if (animId) return; // already running
lastTime = performance.now();
animId = requestAnimationFrame(loop);
};
const stop = () => {
if (animId) {
cancelAnimationFrame(animId);
animId = 0;
}
};
const onVisibilityChange = () => {
docHidden = document.hidden;
if (isPaused()) stop();
else start();
};
const tg = window.Telegram?.WebApp;
const onTgActivated = () => {
tgInactive = false;
if (!isPaused()) start();
};
const onTgDeactivated = () => {
tgInactive = true;
stop();
};
document.addEventListener('visibilitychange', onVisibilityChange);
if (tg) {
tg.onEvent?.('activated', onTgActivated);
tg.onEvent?.('deactivated', onTgDeactivated);
}
// Start the loop (only if page is currently visible)
if (!isPaused()) start();
return () => {
stop();
document.removeEventListener('visibilitychange', onVisibilityChange);
if (tg) {
tg.offEvent?.('activated', onTgActivated);
tg.offEvent?.('deactivated', onTgDeactivated);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
}
/**
* Returns whether animations should be paused (tab hidden or Telegram deactivated).
* Use for CSS/Framer Motion components that can't use useAnimationLoop.
*/
export function useAnimationPause(): boolean {
const [paused, setPaused] = useState(() =>
typeof document !== 'undefined' ? document.hidden : false,
);
useEffect(() => {
let docHidden = document.hidden;
let tgInactive = false;
const update = () => setPaused(docHidden || tgInactive);
const onVisibility = () => {
docHidden = document.hidden;
update();
};
const tg = window.Telegram?.WebApp;
const onActivated = () => {
tgInactive = false;
update();
};
const onDeactivated = () => {
tgInactive = true;
update();
};
document.addEventListener('visibilitychange', onVisibility);
if (tg) {
tg.onEvent?.('activated', onActivated);
tg.onEvent?.('deactivated', onDeactivated);
}
return () => {
document.removeEventListener('visibilitychange', onVisibility);
if (tg) {
tg.offEvent?.('activated', onActivated);
tg.offEvent?.('deactivated', onDeactivated);
}
};
}, []);
return paused;
}
/** Returns a reduced DPR for mobile canvas rendering (saves 4x GPU work on retina) */
export function getMobileDpr(): number {
if (isMobile) return Math.min(devicePixelRatio, 1.5);
return devicePixelRatio || 1;
}

12
src/vite-env.d.ts vendored
View File

@@ -12,3 +12,15 @@ interface ImportMetaEnv {
interface ImportMeta {
readonly env: ImportMetaEnv;
}
/** Telegram WebApp global — available when running inside Telegram Mini App */
interface TelegramWebAppGlobal {
onEvent?: (event: string, callback: () => void) => void;
offEvent?: (event: string, callback: () => void) => void;
}
interface Window {
Telegram?: {
WebApp?: TelegramWebAppGlobal;
};
}