Dot Wave

PreviousNext

Dot-based waves ripple outward in symmetry, creating a subtle, motion-driven background layer

Docs
scrollxuicomponent

Preview

Loading preview…
components/ui/dot-wave.tsx
"use client"

import { useEffect, useRef } from "react"
import { cn } from "@/lib/utils"

interface DotWaveProps {
  dotGap?: number
  sphereRadius?: number
  dotRadiusMax?: number
  speed?: number
  expansionSpeed?: number
  repeatAnimation?: boolean
  lightIntensity?: number
  fadeIntensity?: number
  followMouse?: boolean
  className?: string
  dotClassName?: string
  children?: React.ReactNode
  bgColor?: string
  dotColor?: string
  rippleCount?: number
  rippleSpeed?: number
  rippleWidth?: number
  rippleIntensity?: number
}

class Ease {
  value: number
  begin: number
  end: number
  pow: number
  maxDuration: number
  time: number
  duration: number

  constructor(value: number, pow: number, duration: number, timeBegin: number) {
    this.value = this.begin = this.end = value
    this.pow = pow
    this.maxDuration = duration
    this.time = timeBegin
    this.duration = 0
    this.init()
  }

  init() {
    this.begin = this.end
    this.end = Math.random()
    this.time = 0
    this.duration = Math.sqrt(Math.abs(this.end - this.begin)) * this.maxDuration
  }

  update(timeChange = 1) {
    let timeRatio = this.time / this.duration

    if (timeRatio < 0.5) {
      timeRatio = 0.5 * Math.pow(timeRatio * 2, this.pow)
    } else {
      timeRatio = 1 - 0.5 * Math.pow((1 - timeRatio) * 2, this.pow)
    }

    this.value = this.begin + timeRatio * (this.end - this.begin)
    this.time += timeChange
    if (this.time > this.duration) {
      this.init()
    }
  }
}

interface Ripple {
  startTime: number
  delay: number
}

export function DotWave({
  dotGap = 20,
  sphereRadius = 200,
  dotRadiusMax = 3,
  speed = 0.15,
  expansionSpeed = 150,
  repeatAnimation = true,
  lightIntensity = 0.15,
  fadeIntensity = 0.05,
  followMouse = false,
  className,
  dotClassName,
  children,
  bgColor = "#000000",
  dotColor = "#ffffff",
  rippleCount = 2,
  rippleSpeed = 60,
  rippleWidth = 40,
  rippleIntensity = 0.3,
}: DotWaveProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null)
  const containerRef = useRef<HTMLDivElement>(null)
  const stateRef = useRef({
    canvas: null as HTMLCanvasElement | null,
    ctx: null as CanvasRenderingContext2D | null,
    center: { x: 0, y: 0 },
    windowSize: { w: 0, h: 0 },
    circleNumber: { x: 0, y: 0 },
    posStart: { x: 0, y: 0 },
    easeX: new Ease(0.5, 2, 60, 0),
    easeY: new Ease(0.5, 2, 60, 0),
    animationId: 0,
    ripples: [] as Ripple[],
    bgColor: "#000000",
    dotColor: "#ffffff",
  })

  useEffect(() => {
    const canvas = canvasRef.current
    const container = containerRef.current
    if (!canvas || !container) return

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

    const state = stateRef.current
    state.canvas = canvas
    state.ctx = ctx

    state.ripples = Array.from({ length: rippleCount }, (_, i) => ({
      startTime: Date.now(),
      delay: (i * 2000) / rippleCount,
    }))

    const updateColors = () => {
      const computedStyle = getComputedStyle(container)
      const containerBg = computedStyle.backgroundColor
      const containerColor = computedStyle.color
      
      state.bgColor = containerBg && containerBg !== 'rgba(0, 0, 0, 0)' && containerBg !== 'transparent' 
        ? containerBg 
        : bgColor
      
      state.dotColor = containerColor && containerColor !== 'rgba(0, 0, 0, 0)' && containerColor !== 'transparent'
        ? containerColor
        : dotColor
    }

    updateColors()

    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
    const handleThemeChange = () => updateColors()
    mediaQuery.addEventListener('change', handleThemeChange)

    const handleResize = () => {
      state.windowSize = {
        w: window.innerWidth,
        h: window.innerHeight,
      }

      canvas.width = state.windowSize.w
      canvas.height = state.windowSize.h

      state.center = {
        x: state.windowSize.w / 2,
        y: state.windowSize.h / 2,
      }

      setDotParams()
    }

    const setDotParams = () => {
      state.circleNumber = {
        x: Math.floor(state.windowSize.w / dotGap) + 2,
        y: Math.floor(state.windowSize.h / dotGap) + 1,
      }

      state.posStart = {
        x: Math.round((state.windowSize.w - (state.circleNumber.x - 1) * dotGap) / 2),
        y: Math.round((state.windowSize.h - (state.circleNumber.y - 1) * dotGap) / 2),
      }
    }

    const getDistance = (x: number, y: number) => {
      const distanceX = x - state.center.x
      const distanceY = y - state.center.y
      return Math.sqrt(distanceX * distanceX + distanceY * distanceY)
    }

    const drawDots = () => {
      ctx.beginPath()
      ctx.fillStyle = state.bgColor
      ctx.rect(0, 0, state.windowSize.w, state.windowSize.h)
      ctx.fill()
      ctx.closePath()

      const currentTime = Date.now()
      const maxScreenRadius = Math.sqrt(
        state.windowSize.w * state.windowSize.w + 
        state.windowSize.h * state.windowSize.h
      ) / 2

      const activeRipples = state.ripples.map(ripple => {
        const elapsed = currentTime - ripple.startTime - ripple.delay
        if (elapsed < 0) return -1
        
        const radius = (elapsed / 1000) * rippleSpeed
        
        if (repeatAnimation && radius > maxScreenRadius + 200) {
          ripple.startTime = currentTime
          return 0
        }
        
        return radius
      }).filter(r => r >= 0)

      for (let i = 0; i < state.circleNumber.x; i++) {
        for (let j = 0; j < state.circleNumber.y; j++) {
          const gapX = j % 2 === 0 ? -dotGap / 2 : 0
          const x = state.posStart.x + gapX + i * dotGap
          const y = state.posStart.y + j * dotGap

          const distance = getDistance(x, y)

          if (distance <= maxScreenRadius) {
            let maxAlpha = lightIntensity
            let maxGlow = 0

            activeRipples.forEach(rippleRadius => {
              const distanceFromRipple = Math.abs(distance - rippleRadius)
              
              if (distanceFromRipple < rippleWidth) {
                const rippleProgress = 1 - (distanceFromRipple / rippleWidth)
                const rippleAlpha = lightIntensity + (rippleProgress * rippleIntensity)
                maxAlpha = Math.max(maxAlpha, rippleAlpha)
                maxGlow = Math.max(maxGlow, rippleProgress)
              } else if (distance < rippleRadius) {
                const behindDistance = rippleRadius - distance
                const fadeDistance = 100
                
                if (behindDistance < fadeDistance) {
                  const fadeProgress = behindDistance / fadeDistance
                  const behindAlpha = lightIntensity + (1 - fadeProgress) * 0.1
                  maxAlpha = Math.max(maxAlpha, behindAlpha)
                }
              }
            })

            if (maxAlpha > 0) {
              ctx.save()
              ctx.globalAlpha = Math.min(maxAlpha, 1)
              
              if (maxGlow > 0) {
                ctx.shadowBlur = 5 * maxGlow
                ctx.shadowColor = state.dotColor
              }
              
              ctx.beginPath()
              ctx.fillStyle = state.dotColor
              ctx.arc(x, y, dotRadiusMax, 0, 2 * Math.PI, false)
              ctx.fill()
              ctx.closePath()
              ctx.restore()
            }
          }
        }
      }
    }

    const moveCenter = (e: MouseEvent | null) => {
      if (e === null) {
        state.easeX.update(speed)
        state.easeY.update(speed)
        state.center.x = state.easeX.value * state.windowSize.w
        state.center.y = state.easeY.value * state.windowSize.h
      } else {
        state.center.x = e.pageX
        state.center.y = e.pageY
      }
    }

    const render = () => {
      if (!followMouse) {
        moveCenter(null)
      }
      drawDots()
    }

    const draw = () => {
      state.animationId = requestAnimationFrame(draw)
      render()
    }

    const handleMouseMove = (e: MouseEvent) => {
      if (followMouse) {
        moveCenter(e)
      }
    }

    handleResize()
    window.addEventListener("resize", handleResize)
    canvas.addEventListener("mousemove", handleMouseMove)

    draw()

    return () => {
      window.removeEventListener("resize", handleResize)
      canvas.removeEventListener("mousemove", handleMouseMove)
      mediaQuery.removeEventListener('change', handleThemeChange)
      cancelAnimationFrame(state.animationId)
    }
  }, [dotGap, sphereRadius, dotRadiusMax, speed, expansionSpeed, repeatAnimation, lightIntensity, fadeIntensity, followMouse, bgColor, dotColor, rippleCount, rippleSpeed, rippleWidth, rippleIntensity])

  return (
    <div 
      ref={containerRef}
      className={cn("relative overflow-hidden", className)}
      style={{
        '--dot-wave-bg': 'var(--dot-wave-bg, light-dark(#ffffff, #0a0a0a))',
        '--dot-wave-color': 'var(--dot-wave-color, light-dark(#000000, #00d4ff))',
      } as React.CSSProperties}
    >
      <canvas 
        ref={canvasRef} 
        className={cn("absolute inset-0 w-full h-full", dotClassName)} 
      />
      {children && <div className="relative z-10">{children}</div>}
    </div>
  )
}

Installation

npx shadcn@latest add @scrollxui/dot-wave

Usage

import { DotWave } from "@/components/dot-wave"
<DotWave />