Vortex Background
A wavy, swirly, vortex background ideal for CTAs and backgrounds.
The hell is this?
This is chemical burn. It'll hurt more than you've ever been burned and you'll have a scar.
Installation
Install dependencies
npm i framer-motion clsx tailwind-merge simplex-noise
Add util file
lib/utils.ts
import { ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
Copy the source code
components/ui/vortex.tsx
import { cn } from "@/lib/utils";
import React, { useEffect, useRef } from "react";
import { createNoise3D } from "simplex-noise";
import { motion } from "framer-motion";
interface VortexProps {
children?: any;
className?: string;
containerClassName?: string;
particleCount?: number;
rangeY?: number;
baseHue?: number;
baseSpeed?: number;
rangeSpeed?: number;
baseRadius?: number;
rangeRadius?: number;
backgroundColor?: string;
}
export const Vortex = (props: VortexProps) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef(null);
const particleCount = props.particleCount || 700;
const particlePropCount = 9;
const particlePropsLength = particleCount * particlePropCount;
const rangeY = props.rangeY || 100;
const baseTTL = 50;
const rangeTTL = 150;
const baseSpeed = props.baseSpeed || 0.0;
const rangeSpeed = props.rangeSpeed || 1.5;
const baseRadius = props.baseRadius || 1;
const rangeRadius = props.rangeRadius || 2;
const baseHue = props.baseHue || 220;
const rangeHue = 100;
const noiseSteps = 3;
const xOff = 0.00125;
const yOff = 0.00125;
const zOff = 0.0005;
const backgroundColor = props.backgroundColor || "#000000";
let tick = 0;
const noise3D = createNoise3D();
let particleProps = new Float32Array(particlePropsLength);
let center: [number, number] = [0, 0];
const HALF_PI: number = 0.5 * Math.PI;
const TAU: number = 2 * Math.PI;
const TO_RAD: number = Math.PI / 180;
const rand = (n: number): number => n * Math.random();
const randRange = (n: number): number => n - rand(2 * n);
const fadeInOut = (t: number, m: number): number => {
let hm = 0.5 * m;
return Math.abs(((t + hm) % m) - hm) / hm;
};
const lerp = (n1: number, n2: number, speed: number): number =>
(1 - speed) * n1 + speed * n2;
const setup = () => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (canvas && container) {
const ctx = canvas.getContext("2d");
if (ctx) {
resize(canvas, ctx);
initParticles();
draw(canvas, ctx);
}
}
};
const initParticles = () => {
tick = 0;
// simplex = new SimplexNoise();
particleProps = new Float32Array(particlePropsLength);
for (let i = 0; i < particlePropsLength; i += particlePropCount) {
initParticle(i);
}
};
const initParticle = (i: number) => {
const canvas = canvasRef.current;
if (!canvas) return;
let x, y, vx, vy, life, ttl, speed, radius, hue;
x = rand(canvas.width);
y = center[1] + randRange(rangeY);
vx = 0;
vy = 0;
life = 0;
ttl = baseTTL + rand(rangeTTL);
speed = baseSpeed + rand(rangeSpeed);
radius = baseRadius + rand(rangeRadius);
hue = baseHue + rand(rangeHue);
particleProps.set([x, y, vx, vy, life, ttl, speed, radius, hue], i);
};
const draw = (canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D) => {
tick++;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = backgroundColor;
ctx.fillRect(0, 0, canvas.width, canvas.height);
drawParticles(ctx);
renderGlow(canvas, ctx);
renderToScreen(canvas, ctx);
window.requestAnimationFrame(() => draw(canvas, ctx));
};
const drawParticles = (ctx: CanvasRenderingContext2D) => {
for (let i = 0; i < particlePropsLength; i += particlePropCount) {
updateParticle(i, ctx);
}
};
const updateParticle = (i: number, ctx: CanvasRenderingContext2D) => {
const canvas = canvasRef.current;
if (!canvas) return;
let i2 = 1 + i,
i3 = 2 + i,
i4 = 3 + i,
i5 = 4 + i,
i6 = 5 + i,
i7 = 6 + i,
i8 = 7 + i,
i9 = 8 + i;
let n, x, y, vx, vy, life, ttl, speed, x2, y2, radius, hue;
x = particleProps[i];
y = particleProps[i2];
n = noise3D(x * xOff, y * yOff, tick * zOff) * noiseSteps * TAU;
vx = lerp(particleProps[i3], Math.cos(n), 0.5);
vy = lerp(particleProps[i4], Math.sin(n), 0.5);
life = particleProps[i5];
ttl = particleProps[i6];
speed = particleProps[i7];
x2 = x + vx * speed;
y2 = y + vy * speed;
radius = particleProps[i8];
hue = particleProps[i9];
drawParticle(x, y, x2, y2, life, ttl, radius, hue, ctx);
life++;
particleProps[i] = x2;
particleProps[i2] = y2;
particleProps[i3] = vx;
particleProps[i4] = vy;
particleProps[i5] = life;
(checkBounds(x, y, canvas) || life > ttl) && initParticle(i);
};
const drawParticle = (
x: number,
y: number,
x2: number,
y2: number,
life: number,
ttl: number,
radius: number,
hue: number,
ctx: CanvasRenderingContext2D
) => {
ctx.save();
ctx.lineCap = "round";
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 checkBounds = (x: number, y: number, canvas: HTMLCanvasElement) => {
return x > canvas.width || x < 0 || y > canvas.height || y < 0;
};
const resize = (
canvas: HTMLCanvasElement,
ctx?: CanvasRenderingContext2D
) => {
const { innerWidth, innerHeight } = window;
canvas.width = innerWidth;
canvas.height = innerHeight;
center[0] = 0.5 * canvas.width;
center[1] = 0.5 * canvas.height;
};
const renderGlow = (
canvas: HTMLCanvasElement,
ctx: CanvasRenderingContext2D
) => {
ctx.save();
ctx.filter = "blur(8px) brightness(200%)";
ctx.globalCompositeOperation = "lighter";
ctx.drawImage(canvas, 0, 0);
ctx.restore();
ctx.save();
ctx.filter = "blur(4px) brightness(200%)";
ctx.globalCompositeOperation = "lighter";
ctx.drawImage(canvas, 0, 0);
ctx.restore();
};
const renderToScreen = (
canvas: HTMLCanvasElement,
ctx: CanvasRenderingContext2D
) => {
ctx.save();
ctx.globalCompositeOperation = "lighter";
ctx.drawImage(canvas, 0, 0);
ctx.restore();
};
useEffect(() => {
setup();
window.addEventListener("resize", () => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (canvas && ctx) {
resize(canvas, ctx);
}
});
}, []);
return (
<div className={cn("relative h-full w-full", props.containerClassName)}>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
ref={containerRef}
className="absolute h-full w-full inset-0 z-0 bg-transparent flex items-center justify-center"
>
<canvas ref={canvasRef}></canvas>
</motion.div>
<div className={cn("relative z-10", props.className)}>
{props.children}
</div>
</div>
);
};
Full page demo usage
The hell is this?
This is chemical burn. It'll hurt more than you've ever been burned and you'll have a scar.
Props
Prop Name | Type | Default Value | Description |
---|---|---|---|
children | any | Optional children to be rendered inside the component. | |
className | string | Optional className for styling the children wrapper. | |
containerClassName | string | Optional className for styling the container. | |
particleCount | number | 700 | Number of particles to be generated. |
rangeY | number | 100 | Vertical range for particle movement. |
baseHue | number | 220 | Base hue for particle color. |
baseSpeed | number | 0.0 | Base speed for particle movement. |
rangeSpeed | number | 1.5 | Range of speed variation for particles. |
baseRadius | number | 1 | Base radius of particles. |
rangeRadius | number | 2 | Range of radius variation for particles. |
backgroundColor | string | "#000000" | Background color of the canvas. |
This component is inspired by this GitHub Repo