shader-void

PreviousNext
Docs
aliimamcomponent

Preview

Loading preview…
registry/default/components/shader-void.tsx
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client"

import type React from "react"
import { useEffect, useRef, useState } from "react"

interface VoidBallAnimationProps {
  width?: number
  height?: number
  voidBallsAmount?: number
  voidBallsColor?: string
  plasmaBallsColor?: string
  plasmaBallsStroke?: string
  gooeyCircleSize?: number
  blendMode?: string
  className?: string
}

interface VoidBall {
  r: number
  cx: number
  cy: number
  fill: string
  stroke: string
  strokeWidth: number
  strokeDashArray: number
}

interface PlasmaBall {
  id: string
  x: number
  y: number
  targetX: number
  targetY: number
  scale: number
  opacity: number
}

export function ShaderVoid({
  width = 1400,
  height = 600,
  voidBallsAmount = 0,
  voidBallsColor = "#7700FF",
  plasmaBallsColor = "#FF00FF",
  plasmaBallsStroke = "#0000FF",
  gooeyCircleSize = 30,
  blendMode = "difference",
  className = "",
}: VoidBallAnimationProps) {
  const svgRef = useRef<SVGSVGElement>(null)
  const [voidBalls, setVoidBalls] = useState<VoidBall[]>([])
  const [plasmaBalls, setPlasmaBalls] = useState<PlasmaBall[]>([])
  const lastUserGesture = useRef(0)
  const animationFrame = useRef<number | null>(null)

  // Initialize void balls
  useEffect(() => {
    const voidBallsMaxRadius = Math.sqrt((width * height) / Math.PI) / 3
    const balls = Array.from({ length: voidBallsAmount }, () => ({
      r: Math.max(
        Math.min(voidBallsMaxRadius * Math.random() * 2, voidBallsMaxRadius),
        voidBallsMaxRadius / 1.5
      ),
      cx: Math.random() * width,
      cy: Math.random() * height,
      fill: "url(#void-ball-inner)",
      stroke: voidBallsColor,
      strokeWidth: 10,
      strokeDashArray: voidBallsMaxRadius / 10,
    }))
    setVoidBalls(balls)
  }, [width, height, voidBallsAmount, voidBallsColor])

  // Add plasma ball
  const addPlasmaBall = (x: number, y: number) => {
    const id = Math.random().toString(36).substr(2, 9)
    const spreadFactor = 12
    const targetX = x + (Math.random() - 0.5) * gooeyCircleSize * spreadFactor
    const targetY = y + (Math.random() - 0.5) * gooeyCircleSize * spreadFactor

    const newBall: PlasmaBall = {
      id,
      x,
      y,
      targetX,
      targetY,
      scale: 1,
      opacity: 1,
    }

    setPlasmaBalls((prev) => [...prev, newBall])

    // Animate the ball
    const startTime = Date.now()
    const duration = 2000

    const animate = () => {
      const elapsed = Date.now() - startTime
      const progress = Math.min(elapsed / duration, 1)

      if (progress >= 1) {
        setPlasmaBalls((prev) => prev.filter((ball) => ball.id !== id))
        return
      }

      setPlasmaBalls((prev) =>
        prev.map((ball) =>
          ball.id === id
            ? {
                ...ball,
                x: x + (targetX - x) * progress,
                y: y + (targetY - y) * progress,
                scale: 1 - progress,
                opacity: 1 - progress,
              }
            : ball
        )
      )

      requestAnimationFrame(animate)
    }

    requestAnimationFrame(animate)
  }

  // Handle mouse/touch events
  const handleInteraction = (event: React.MouseEvent | React.TouchEvent) => {
    event.preventDefault()

    let clientX: number, clientY: number

    if ("touches" in event) {
      //@ts-ignore
      clientX = event.touches[0].clientX
      //@ts-ignore
      clientY = event.touches[0].clientY
    } else {
      clientX = event.clientX
      clientY = event.clientY
    }

    const rect = svgRef.current?.getBoundingClientRect()
    if (rect) {
      const x = clientX - rect.left
      const y = clientY - rect.top

      addPlasmaBall(x, y)

      // Add plasma balls at void ball positions
      voidBalls.forEach((voidBall) => {
        addPlasmaBall(voidBall.cx, voidBall.cy)
      })
    }

    lastUserGesture.current = Date.now()
  }

  // Auto-render animation
  useEffect(() => {
    const autoRender = () => {
      if (Date.now() - lastUserGesture.current > 500) {
        // Simple noise simulation
        const time = Date.now() * 0.0001
        const noiseX = (Math.sin(time * 2) + 1) / 2
        const noiseY = (Math.cos(time * 3) + 1) / 2
        const x = noiseX * width
        const y = noiseY * height

        addPlasmaBall(x, y)

        voidBalls.forEach((voidBall) => {
          addPlasmaBall(voidBall.cx, voidBall.cy)
        })
      }

      animationFrame.current = requestAnimationFrame(autoRender)
    }

    animationFrame.current = requestAnimationFrame(autoRender)

    return () => {
      if (animationFrame.current) {
        cancelAnimationFrame(animationFrame.current)
      }
    }
  }, [width, height, voidBalls])

  return (
    <div className={`overflow-hidden ${className}`} style={{ width, height }}>
      <svg
        ref={svgRef}
        width={width}
        height={height}
        viewBox={`0 0 ${width} ${height}`}
        className="cursor-none"
        style={{ filter: "url(#gooey-filter)" }}
        onMouseMove={handleInteraction}
        onClick={handleInteraction}
        onTouchMove={handleInteraction}
      >
        <defs>
          <radialGradient id="void-ball-inner">
            <stop offset="50%" stopColor="#000000" />
            <stop offset="100%" stopColor="#FF0000" />
          </radialGradient>

          <filter id="gooey-filter">
            <feGaussianBlur
              in="SourceGraphic"
              colorInterpolationFilters="sRGB"
              stdDeviation="15"
              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 50 -16"
              result="gooey"
            />
            <feTurbulence baseFrequency="0.03" numOctaves="1" />
            <feDisplacementMap
              in="blur"
              scale="30"
              xChannelSelector="B"
              yChannelSelector="G"
            />
            <feBlend in="gooey" mode={blendMode as any} />
          </filter>
        </defs>

        {/* Void Balls */}
        {voidBalls.map((ball, index) => (
          <circle
            key={index}
            r={ball.r}
            cx={ball.cx}
            cy={ball.cy}
            fill={ball.fill}
            stroke={ball.stroke}
            strokeWidth={ball.strokeWidth}
            strokeDasharray={ball.strokeDashArray}
            strokeLinecap="round"
            style={{
              animation:
                "strokeDashoffset 100s linear infinite, strokeWidth 1s linear infinite alternate",
            }}
          />
        ))}

        {/* Plasma Balls */}
        {plasmaBalls.map((ball) => (
          <circle
            key={ball.id}
            r={gooeyCircleSize}
            cx={ball.x}
            cy={ball.y}
            fill={plasmaBallsColor}
            stroke={plasmaBallsStroke}
            strokeWidth={gooeyCircleSize / 3}
            opacity={ball.opacity}
            transform={`scale(${ball.scale})`}
            style={{ transformOrigin: `${ball.x}px ${ball.y}px` }}
          />
        ))}
      </svg>

      <style jsx>{`
        @keyframes strokeDashoffset {
          from {
            stroke-dashoffset: 0px;
          }
          to {
            stroke-dashoffset: 10000px;
          }
        }

        @keyframes strokeWidth {
          from {
            stroke-width: 20px;
          }
          to {
            stroke-width: 40px;
          }
        }
      `}</style>
    </div>
  )
}

Installation

npx shadcn@latest add @aliimam/shader-void

Usage

import { ShaderVoid } from "@/components/shader-void"
<ShaderVoid />