animated-grid-pattern

PreviousNext

An animated grid pattern component.

Docs
eldorauiui

Preview

Loading preview…
registry/eldoraui/animated-grid-pattern.tsx
"use client"

import React, { useEffect, useMemo, useRef, useState } from "react"
import { Canvas, useFrame, useThree } from "@react-three/fiber"
import { useTheme } from "next-themes"
import { ExtrudeGeometry, Shape } from "three"
import * as THREE from "three"

interface GridProps {
  position: [number, number, number]
  width?: number
  length?: number
  cornerRadius?: number
  gridPosition: [number, number]
  hoveredGrid: [number, number] | null
  rippleScale?: number
  rippleRadius?: number
}

const Grid = ({
  position,
  width = 4,
  length = 4,
  cornerRadius = 2,
  gridPosition,
  hoveredGrid,
  rippleScale = 0.3,
  rippleRadius = 3,
}: GridProps) => {
  const meshRef = useRef<THREE.Mesh>(null)
  const [currentScale, setCurrentScale] = useState(1)
  const { theme } = useTheme()

  const gridColor = theme === "dark" ? "#232323" : "#e5e7eb"

  const geometry = useMemo(() => {
    const shape = new Shape()
    const angleStep = Math.PI * 0.5
    const radius = cornerRadius

    const halfWidth = width / 2
    const halfLength = length / 2

    shape.absarc(
      halfWidth - radius,
      halfLength - radius,
      radius,
      angleStep * 0,
      angleStep * 1
    )
    shape.absarc(
      -halfWidth + radius,
      halfLength - radius,
      radius,
      angleStep * 1,
      angleStep * 2
    )
    shape.absarc(
      -halfWidth + radius,
      -halfLength + radius,
      radius,
      angleStep * 2,
      angleStep * 3
    )
    shape.absarc(
      halfWidth - radius,
      -halfLength + radius,
      radius,
      angleStep * 3,
      angleStep * 4
    )

    const extrudeSettings = {
      depth: 0.3,
      bevelEnabled: true,
      bevelThickness: 0.05,
      bevelSize: 0.05,
      bevelSegments: 20,
      curveSegments: 20,
    }

    const geometry = new ExtrudeGeometry(shape, extrudeSettings)
    geometry.center()

    return geometry
  }, [width, length, cornerRadius])

  useEffect(() => {
    return () => {
      geometry.dispose()
    }
  }, [geometry])

  useFrame(() => {
    if (meshRef.current) {
      let targetScale = 1

      const isThisGridHovered =
        hoveredGrid &&
        gridPosition[0] === hoveredGrid[0] &&
        gridPosition[1] === hoveredGrid[1]

      if (isThisGridHovered) {
        targetScale = 5
      } else if (hoveredGrid) {
        const dx = gridPosition[0] - hoveredGrid[0]
        const dz = gridPosition[1] - hoveredGrid[1]
        const distance = Math.sqrt(dx * dx + dz * dz)

        if (distance <= rippleRadius && distance > 0) {
          const falloff = Math.max(0, 1 - distance / rippleRadius)
          const rippleEffect = falloff * rippleScale
          targetScale = 1 + rippleEffect * 3
        }
      }

      const lerpFactor = 0.1
      const newScale = currentScale + (targetScale - currentScale) * lerpFactor
      setCurrentScale(newScale)

      meshRef.current.scale.z = newScale
    }
  })

  useEffect(() => {
    if (meshRef.current) {
      meshRef.current.userData.gridPosition = gridPosition
    }
  }, [gridPosition])

  return (
    <mesh
      ref={meshRef}
      geometry={geometry}
      position={position}
      rotation={[Math.PI / 2, 0, 0]}
    >
      <meshPhysicalMaterial
        color={gridColor}
        roughness={0.5}
        metalness={1}
        clearcoat={1}
        clearcoatRoughness={0}
        clearcoatNormalScale={1}
        clearcoatNormalMap={null}
      />
    </mesh>
  )
}

function HoverDetector({
  onHoverChange,
}: {
  gridSize: number
  spacingX: number
  spacingZ: number
  onHoverChange: (hoveredGrid: [number, number] | null) => void
}) {
  const { camera, raycaster, pointer, scene } = useThree()

  useFrame(() => {
    raycaster.setFromCamera(pointer, camera)

    const intersects = raycaster.intersectObjects(scene.children, true)

    if (intersects.length > 0) {
      for (const intersect of intersects) {
        const mesh = intersect.object
        if (mesh.userData && mesh.userData.gridPosition) {
          const gridPos = mesh.userData.gridPosition as [number, number]
          onHoverChange(gridPos)
          return
        }
      }
    }

    onHoverChange(null)
  })

  return null
}

interface GridOfGridesProps {
  gridSize: number
  gridWidth: number
  gridLength: number
  gap: number
  rippleScale: number
  rippleRadius: number
  cornerRadius: number
}

function GridOfGrides({
  gridSize,
  gridWidth,
  gridLength,
  gap,
  rippleScale,
  rippleRadius,
  cornerRadius,
}: GridOfGridesProps) {
  const spacingX = gridWidth + gap
  const spacingZ = gridLength + gap

  const [hoveredGrid, setHoveredGrid] = useState<[number, number] | null>(null)

  const Grides = []

  for (let x = 0; x < gridSize; x++) {
    for (let z = 0; z < gridSize; z++) {
      const posX = (x - (gridSize - 1) / 2) * spacingX
      const posZ = (z - (gridSize - 1) / 2) * spacingZ

      Grides.push(
        <Grid
          key={`${x}-${z}`}
          position={[posX, -0.85, posZ]}
          width={gridWidth}
          length={gridLength}
          cornerRadius={cornerRadius}
          gridPosition={[x, z]}
          hoveredGrid={hoveredGrid}
          rippleScale={rippleScale}
          rippleRadius={rippleRadius}
        />
      )
    }
  }

  return (
    <>
      <HoverDetector
        gridSize={gridSize}
        spacingX={spacingX}
        spacingZ={spacingZ}
        onHoverChange={setHoveredGrid}
      />
      {Grides}
    </>
  )
}

interface AnimatedGridPatternProps {
  gridSize?: number
  gridWidth?: number
  gridLength?: number
  gap?: number
  rippleScale?: number
  rippleRadius?: number
  cornerRadius?: number
  cameraPosition?: [number, number, number]
  cameraRotation?: [number, number, number]
  fov?: number
}

export function AnimatedGridPattern({
  gridSize = 10,
  gridWidth = 4,
  gridLength = 4,
  gap = 0.05,
  rippleScale = 2.5,
  rippleRadius = 2,
  cornerRadius = 0.8,
  cameraPosition = [-9.31, 12, 24.72],
  cameraRotation = [-0.65, -0.2, -0.13],
  fov = 35,
}: AnimatedGridPatternProps) {
  return (
    <div className="relative z-0 h-full w-full bg-gray-50 dark:bg-black">
      <Canvas
        camera={{
          position: cameraPosition,
          rotation: cameraRotation,
          fov: fov,
        }}
      >
        <ambientLight intensity={1} />

        <directionalLight position={[10, 15, 10]} intensity={10} castShadow />

        <directionalLight
          position={[-10, 10, -5]}
          intensity={10}
          color="#ffffff"
        />

        <directionalLight
          position={[5, -10, 15]}
          intensity={5}
          color="#f0f8ff"
        />

        <pointLight position={[0, 20, 3]} intensity={2} distance={50} />

        <pointLight
          position={[15, 5, 15]}
          intensity={1.5}
          distance={40}
          color="#ffffff"
        />

        <GridOfGrides
          gridSize={gridSize}
          gridWidth={gridWidth}
          gridLength={gridLength}
          gap={gap}
          rippleScale={rippleScale}
          rippleRadius={rippleRadius}
          cornerRadius={cornerRadius}
        />
      </Canvas>
    </div>
  )
}

Installation

npx shadcn@latest add @eldoraui/animated-grid-pattern

Usage

import { AnimatedGridPattern } from "@/components/ui/animated-grid-pattern"
<AnimatedGridPattern />