Spark Waves

PreviousNext

Radial spark waves pulse outward and inward, creating a dynamic energy-driven background layer.

Docs
scrollxuicomponent

Preview

Loading preview…
components/ui/spark-waves.tsx
"use client";
import React, { useRef, useEffect, useCallback } from 'react';
import { cn } from "@/lib/utils";

interface SparkWavesProps {
  sparkColor?: string;
  sparkSize?: number;
  sparkCount?: number;
  duration?: number;
  easing?: 'linear' | 'ease-in' | 'ease-out' | 'ease-in-out';
  waveInterval?: number;
  maxRadius?: number;
  ringsPerWave?: number;
  ringSpacing?: number;
  enableInward?: boolean;
  className?: string;
  children?: React.ReactNode;
}

interface Spark {
  x: number;
  y: number;
  angle: number;
  radius: number;
  startTime: number;
  waveId: number;
  ringIndex: number;
  direction: 'outward' | 'inward';
}

export const SparkWaves: React.FC<SparkWavesProps> = ({
  sparkColor = '#3b82f6',
  sparkSize = 10,
  sparkCount = 24,
  duration = 4000,
  easing = 'ease-out',
  waveInterval = 1400,
  maxRadius = 1000,
  ringsPerWave = 6,
  ringSpacing = 50,
  enableInward = true,
  className = '',
  children
}) => {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const sparksRef = useRef<Spark[]>([]);
  const lastWaveTimeRef = useRef<number>(0);
  const lastInwardWaveTimeRef = useRef<number>(700);
  const waveIdRef = useRef<number>(0);
  const centerXRef = useRef<number>(0);
  const centerYRef = useRef<number>(0);
  const maxScreenRadiusRef = useRef<number>(0);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const parent = canvas.parentElement;
    if (!parent) return;

    let resizeTimeout: NodeJS.Timeout;

    const resizeCanvas = () => {
      const { width, height } = parent.getBoundingClientRect();
      if (canvas.width !== width || canvas.height !== height) {
        canvas.width = width;
        canvas.height = height;
        centerXRef.current = width / 2;
        centerYRef.current = height / 2;
        maxScreenRadiusRef.current = Math.sqrt(width * width + height * height) / 2 + 100;
      }
    };

    const handleResize = () => {
      clearTimeout(resizeTimeout);
      resizeTimeout = setTimeout(resizeCanvas, 100);
    };

    const ro = new ResizeObserver(handleResize);
    ro.observe(parent);

    resizeCanvas();

    return () => {
      ro.disconnect();
      clearTimeout(resizeTimeout);
    };
  }, []);

  const easeFunc = useCallback(
    (t: number) => {
      switch (easing) {
        case 'linear':
          return t;
        case 'ease-in':
          return t * t;
        case 'ease-in-out':
          return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
        default:
          return t * (2 - t);
      }
    },
    [easing]
  );

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;

    const ctx = canvas.getContext('2d');
    if (!ctx) return;

    let animationId: number;

    const draw = (timestamp: number) => {
      ctx.clearRect(0, 0, canvas.width, canvas.height);

      // Outward waves
      if (timestamp - lastWaveTimeRef.current >= waveInterval) {
        const currentWaveId = waveIdRef.current++;

        for (let ringIndex = 0; ringIndex < ringsPerWave; ringIndex++) {
          const ringDelay = ringIndex * 150;

          for (let i = 0; i < sparkCount; i++) {
            const angleOffset = (ringIndex % 2 === 0) ? 0 : (Math.PI / sparkCount);
            const angle = (2 * Math.PI * i) / sparkCount + angleOffset;

            sparksRef.current.push({
              x: centerXRef.current,
              y: centerYRef.current,
              angle,
              radius: ringIndex * ringSpacing,
              startTime: timestamp + ringDelay,
              waveId: currentWaveId,
              ringIndex,
              direction: 'outward'
            });
          }
        }

        lastWaveTimeRef.current = timestamp;
      }

      // Inward waves
      if (enableInward && timestamp - lastInwardWaveTimeRef.current >= waveInterval) {
        const currentWaveId = waveIdRef.current++;

        for (let ringIndex = 0; ringIndex < ringsPerWave; ringIndex++) {
          const ringDelay = ringIndex * 150;

          for (let i = 0; i < sparkCount; i++) {
            const angleOffset = (ringIndex % 2 === 0) ? (Math.PI / sparkCount / 2) : 0;
            const angle = (2 * Math.PI * i) / sparkCount + angleOffset;

            sparksRef.current.push({
              x: centerXRef.current,
              y: centerYRef.current,
              angle,
              radius: maxScreenRadiusRef.current - ringIndex * ringSpacing,
              startTime: timestamp + ringDelay,
              waveId: currentWaveId,
              ringIndex,
              direction: 'inward'
            });
          }
        }

        lastInwardWaveTimeRef.current = timestamp;
      }

      // Draw & filter sparks
      sparksRef.current = sparksRef.current.filter((spark: Spark) => {
        const elapsed = timestamp - spark.startTime;

        if (elapsed < 0) return true;
        if (elapsed >= duration) return false;

        const progress = elapsed / duration;
        const eased = easeFunc(progress);

        let currentRadius: number;

        if (spark.direction === 'outward') {
          const expansionDistance = eased * maxRadius;
          currentRadius = spark.radius + expansionDistance;
        } else {
          const contractionDistance = eased * (maxScreenRadiusRef.current - 0);
          currentRadius = spark.radius - contractionDistance;
          if (currentRadius < 0) return false;
        }

        const fadeStart = 0.6;
        const opacity = progress < fadeStart ? 1 : 1 - ((progress - fadeStart) / (1 - fadeStart));

        const lineLength = sparkSize * (1 - eased * 0.3);

        const x1 = spark.x + currentRadius * Math.cos(spark.angle);
        const y1 = spark.y + currentRadius * Math.sin(spark.angle);

        const lineAngle = spark.direction === 'inward' ? spark.angle + Math.PI : spark.angle;
        const x2 = x1 + lineLength * Math.cos(lineAngle);
        const y2 = y1 + lineLength * Math.sin(lineAngle);

        ctx.shadowBlur = 10;
        ctx.shadowColor = sparkColor;
        ctx.strokeStyle = sparkColor;
        ctx.globalAlpha = opacity;
        ctx.lineWidth = 2.5;
        ctx.lineCap = 'round';

        ctx.beginPath();
        ctx.moveTo(x1, y1);
        ctx.lineTo(x2, y2);
        ctx.stroke();

        return true;
      });

      ctx.shadowBlur = 0;
      ctx.globalAlpha = 1;

      animationId = requestAnimationFrame(draw);
    };

    animationId = requestAnimationFrame(draw);

    return () => {
      cancelAnimationFrame(animationId);
    };
  }, [sparkColor, sparkSize, sparkCount, duration, easeFunc, waveInterval, maxRadius, ringsPerWave, ringSpacing, enableInward]);

  return (
    <div className={cn("relative overflow-hidden", className)}>
      <canvas ref={canvasRef} className="absolute inset-0 w-full h-full" />
      {children && <div className="relative z-10">{children}</div>}
    </div>
  );
};

Installation

npx shadcn@latest add @scrollxui/spark-waves

Usage

import { SparkWaves } from "@/components/spark-waves"
<SparkWaves />