All components

Aether Ribbon Mesh

Aether Ribbon Mesh is a React and Tailwind CSS background component written for NedDev and yours to ship. Copy it and paste it into your project.

NedDev backgrounds

What it is

Aether Ribbon Mesh is a React and Tailwind CSS background component written for NedDev and yours to ship. Copy it and paste it into your project.

How to use it

Open it in the catalogue, press copy, and paste it into your project. The prompt you copy carries the code and the rules that tell your agent to reproduce it without changing anything.

Open in the catalogue

Four free copies a week. No card.

No packages beyond the project baseline

Licence

This component was written for this catalogue. It is yours to ship in client work and in products you sell, under the terms of use.

Aether Ribbon Mesh

components/ui/aether-ribbon-mesh.tsx
'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>
    );
}

Similar components