Aether Ribbon Mesh
Aether Ribbon Mesh é um componente de fundo para React e Tailwind CSS, escrito para a NedDev e teu para usares. Copia e cola no teu projeto.
NedDev
backgrounds
O que é
Aether Ribbon Mesh é um componente de fundo para React e Tailwind CSS, escrito para a NedDev e teu para usares. Copia e cola no teu projeto.
Como usar
Abre o componente no catálogo, carrega em copiar, e cola no teu projeto. O prompt copiado leva o código e as regras para o teu agente o reproduzir sem alterar nada.
Quatro cópias grátis por semana. Sem cartão.
Não precisa de pacotes além do que o projeto já tem
Licença
Este componente foi escrito para este catálogo. É teu para usares em trabalho de cliente e em produtos que vendas, nos termos de uso.
Aether Ribbon Mesh
'use client';
import React, { useEffect, useRef, useState } from 'react';
class Particle {
x: number;
y: number;
vx: number;
vy: number;
life: number;
maxLife: number;
size: number;
color: string;
constructor(x: number, y: number, color: string) {
this.x = x;
this.y = y;
this.vx = (Math.random() - 0.5) * 2;
this.vy = (Math.random() - 0.5) * 2;
this.maxLife = 80 + Math.random() * 60;
this.life = this.maxLife;
this.size = 1 + Math.random() * 2;
this.color = color;
}
update() {
this.x += this.vx;
this.y += this.vy;
this.life -= 1;
this.vx *= 0.98;
this.vy *= 0.98;
}
draw(ctx: CanvasRenderingContext2D) {
if (this.life <= 0) return;
ctx.globalAlpha = this.life / this.maxLife;
ctx.fillStyle = this.color;
ctx.beginPath();
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1.0;
}
}
export default function AetherRibbonMesh() {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const [isDarkMode, setIsDarkMode] = useState<boolean>(true);
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
setIsDarkMode(mediaQuery.matches);
const handler = (e: MediaQueryListEvent) => setIsDarkMode(e.matches);
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}, []);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d', { alpha: false });
if (!ctx) return;
let animationFrameId: number;
let width = 0;
let height = 0;
const mouse = { x: 0, y: 0, targetX: 0, targetY: 0, active: false };
const particles: Particle[] = [];
const clickRipple = { x: 0, y: 0, radius: 0, maxRadius: 400, speed: 14 };
const handleResize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
width = window.innerWidth;
height = window.innerHeight;
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
ctx.scale(dpr, dpr);
};
const handlePointerMove = (e: MouseEvent | TouchEvent) => {
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
mouse.targetX = clientX - width / 2;
mouse.targetY = clientY - height / 2;
mouse.active = true;
};
const handlePointerLeave = () => {
mouse.targetX = 0;
mouse.targetY = 0;
mouse.active = false;
};
const handlePointerDown = (e: MouseEvent | TouchEvent) => {
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
clickRipple.x = clientX;
clickRipple.y = clientY;
clickRipple.radius = 0;
// Dark mode (white background) gets slate blue particles; Light mode (black background) gets cyan
const pColor = isDarkMode ? 'rgba(37, 99, 235, 0.85)' : 'rgba(56, 189, 248, 0.85)';
for (let i = 0; i < 30; i++) {
particles.push(new Particle(clickRipple.x, clickRipple.y, pColor));
}
};
handleResize();
window.addEventListener('resize', handleResize);
window.addEventListener('mousemove', handlePointerMove);
window.addEventListener('touchmove', handlePointerMove, { passive: true });
window.addEventListener('mouseleave', handlePointerLeave);
window.addEventListener('mousedown', handlePointerDown);
window.addEventListener('touchstart', handlePointerDown, { passive: true });
let lastTime = performance.now();
let time = 0;
const noise = (x: number, t: number, o: number) =>
(Math.sin(x * 0.0012 + t * 0.25 + o) + Math.cos(x * 0.0028 - t * 0.4 + o * 2)) / 2;
const render = (now: number) => {
const dt = Math.min((now - lastTime) / 1000, 0.1);
lastTime = now;
time += dt * 0.85;
// Interpolate mouse position
const lerpFactor = 1 - Math.exp(-9 * dt);
mouse.x += (mouse.targetX - mouse.x) * lerpFactor;
mouse.y += (mouse.targetY - mouse.y) * lerpFactor;
// Inverted theme layout
// Dark mode -> Light & White layout | Light mode -> Black layout
const bgColor = isDarkMode ? '#ffffff' : '#030712';
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, width, height);
// Render and update active particles
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.update();
p.draw(ctx);
if (p.life <= 0) particles.splice(i, 1);
}
if (clickRipple.radius < clickRipple.maxRadius) {
clickRipple.radius += clickRipple.speed;
}
const layers = [
{ ribbonCount: 16, step: 4, offsetMod: 0, freqScale: 0.0035, ampScale: 55, speedScale: 1.1, primary: true },
{ ribbonCount: 10, step: 6, offsetMod: 1.2, freqScale: 0.0075, ampScale: 30, speedScale: 0.7, primary: false },
];
layers.forEach((layer) => {
ctx.globalCompositeOperation = layer.primary ? 'source-over' : 'multiply';
const gradient = ctx.createLinearGradient(0, 0, width, 0);
if (isDarkMode) {
// Dark Mode: Soft slate blue to deep blue-indigo gradient on white
gradient.addColorStop(0, `rgba(37, 99, 235, ${layer.primary ? 0.15 : 0.03})`);
gradient.addColorStop(0.5, `rgba(29, 78, 216, ${layer.primary ? 0.75 : 0.3})`);
gradient.addColorStop(1, `rgba(109, 40, 217, ${layer.primary ? 0.15 : 0.03})`);
} else {
// Light Mode: Vibrant cyan to light blue gradient on black
gradient.addColorStop(0, `rgba(56, 189, 248, ${layer.primary ? 0.1 : 0.02})`);
gradient.addColorStop(0.5, `rgba(96, 165, 250, ${layer.primary ? 0.8 : 0.35})`);
gradient.addColorStop(1, `rgba(168, 85, 247, ${layer.primary ? 0.1 : 0.02})`);
}
for (let r = 0; r < layer.ribbonCount; r++) {
const ribbonProgress = r / layer.ribbonCount;
const yOffset = height * 0.22 + r * (height * 0.032) + layer.offsetMod * 35;
const baseAlpha = (1 - ribbonProgress * 0.75) * (isDarkMode ? 0.8 : 0.65);
const rippleDistort =
clickRipple.radius < clickRipple.maxRadius
? Math.sin((time * 2 + ribbonProgress * Math.PI) * 2) *
((clickRipple.maxRadius / Math.max(clickRipple.radius, 1)) * 2.5)
: 0;
ctx.beginPath();
for (let x = 0; x <= width + layer.step; x += layer.step) {
const edgeEnvelope = Math.sin((x / width) * Math.PI);
const nFreq = 1 + noise(x, time, ribbonProgress) * 0.18;
const nAmp = 1 + noise(x * 2, -time, ribbonProgress * 0.5) * 0.15;
const wave1 =
Math.sin(x * (layer.freqScale * nFreq) + time * layer.speedScale + r * 0.18) *
(layer.ampScale * edgeEnvelope * nAmp);
const wave2 = Math.cos(x * 0.008 - time * 0.7 + r * 0.1) * (20 * edgeEnvelope);
const wave3 = Math.sin(x * 0.018 + time * 1.4) * (8 * edgeEnvelope);
const cursorXWorld = width / 2 + mouse.x;
const distToMouseX = Math.abs(x - cursorXWorld);
const mouseRadius = layer.primary ? 380 : 220;
const mouseFactor = Math.exp(-Math.pow(distToMouseX / mouseRadius, 2));
const mouseDisplacement =
Math.sin(x * 0.015 + time * 2.6) *
(mouseFactor * (layer.primary ? 50 : 25) * edgeEnvelope);
const rippleFactor = Math.exp(
-Math.pow(Math.abs(distToMouseX - clickRipple.radius) / (25 + rippleDistort), 2)
);
const rippleDisplacement = rippleFactor * rippleDistort * (1.8 - ribbonProgress);
const y =
yOffset +
wave1 +
wave2 +
wave3 +
mouseDisplacement +
rippleDisplacement +
mouse.y * (ribbonProgress * 0.1);
if (x === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
// Small accent node markings
if (layer.primary && x % 48 === 0) {
ctx.fillStyle = isDarkMode ? 'rgba(29, 78, 216, 0.25)' : 'rgba(168, 85, 247, 0.3)';
ctx.fillRect(x - 1, y - 1, 2, 2);
}
}
ctx.globalAlpha = baseAlpha;
ctx.strokeStyle = gradient;
ctx.lineWidth = (layer.primary ? 1.4 : 0.8) + (1 - ribbonProgress) * 0.5;
ctx.stroke();
}
});
ctx.globalAlpha = 1.0;
ctx.globalCompositeOperation = 'source-over';
animationFrameId = requestAnimationFrame(render);
};
animationFrameId = requestAnimationFrame(render);
return () => {
cancelAnimationFrame(animationFrameId);
window.removeEventListener('resize', handleResize);
window.removeEventListener('mousemove', handlePointerMove);
window.removeEventListener('touchmove', handlePointerMove);
window.removeEventListener('mouseleave', handlePointerLeave);
window.removeEventListener('mousedown', handlePointerDown);
window.removeEventListener('touchstart', handlePointerDown);
};
}, [isDarkMode]);
return (
<div
className={`relative w-full h-screen overflow-hidden select-none ${isDarkMode ? 'bg-white' : 'bg-gray-950'
}`}
>
<canvas ref={canvasRef} className="absolute inset-0 block cursor-default" />
{/* Hero Overlay */}
<div
className={`relative z-10 flex h-full flex-col items-center justify-center text-center px-4 pointer-events-none mix-blend-difference ${isDarkMode ? 'text-black' : 'text-white'
}`}
>
<span
className={`font-mono text-xs tracking-[0.3em] uppercase mb-4 ${isDarkMode ? 'text-blue-700' : 'text-blue-400'
}`}
>
</span>
<h1 className="font-mono text-7xl md:text-9xl font-black tracking-tighter uppercase leading-none">
AETHER
</h1>
<p className="mt-6 font-mono text-xs md:text-sm max-w-lg opacity-75 leading-relaxed">
Superpositioned trigonometric ribbon meshes reactive to cursor deflection, noise
modulation, and impulse force.
</p>
</div>
</div>
);
}