Velocity UI
Loading…
Menu

Installation

Add this component to your project using the CLI:

terminal
npx vui-registry-cli-v1 add particle-image-gallery

Source Code

particle-image-gallery.tsx
'use client'

import React, { useState, useRef, useMemo, useEffect } from 'react'
import { Canvas, useFrame, useThree, extend } from '@react-three/fiber'
import { useTexture, shaderMaterial, OrbitControls, Center, Float } from '@react-three/drei'
import * as THREE from 'three'
import { motion, AnimatePresence } from 'framer-motion'
import { ChevronLeft, ChevronRight, Maximize2, Sparkles, Wind, Zap, Droplets } from 'lucide-react'
import { Suspense } from 'react'
import { cn } from '@/lib/utils'

// ==================================================================================
// SHADERS
// ==================================================================================

const PremiumParticleMaterial = shaderMaterial(
  {
    uTime: 0,
    uProgress: 0,
    uTexture1: null,
    uTexture2: null,
    uResolution: new THREE.Vector2(0, 0),
    uMouse: new THREE.Vector3(0, 0, 0),
    uPointSize: 2.0,
    uMode: 0, // 0: Liquid, 1: Plasma, 2: Crystal, 3: Cloud
    uColor1: new THREE.Color('#ffffff'),
    uColor2: new THREE.Color('#ffffff'),
  },
  // Vertex Shader
  `
    uniform float uTime;
    uniform float uProgress;
    uniform vec3 uMouse;
    uniform float uPointSize;
    uniform int uMode;
    
    attribute float aRandom;
    attribute float aSize;
    
    varying vec2 vUv;
    varying vec3 vColor;
    varying float vAlpha;

    // Simplex noise function
    vec3 permute(vec3 x) { return mod(((x*34.0)+1.0)*x, 289.0); }
    float snoise(vec2 v){
      const vec4 C = vec4(0.211324865405187, 0.366025403784439,
               -0.577350269189626, 0.024390243902439);
      vec2 i  = floor(v + dot(v, C.yy) );
      vec2 x0 = v -   i + dot(i, C.xx);
      vec2 i1;
      i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
      vec4 x12 = x0.xyxy + C.xxzz;
      x12.xy -= i1;
      i = mod(i, 289.0);
      vec3 p = permute( permute( i.y + vec3(0.0, i1.y, 1.0 ))
      + i.x + vec3(0.0, i1.x, 1.0 ));
      vec3 m = max(0.5 - vec3(dot(x0,x0), dot(x12.xy,x12.xy), dot(x12.zw,x12.zw)), 0.0);
      m = m*m ;
      m = m*m ;
      vec3 x = 2.0 * fract(p * C.www) - 1.0;
      vec3 h = abs(x) - 0.5;
      vec3 ox = floor(x + 0.5);
      vec3 a0 = x - ox;
      m *= 1.79284291400159 - 0.85373472095314 * ( a0*a0 + h*h );
      vec3 g;
      g.x  = a0.x  * x0.x  + h.x  * x0.y;
      g.yz = a0.yz * x12.xz + h.yz * x12.yw;
      return 130.0 * dot(m, g);
    }

    // Curl noise for plasma
    vec3 snoiseVec3( vec3 x ){
      float s  = snoise(vec2( x ));
      float s1 = snoise(vec2( x.y - 19.1 , x.z + 33.4 ));
      float s2 = snoise(vec2( x.z + 44.5 , x.x - 89.9 ));
      return vec3( s , s1 , s2 );
    }

    vec3 curlNoise( vec3 p ){
      const float e = .1;
      vec3 dx = vec3( e   , 0.0 , 0.0 );
      vec3 dy = vec3( 0.0 , e   , 0.0 );
      vec3 dz = vec3( 0.0 , 0.0 , e   );

      vec3 p_x0 = snoiseVec3( p - dx );
      vec3 p_x1 = snoiseVec3( p + dx );
      vec3 p_y0 = snoiseVec3( p - dy );
      vec3 p_y1 = snoiseVec3( p + dy );
      vec3 p_z0 = snoiseVec3( p - dz );
      vec3 p_z1 = snoiseVec3( p + dz );

      float x = p_y1.z - p_y0.z - p_z1.y + p_z0.y;
      float y = p_z1.x - p_z0.x - p_x1.z + p_x0.z;
      float z = p_x1.y - p_x0.y - p_y1.x + p_y0.x;

      const float divisor = 1.0 / ( 2.0 * e );
      return normalize( vec3( x , y , z ) * divisor );
    }

    void main() {
      vUv = uv;
      vec3 pos = position;
      
      // Calculate transition intensity based on progress (0 -> 1 -> 0 curve)
      float activation = sin(uProgress * 3.14159);
      
      // Mouse Interaction
      float dist = distance(uMouse.xy, pos.xy);
      float mouseArea = 1.2; // Wider interaction area (Smooth)
      float mouseRepel = smoothstep(mouseArea, 0.0, dist);
      
      // === MODE 0: LIQUID (Smooth Sine Waves) ===
      if (uMode == 0) {
        float wave = sin(pos.x * 1.5 + uTime * 0.8) * 0.1 + cos(pos.y * 2.5 + uTime * 0.8) * 0.1; 
        pos.z += wave * 0.15; 
        
        // Add random micro-jitter to break grid lines
        pos.x += (aRandom - 0.5) * 0.015;
        pos.y += (aRandom - 0.5) * 0.015;

        // Liquid mouse ripple
        pos.z += mouseRepel * 0.4; 
      }
      
      // === MODE 1: CYBER PLASMA (Curl Noise + Jitter) ===
    else if (uMode == 1) {
      vec3 noise = curlNoise(vec3(pos.x * 0.3, pos.y * 0.3, uTime * 0.05)); // Slowed down from 0.1
      pos += noise * 0.1; 
      pos.z += snoise(pos.xy + uTime * 0.5) * 0.15; // Slowed down from 1.0
      
      // Electric jitter (smoother)
      pos.x += (aRandom - 0.5) * 0.01 * sin(uTime * 1.5); // Slowed down from 3.0
      
      pos.z += mouseRepel * 0.8; 
    }
      
      // === MODE 2: CRYSTAL PRISM (Angular + Sharp) ===
      else if (uMode == 2) {
        // Geometric displacement - Reduced for visibility
        float geo = step(0.6, sin(pos.x * 2.0 + pos.y * 2.0 + uTime * 0.3)); 
        pos.z += geo * 0.05; 
        
        // Micro-shatter to break grid
        pos.x += (step(0.5, aRandom) - 0.5) * 0.01; 
        pos.y += (step(0.5, aRandom) - 0.5) * 0.01;
        
        // Shatter effect on mouse
        pos.z += mouseRepel * 0.5 * aRandom; 
        pos.x += (pos.x - uMouse.x) * mouseRepel * 0.3;
        pos.y += (pos.y - uMouse.y) * mouseRepel * 0.3;
      }
      
      // === MODE 3: ETHEREAL LIQUID WATER (Premium Smooth) ===
    else if (uMode == 3) {
      // Gentle ocean swell (Low freq, high amplitude)
      float swell = sin(pos.x * 0.5 + uTime * 0.3) * 0.2 + cos(pos.y * 0.4 + uTime * 0.2) * 0.2;
      pos.z += swell * 0.5;
      
      // Surface ripples (High freq, low amplitude)
      float ripples = sin(pos.x * 5.0 + uTime * 1.5) * 0.02 + cos(pos.y * 4.0 + uTime * 1.5) * 0.02;
      pos.z += ripples;
      
      // Water flow drift (Optimized: Replaced Curl Noise with cheap Trig sums)
      float flowX = sin(uTime * 0.1 + pos.y * 0.5) * 0.1;
      float flowY = cos(uTime * 0.1 + pos.x * 0.5) * 0.1;
      pos.x += flowX;
      pos.y += flowY;

      // Mouse Wake Effect
      float wake = smoothstep(1.5, 0.0, dist);
      pos.z += wake * 0.4 * sin(dist * 10.0 - uTime * 5.0); // Ripple wave around mouse
    }
    
    // === TRANSITION: ELEGANT WAVE FLOW (Lightweight) ===
    if (activation > 0.001) {
      // Wave moving across X axis
      // uProgress goes 0 -> 1. We map this to a wave traveling from -1 to 1 in UV space.
      
      // Map activation (bell curve 0->1->0) to linear progress if possible, 
      // but here we use activation as the intensity and uTime or a uniform for position.
      // Since we only have activation (0->1->0), we'll create a "Pulse" that travels radially.
      
      // Elegant Wave:
      float wavePos = (activation * 4.0) - 2.0; // Moves roughly -2 to +2 relative
      // Actually activation is 0->1->0, so it pulses.
      
      // Let's use a simple Z-lift wave based on activation intensity + slight twist
      
      // 1. Uniform Lift (Breathing)
      float lift = activation * 1.5;
      
      // 2. Wave Ripple
      float ripple = sin(pos.x * 5.0 + uTime * 5.0) * activation * 0.2;
      pos.z += lift + ripple;
      
      // 3. Lateral Slide (Subtle)
      pos.x += sin(uTime * 2.0) * activation * 0.1;
      
      // 4. Scale up slightly
      pos.x *= 1.0 + activation * 0.2;
      pos.y *= 1.0 + activation * 0.2;
    }

    // Particle Size Calculation
    float baseSize = uPointSize;
    if (uMode == 1) baseSize *= 1.0; // Normalized
    if (uMode == 3) baseSize *= 1.2; // Reduced multiplier
    
    gl_PointSize = baseSize * (1.0 + mouseRepel * 0.5) * (1.0 - pos.z * 0.02); // Reduced Z scaling
    
    // Pass alpha to fragment
    vAlpha = 1.0 - smoothstep(4.0, 8.0, pos.z); // Adjusted fade range
    
    gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
  }
`,
  // Fragment Shader
  `
  uniform sampler2D uTexture1;
  uniform sampler2D uTexture2;
  uniform float uProgress;
  uniform int uMode;
  uniform float uTime;
  
  varying vec2 vUv;
  varying float vAlpha;

  void main() {
    // Sample both textures
    vec4 tex1 = texture2D(uTexture1, vUv);
    vec4 tex2 = texture2D(uTexture2, vUv);
    
    // Mix colors based on progress
    // Buttery smooth cross-dissolve over entire duration
    float mixStep = smoothstep(0.0, 1.0, uProgress); 
    vec4 finalColor = mix(tex1, tex2, mixStep);
    
    // Circle shape for particles
    vec2 cxy = 2.0 * gl_PointCoord - 1.0;
    float r = dot(cxy, cxy);
    
    // Soften edge for smooth look
    float alpha = 1.0 - smoothstep(0.0, 1.0, r);
    if (alpha < 0.01) discard;
    finalColor.a *= alpha;
    
    // === MODE SPECIFIC FRAGMENT EFFECTS ===
    
    // 0: LIQUID (Glassy, high alpha)
    if (uMode == 0) {
      float glint = smoothstep(0.9, 0.0, r);
      finalColor.rgb += glint * 0.05; 
    }
    
    // 1: PLASMA (Neon Additive)
    else if (uMode == 1) {
      float core = 1.0 - r;
      finalColor.rgb *= 1.05; 
      finalColor.rgb += vec3(0.02, 0.0, 0.08) * sin(uTime * 3.0); 
    }
    
    // 2: CRYSTAL (Sharp, Chromatic Aberration simulated)
    else if (uMode == 2) {
      // Square particles for crystal look - but smoother edge
      float sqR = max(abs(cxy.x), abs(cxy.y));
      if (sqR > 0.9) discard; 
      finalColor.rgb *= 1.05; 
    }
    
    // 3: LIQUID WATER (Premium Smooth)
    else if (uMode == 3) {
      // Water specular highlights
      float glint = smoothstep(0.8, 0.0, r);
      finalColor.rgb += vec3(0.2, 0.3, 0.5) * glint * 0.5; // Blue-ish tint
      finalColor.a *= 1.2; // Solid visibility
      finalColor.rgb *= 1.2; // Brightness
    }
    
    gl_FragColor = vec4(finalColor.rgb, finalColor.a * vAlpha);
  }
`,
)

extend({ PremiumParticleMaterial })

// ==================================================================================
// 3D COMPONENT
// ==================================================================================

interface SceneProps {
  activeIndex: number
  nextIndex: number
  targetProgress: number
  mode: number
  images: string[]
}

function ParticleScene({ activeIndex, nextIndex, targetProgress, mode, images }: SceneProps) {
  const meshRef = useRef<THREE.Points>(null)
  const materialRef = useRef<any>(null)
  const lastActiveIndex = useRef(activeIndex)

  const allTextures = useTexture(images)

  // Geometry: High density grid
  const { geometry } = useMemo(() => {
    const width = 8
    const height = 5
    // Higher density for smoother, non-grid look
    const segmentsW = 512
    const segmentsH = 320

    const geo = new THREE.PlaneGeometry(width, height, segmentsW, segmentsH)
    const count = geo.attributes.position.count

    const randoms = new Float32Array(count)
    const sizes = new Float32Array(count)

    for (let i = 0; i < count; i++) {
      randoms[i] = Math.random()
      sizes[i] = Math.random()
    }

    geo.setAttribute('aRandom', new THREE.BufferAttribute(randoms, 1))
    geo.setAttribute('aSize', new THREE.BufferAttribute(sizes, 1))

    return { geometry: geo, count }
  }, [])

  useFrame((state) => {
    if (materialRef.current) {
      materialRef.current.uTime = state.clock.elapsedTime

      // Detect index change to snap progress and prevent double-blink
      if (lastActiveIndex.current !== activeIndex) {
        materialRef.current.uProgress = 0
        lastActiveIndex.current = activeIndex
      } else {
        // Smooth lerp for progress
        materialRef.current.uProgress = THREE.MathUtils.lerp(
          materialRef.current.uProgress,
          targetProgress,
          0.05,
        )
      }

      // Update Mode Uniform
      materialRef.current.uMode = mode

      // Mouse interaction
      const mouse = state.mouse
      materialRef.current.uMouse.lerp(new THREE.Vector3(mouse.x * 5, mouse.y * 3.5, 0), 0.2) // Increased lerp speed for faster response

      // Dynamic Texture Assignment
      materialRef.current.uTexture1 = allTextures[activeIndex]
      materialRef.current.uTexture2 = allTextures[nextIndex]
    }
  })

  // No useEffect needed for texture assignment anymore

  return (
    <Center>
      <Float speed={1.5} rotationIntensity={0.1} floatIntensity={0.2}>
        <points ref={meshRef} geometry={geometry}>
          {/* @ts-ignore */}
          <premiumParticleMaterial
            ref={materialRef}
            uTexture1={allTextures[activeIndex]}
            uTexture2={allTextures[nextIndex]}
            transparent={true}
            depthWrite={false}
            blending={THREE.NormalBlending}
            uPointSize={5.0} // Increased size for visibility
          />
        </points>
      </Float>
    </Center>
  )
}

// ==================================================================================
// MAIN COMPONENT
// ==================================================================================

const DEFAULT_IMAGES = [
  'https://images.unsplash.com/photo-1553440569-bcc63803a83d?q=80&w=1125&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', // Ferrari
  'https://images.unsplash.com/photo-1517994112540-009c47ea476b?q=80&w=1206&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', // F1
  'https://images.unsplash.com/photo-1618863099278-75222d755814?q=80&w=1170&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', // G-Wagon
  'https://images.unsplash.com/photo-1647200527826-e9395f4683ea?q=80&w=1170&auto=format&fit=crop', // Rolls Royce
]

useTexture.preload(DEFAULT_IMAGES)

const SLIDES = [
  {
    title: 'Liquid Metal',
    subtitle: 'Premium Flow',
    mode: 0,
    icon: Droplets,
    desc: 'Fluid dynamics simulation with refractive index and smooth sine wave distortion.',
  },
  {
    title: 'Cyber Plasma',
    subtitle: 'High Energy Field',
    mode: 1,
    icon: Zap,
    desc: 'Electric curl noise particles with additive neon blending and jitter.',
  },
  {
    title: 'Prism Crystal',
    subtitle: 'Geometric Refraction',
    mode: 2,
    icon: Sparkles,
    desc: 'Sharp angular displacement with chromatic aberration and crystalline structures.',
  },
  {
    title: 'Liquid Ethereal',
    subtitle: 'Premium Water',
    mode: 3,
    icon: Droplets,
    desc: 'Hyper-realistic smooth water simulation with dynamic ripples and specular highlights.',
  },
]

class ErrorBoundary extends React.Component<
  { fallback: React.ReactNode; children: React.ReactNode },
  { hasError: boolean }
> {
  constructor(props: any) {
    super(props)
    this.state = { hasError: false }
  }

  static getDerivedStateFromError() {
    return { hasError: true }
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback
    }
    return this.props.children
  }
}

interface ParticleImageGalleryProps {
  className?: string
  style?: React.CSSProperties
  images?: string[]
}

export function ParticleImageGallery({
  className,
  style,
  images = DEFAULT_IMAGES,
}: ParticleImageGalleryProps) {
  // Optimized: Reduced from 128 to 100 for better performance on lower-end devices (10k particles vs 16k)
  const size = 100
  const dpr = typeof window !== 'undefined' ? Math.min(window.devicePixelRatio, 2) : 1

  const [currentIndex, setCurrentIndex] = useState(0)
  const [nextIndex, setNextIndex] = useState(0)
  const [isTransitioning, setIsTransitioning] = useState(false)
  const [targetProgress, setTargetProgress] = useState(0)

  // Use images prop if provided, otherwise fallback to DEFAULT_IMAGES
  const activeImages = images && images.length > 0 ? images : DEFAULT_IMAGES

  const handleNext = () => {
    if (isTransitioning) return
    setIsTransitioning(true)

    // Set next index
    const next = (currentIndex + 1) % activeImages.length
    setNextIndex(next)

    // Start transition
    setTargetProgress(1)

    // Complete transition
    setTimeout(() => {
      setCurrentIndex(next)
      setTargetProgress(0)
      setIsTransitioning(false)
    }, 1200)
  }

  const handlePrev = () => {
    if (isTransitioning) return
    setIsTransitioning(true)

    const prev = (currentIndex - 1 + activeImages.length) % activeImages.length
    setNextIndex(prev)

    setTargetProgress(1)

    setTimeout(() => {
      setCurrentIndex(prev)
      setTargetProgress(0)
      setIsTransitioning(false)
    }, 1200)
  }

  const displayIndex = isTransitioning ? nextIndex : currentIndex
  const CurrentIcon = SLIDES[displayIndex].icon

  return (
    <div
      className={cn(
        'relative w-full h-[500px] bg-[#050505] overflow-hidden rounded-[24px] font-sans border border-white/10 shadow-2xl',
        className,
      )}
      style={style}
    >
      {/* Inner Dotted Border Frame - Premium Touch */}
      <div className="absolute inset-3 z-50 pointer-events-none rounded-[18px] border-2 border-dotted border-white/20" />

      {/* Second Inner Dotted Border - Double Dotted Effect */}
      <div className="absolute inset-5 z-50 pointer-events-none rounded-[16px] border border-dotted border-white/10" />

      {/* 3D Canvas - Confined to inner border to prevent leaking */}
      <div className="absolute inset-3 z-0 rounded-[18px] overflow-hidden">
        <Canvas camera={{ position: [0, 0, 5], fov: 45 }}>
          <color attach="background" args={['#050505']} />
          <ErrorBoundary fallback={null}>
            <Suspense fallback={null}>
              <ParticleScene
                activeIndex={currentIndex}
                nextIndex={nextIndex}
                targetProgress={targetProgress}
                mode={SLIDES[displayIndex].mode}
                images={activeImages}
              />
            </Suspense>
          </ErrorBoundary>
          <OrbitControls
            enableZoom={false}
            enablePan={false}
            maxPolarAngle={Math.PI / 1.6}
            minPolarAngle={Math.PI / 2.5}
            maxAzimuthAngle={Math.PI / 4}
            minAzimuthAngle={-Math.PI / 4}
          />
        </Canvas>
      </div>

      {/* Cinematic Vignette */}
      <div className="absolute inset-0 pointer-events-none bg-[radial-gradient(circle_at_center,transparent_0%,#000000_120%)] opacity-80" />

      {/* UI Overlay */}
      <div className="absolute inset-0 z-10 pointer-events-none flex flex-col justify-between p-6 md:p-10">
        {/* Header */}
        <div className="flex justify-between items-start">
          <div className="flex items-center gap-3">
            <div className="w-8 h-8 rounded-full bg-white/5 backdrop-blur-xl border border-white/10 flex items-center justify-center">
              <CurrentIcon className="text-white/80 w-4 h-4" />
            </div>
            <div className="flex flex-col">
              <span className="text-[10px] font-bold tracking-[0.2em] text-white/40 uppercase">
                Shader Mode
              </span>
              <span className="text-xs font-medium text-white/90">
                {SLIDES[displayIndex].title}
              </span>
            </div>
          </div>

          <div className="bg-white/5 backdrop-blur-md border border-white/10 rounded-full px-3 py-1.5 flex items-center gap-2 transition-all hover:bg-white/10 pointer-events-auto cursor-pointer">
            <Maximize2 size={12} className="text-white/60" />
            <span className="text-[10px] text-white/60 uppercase tracking-wider font-semibold">
              Fullscreen
            </span>
          </div>
        </div>

        {/* Footer / Controls */}
        <div className="flex flex-col md:flex-row items-end md:items-end justify-between gap-8">
          {/* Text Content */}
          <div className="pointer-events-auto max-w-lg">
            <AnimatePresence mode="wait">
              <motion.div
                key={displayIndex}
                initial={{ opacity: 0, y: 20, filter: 'blur(5px)' }}
                animate={{ opacity: 1, y: 0, filter: 'blur(0px)' }}
                exit={{ opacity: 0, y: -20, filter: 'blur(5px)' }}
                transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
                className="space-y-3"
              >
                <div className="flex items-center gap-3 mb-1">
                  <div className="h-[1px] w-8 bg-gradient-to-r from-cyan-500 to-transparent" />
                  <span className="text-cyan-400 text-xs font-mono tracking-widest uppercase">
                    0{displayIndex + 1}{SLIDES[displayIndex].title}
                  </span>
                </div>

                <h2 className="text-4xl md:text-5xl font-bold text-white tracking-tighter leading-[0.9]">
                  {SLIDES[displayIndex].subtitle}
                </h2>

                <p className="text-sm text-zinc-400 leading-relaxed border-l-2 border-white/10 pl-4 py-1">
                  {SLIDES[displayIndex].desc}
                </p>
              </motion.div>
            </AnimatePresence>
          </div>

          {/* Navigation Buttons */}
          <div className="flex items-center gap-4 pointer-events-auto">
            <div className="flex gap-2">
              {activeImages.map((_, idx) => (
                <div
                  key={idx}
                  className={`h-1 rounded-full transition-all duration-500 ${idx === displayIndex ? 'w-6 bg-white' : 'w-1.5 bg-white/20'}`}
                />
              ))}
            </div>

            <div className="flex items-center gap-2">
              <button
                onClick={handlePrev}
                disabled={isTransitioning}
                className="group relative w-10 h-10 rounded-full border border-white/10 bg-white/5 hover:bg-white/10 backdrop-blur-md flex items-center justify-center transition-all active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed"
              >
                <ChevronLeft className="text-white w-5 h-5 group-hover:-translate-x-0.5 transition-transform" />
              </button>

              <button
                onClick={handleNext}
                disabled={isTransitioning}
                className="group relative w-10 h-10 rounded-full border border-white/10 bg-white/5 hover:bg-white/10 backdrop-blur-md flex items-center justify-center transition-all active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed"
              >
                <ChevronRight className="text-white w-5 h-5 group-hover:translate-x-0.5 transition-transform" />
              </button>
            </div>
          </div>
        </div>
      </div>
    </div>
  )
}

shaders/fragment.ts

shaders/fragment.ts
export const fragmentShader = `
uniform float uTime;
uniform float uProgress;
uniform sampler2D uTexture1;
uniform sampler2D uTexture2;
uniform int uMode;

varying vec2 vUv;
varying vec3 vColor;
varying float vAlpha;

void main() {
  vec2 uv = vUv;
  
  // Point Sprite Logic (Circle)
  vec2 cxy = 2.0 * gl_PointCoord - 1.0;
  float r = dot(cxy, cxy);
  if (r > 1.0) discard;
  
  // Soft edge
  float alpha = 1.0 - smoothstep(0.8, 1.0, r);
  
  // Sample Textures
  vec4 tex1 = texture2D(uTexture1, uv);
  vec4 tex2 = texture2D(uTexture2, uv);
  
  // Mix based on progress
  // Non-linear mix for premium feel
  float mixFactor = smoothstep(0.2, 0.8, uProgress);
  vec4 finalColor = mix(tex1, tex2, mixFactor);
  
  // Apply alpha from point shape
  finalColor.a *= alpha;
  
  // === MODE SPECIFIC FRAGMENT EFFECTS ===
  
  // 0: LIQUID (Glassy)
  if (uMode == 0) {
    float glint = smoothstep(0.9, 0.0, r);
    finalColor.rgb += glint * 0.05; 
  }
  
  // 1: PLASMA (Neon)
  else if (uMode == 1) {
    finalColor.rgb *= 1.1; 
    finalColor.rgb += vec3(0.05, 0.0, 0.1) * sin(uTime * 3.0); 
  }
  
  // 2: CRYSTAL (Sharp)
  else if (uMode == 2) {
    // Square crop for crystal
    float sqR = max(abs(cxy.x), abs(cxy.y));
    if (sqR > 0.8) discard; 
    finalColor.rgb *= 1.1; 
  }
  
  // 3: LIQUID WATER (Premium Smooth - NEW)
  else if (uMode == 3) {
    // Specular highlight offset
    float glint = smoothstep(0.7, 0.0, length(cxy - vec2(-0.2, 0.2)));
    finalColor.rgb += vec3(0.4, 0.5, 0.7) * glint * 0.4; // Blue-ish tint
    
    // Water caustic approximation (moving brightness)
    float caustic = sin(uv.x * 10.0 + uTime) * sin(uv.y * 10.0 + uTime) * 0.1;
    finalColor.rgb += caustic;
    
    finalColor.a *= 1.2;
    finalColor.rgb *= 1.1;
  }
  
  gl_FragColor = vec4(finalColor.rgb, finalColor.a * vAlpha);
}
`;

shaders/vertex.ts

shaders/vertex.ts
export const vertexShader = `
uniform float uTime;
uniform float uProgress;
uniform vec3 uMouse;
uniform float uPointSize;
uniform int uMode;

attribute float aRandom;
attribute float aSize;

varying vec2 vUv;
varying vec3 vColor;
varying float vAlpha;

// --- SIMPLEX NOISE ---
vec3 permute(vec3 x) { return mod(((x*34.0)+1.0)*x, 289.0); }
float snoise(vec2 v){
  const vec4 C = vec4(0.211324865405187, 0.366025403784439,
           -0.577350269189626, 0.024390243902439);
  vec2 i  = floor(v + dot(v, C.yy) );
  vec2 x0 = v -   i + dot(i, C.xx);
  vec2 i1;
  i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
  vec4 x12 = x0.xyxy + C.xxzz;
  x12.xy -= i1;
  i = mod(i, 289.0);
  vec3 p = permute( permute( i.y + vec3(0.0, i1.y, 1.0 ))
  + i.x + vec3(0.0, i1.x, 1.0 ));
  vec3 m = max(0.5 - vec3(dot(x0,x0), dot(x12.xy,x12.xy), dot(x12.zw,x12.zw)), 0.0);
  m = m*m ;
  m = m*m ;
  vec3 x = 2.0 * fract(p * C.www) - 1.0;
  vec3 h = abs(x) - 0.5;
  vec3 ox = floor(x + 0.5);
  vec3 a0 = x - ox;
  m *= 1.79284291400159 - 0.85373472095314 * ( a0*a0 + h*h );
  vec3 g;
  g.x  = a0.x  * x0.x  + h.x  * x0.y;
  g.yz = a0.yz * x12.xz + h.yz * x12.yw;
  return 130.0 * dot(m, g);
}
// ---------------------

void main() {
  vUv = uv;
  vec3 pos = position;
  
  // Base noise movement
  float noiseVal = snoise(vec2(pos.x * 0.5 + uTime * 0.1, pos.y * 0.5));
  
  // Transition Logic
  // When uProgress goes 0->1, we distort particles
  float trans = smoothstep(0.0, 1.0, uProgress);
  float transPeak = sin(trans * 3.14159); // 0 -> 1 -> 0
  
  // --- MODE SPECIFIC MOTION ---
  
  // 0: LIQUID (Flowy)
  if (uMode == 0) {
      pos.z += noiseVal * 0.5 * transPeak;
      pos.x += sin(uTime * 0.5 + pos.y) * 0.2 * transPeak;
  }
  
  // 1: PLASMA (Jittery)
  else if (uMode == 1) {
      pos.z += noiseVal * 1.0 * transPeak;
      pos.x += (aRandom - 0.5) * 0.5 * transPeak; // Scatter
      pos.y += (aSize - 0.5) * 0.5 * transPeak;
  }
  
  // 2: CRYSTAL (Geometric)
  else if (uMode == 2) {
      float stepX = floor(pos.x * 2.0) / 2.0;
      pos.z += sin(stepX * 10.0 + uTime) * 0.5 * transPeak;
  }
  
  // 3: LIQUID WATER (Premium Smooth - NEW)
  else if (uMode == 3) {
      // Gentle wave drift
      float wave = sin(pos.x * 2.0 + uTime * 0.5) * 0.2;
      float wave2 = cos(pos.y * 2.0 + uTime * 0.4) * 0.2;
      
      // Lift effect during transition
      pos.z += (wave + wave2) * transPeak * 2.0;
      
      // Expansion
      pos.x += pos.x * 0.1 * transPeak;
      pos.y += pos.y * 0.1 * transPeak;
      
      // Mouse interaction (Repel)
      float dist = distance(pos.xy, uMouse.xy);
      float mouseForce = smoothstep(2.0, 0.0, dist);
      pos.z += mouseForce * 1.0;
  }
  
  gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
  gl_PointSize = uPointSize * aSize * (1.0 + transPeak * 0.5);
  
  // Depth attenuation
  float depth = 1.0 / gl_Position.w;
  gl_PointSize *= (depth * 10.0);
  
  // Fade alpha at edges or during transition
  vAlpha = 1.0 - smoothstep(0.0, 0.5, abs(trans - 0.5)); 
  vAlpha = 1.0; // Keep opaque for now, let fragment handle mix
  
  vColor = vec3(1.0);
}
`;

Dependencies

  • three: latest
  • @react-three/fiber: latest
  • @react-three/drei: latest
  • framer-motion: latest
  • lucide-react: latest

Props

Component property reference.

NameTypeDefaultDescription
imagesstring[]DEFAULT_IMAGESArray of image URLs to display in the gallery.
classNamestringundefinedAdditional CSS classes.
styleReact.CSSPropertiesundefinedInline styles.
Context Worth Keeping In Orbit

Most components here are inspired by outstanding libraries and creators in the ecosystem. I don’t claim to be the original author — this is my space for learning, rebuilding, and understanding great work at a deeper level.

I’m still a student of the craft, constantly studying the best and translating what I learn through my own perspective. Every piece reflects curiosity, respect for the community, and small creative touches that feel true to me.

I’ve done my best to credit inspirations properly. If anything is missing or inaccurate, I truly appreciate a message so it can be corrected with care.