Milky Way
A WebGL starfield background that adds scale and atmospheric depth to AI, space-adjacent, developer-tool, and premium brand heroes.

Overview
Milky Way gives the hero scale without weight: a starfield as atmosphere, not spectacle.
Use it for AI, space-adjacent, developer-tool, and premium brand heroes that need atmosphere behind a short claim. The page job is scale: the field should make the statement feel larger without adding visual noise.
The production risk is needless particles. If a static starfield gives the same feeling, ship the still on lower tiers.
Install
npx hyperiux add milky-wayUsage
import MilkyWay from "@/components/milkyway"
const page = () => {
return (
<MilkyWay />
)
}
export default page
Component Code
'use client'
import React, { useRef, useMemo, useEffect, useLayoutEffect, useState } from 'react'
import { Canvas, useFrame, useThree } from '@react-three/fiber'
import { Center } from '@react-three/drei'
import * as THREE from 'three'
import { degToRad } from 'three/src/math/MathUtils'
import { EffectComposer, Vignette } from '@react-three/postprocessing'
import { ArrowRight, Stars } from 'lucide-react'
import Link from 'next/link'
import EdgeBlurEffect from './EdgeBlurEffect'
import CircularText from './CircularText'
const CFG = {
texSize: 400,
maxRadius: 3.5,
holeRadius: 1.2,
holeEdgeBand: 1.5,
arms: 1,
spiralTightness: 10.75,
armWidth: 0.38,
diskHeight: 0.5,
coreRadius: 0.22,
coreHeight: 0.28,
seed: 91,
colorParticleRatio: 0.01,
baseSize: 8,
sparkleSize: 12.0,
twinkleSpeed: 4.5,
colorLevels: {
core: 1.15,
mid: 1.0,
outer: 0.9,
sparkle: 1.1,
},
colors: {
core: [0.96, 0.96, 1.0],
mid: [1.0, 0.9, 0.68],
outer: [0.88, 0.36, 0.07],
sparkleA: [0.4, 0.78, 1.0],
sparkleB: [0.25, 0.95, 0.88],
sparkleC: [1.0, 0.85, 0.25],
sparkleD: [1.0, 0.22, 0.06],
sparkleE: [0.65, 0.3, 1.0],
},
}
const SMOKE_CFG = {
texSize: 50,
maxRadius: 3.5,
holeRadius: 1.2,
holeEdgeBand: 1.5,
arms: 2,
spiralTightness: 10.75,
armWidth: 0.9,
diskHeight: 0.18,
orbSpeedBase: 0.2,
noiseScale: 0.0,
noiseStrength: 0.01,
noiseSpeed: 0.0,
tangentFlow: 0.3,
armRestore: 1.7,
radialRestore: 0.8,
particleSize: 92.0,
opacity: 0.05,
colorLevels: {
core: 1.25,
cyan: 1.05,
magenta: 1.1,
violet: 0.95,
outer: 0.75,
},
colors: {
core: [0.98, 0.97, 1.0],
cyan: [0.32, 0.84, 1.0],
magenta: [0.96, 0.42, 1.0],
violet: [0.52, 0.38, 0.95],
outer: [0.18, 0.24, 0.58],
},
seed: 7777,
}
// SHADERS (do not remove/modify except for whitespace)
const SIM_FRAG = /* glsl */`
precision highp float;
uniform sampler2D uPosition;
uniform sampler2D uData;
uniform float uDelta;
uniform float uTime;
varying vec2 vUv;
void main(){
vec4 pos = texture2D(uPosition, vUv);
vec4 data = texture2D(uData, vUv);
vec3 p = pos.xyz;
float phase = pos.w;
float radiusFrac = data.x;
float seed = data.y;
float orbSpeed = data.z;
float r = length(p.xy) + 0.0001;
float vTan = orbSpeed * (r / (r + 0.28));
float omega = vTan / r;
float dAngle = omega * uDelta * .2;
float cosA = cos(dAngle);
float sinA = sin(dAngle);
float nx = p.x * cosA - p.y * sinA;
float ny = p.x * sinA + p.y * cosA;
p.x = nx;
p.y = ny;
p.z += sin(uTime * 0.2 + seed * 6.28318) * 0.0001;
phase = mod(phase + uDelta * (0.018 + seed * 0.008), 1.0);
gl_FragColor = vec4(p, phase);
}
`
const PARTICLE_VERT = /* glsl */`
precision highp float;
uniform sampler2D uPosition;
uniform float uPixelRatio;
attribute vec2 aRef;
attribute float aRadiusFrac;
attribute float aSeed;
attribute float aColor;
varying float vRadiusFrac;
varying float vPhase;
varying float vSeed;
varying float vColor;
void main(){
vec4 posData = texture2D(uPosition, aRef);
vec3 pos = posData.xyz;
vPhase = posData.w;
vRadiusFrac = aRadiusFrac;
vSeed = aSeed;
vColor = aColor;
vec4 mvPos = modelViewMatrix * vec4(pos, 1.0);
float depth = -mvPos.z;
float isSpecial = step(${(1.0 - CFG.colorParticleRatio).toFixed(3)}, aColor);
float sizeFactor = pow(1.0 - aRadiusFrac, 1.3);
float normalSz = mix(0.5, ${CFG.baseSize.toFixed(1)}, sizeFactor) * (0.7 + aSeed * 0.5);
float specialSz = mix(
${CFG.sparkleSize.toFixed(1)} * 0.6,
${CFG.sparkleSize.toFixed(1)},
aSeed
);
float sz = mix(normalSz, specialSz, isSpecial);
sz *= (420.0 / max(depth, 0.1)) * uPixelRatio;
float maxSize = mix(
(${CFG.baseSize.toFixed(1)} * (2.0 + aSeed * 1.0)),
(${CFG.sparkleSize.toFixed(1)} * (2.0 + aSeed * 1.0)),
isSpecial
);
gl_PointSize = clamp(sz, 0.4, maxSize);
gl_Position = projectionMatrix * mvPos;
}
`
const PARTICLE_FRAG = /* glsl */`
precision highp float;
varying float vRadiusFrac;
varying float vPhase;
varying float vSeed;
varying float vColor;
void main(){
vec2 uv = gl_PointCoord - 0.5;
float r = length(uv) * 2.0;
if(r > 1.0) discard;
float cp = exp(-r * r * 14.0);
float halo = exp(-r * r * 3.0) * 0.30;
float disc = clamp(cp + halo, 0.0, 1.0);
float dispersion = pow(1.0 - vRadiusFrac, 1.05);
float coreBulge = smoothstep(0.22, 0.0, vRadiusFrac) * 0.55;
float intensity = clamp(dispersion + coreBulge, 0.0, 1.0);
float tRate = 2.5 + vSeed * ${CFG.twinkleSpeed.toFixed(1)};
float twinkle = 0.78 + 0.22 * sin(vPhase * 6.28318 * tRate + vSeed * 17.3);
intensity *= twinkle;
float isSpecial = step(${(1.0 - CFG.colorParticleRatio).toFixed(3)}, vColor);
vec3 nCore = vec3(${CFG.colors.core[0].toFixed(2)}, ${CFG.colors.core[1].toFixed(2)}, ${CFG.colors.core[2].toFixed(2)}) * ${CFG.colorLevels.core.toFixed(2)};
vec3 nMid = vec3(${CFG.colors.mid[0].toFixed(2)}, ${CFG.colors.mid[1].toFixed(2)}, ${CFG.colors.mid[2].toFixed(2)}) * ${CFG.colorLevels.mid.toFixed(2)};
vec3 nOuter = vec3(${CFG.colors.outer[0].toFixed(2)}, ${CFG.colors.outer[1].toFixed(2)}, ${CFG.colors.outer[2].toFixed(2)}) * ${CFG.colorLevels.outer.toFixed(2)};
vec3 normalCol = mix(nCore, nMid, smoothstep(0.00, 0.42, vRadiusFrac));
normalCol = mix(normalCol, nOuter, smoothstep(0.42, 1.00, vRadiusFrac));
float ss = fract((vColor - ${(1.0 - CFG.colorParticleRatio).toFixed(3)}) / ${CFG.colorParticleRatio.toFixed(3)} * 5.0) * 5.0;
vec3 s0 = vec3(${CFG.colors.sparkleA[0].toFixed(2)}, ${CFG.colors.sparkleA[1].toFixed(2)}, ${CFG.colors.sparkleA[2].toFixed(2)}) * ${CFG.colorLevels.sparkle.toFixed(2)};
vec3 s1 = vec3(${CFG.colors.sparkleB[0].toFixed(2)}, ${CFG.colors.sparkleB[1].toFixed(2)}, ${CFG.colors.sparkleB[2].toFixed(2)}) * ${CFG.colorLevels.sparkle.toFixed(2)};
vec3 s2 = vec3(${CFG.colors.sparkleC[0].toFixed(2)}, ${CFG.colors.sparkleC[1].toFixed(2)}, ${CFG.colors.sparkleC[2].toFixed(2)}) * ${CFG.colorLevels.sparkle.toFixed(2)};
vec3 s3 = vec3(${CFG.colors.sparkleD[0].toFixed(2)}, ${CFG.colors.sparkleD[1].toFixed(2)}, ${CFG.colors.sparkleD[2].toFixed(2)}) * ${CFG.colorLevels.sparkle.toFixed(2)};
vec3 s4 = vec3(${CFG.colors.sparkleE[0].toFixed(2)}, ${CFG.colors.sparkleE[1].toFixed(2)}, ${CFG.colors.sparkleE[2].toFixed(2)}) * ${CFG.colorLevels.sparkle.toFixed(2)};
vec3 specialCol;
if(ss < 1.0) specialCol = mix(s0, s1, ss);
else if(ss < 2.0) specialCol = mix(s1, s2, ss - 1.0);
else if(ss < 3.0) specialCol = mix(s2, s3, ss - 2.0);
else if(ss < 4.0) specialCol = mix(s3, s4, ss - 3.0);
else specialCol = mix(s4, s0, ss - 4.0);
intensity = mix(intensity, clamp(intensity * 2.5, 0.0, 1.0), isSpecial);
vec3 col = mix(normalCol, specialCol, isSpecial);
float alpha = disc * intensity * 0.90;
gl_FragColor = vec4(col * alpha, alpha);
}
`
const SMOKE_SIM_FRAG = /* glsl */`
precision highp float;
uniform sampler2D uPosition;
uniform sampler2D uData;
uniform float uDelta;
uniform float uTime;
varying vec2 vUv;
// Simplex-style 3D noise (Ashima Arts)
vec3 mod289(vec3 x){ return x - floor(x * (1.0/289.0)) * 289.0; }
vec4 mod289(vec4 x){ return x - floor(x * (1.0/289.0)) * 289.0; }
vec4 permute(vec4 x){ return mod289(((x*34.0)+1.0)*x); }
vec4 taylorInvSqrt(vec4 r){ return 1.79284291400159 - 0.85373472095314 * r; }
float snoise(vec3 v){
const vec2 C = vec2(1.0/6.0, 1.0/3.0);
const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);
vec3 i = floor(v + dot(v, C.yyy));
vec3 x0 = v - i + dot(i, C.xxx);
vec3 g = step(x0.yzx, x0.xyz);
vec3 l = 1.0 - g;
vec3 i1 = min(g.xyz, l.zxy);
vec3 i2 = max(g.xyz, l.zxy);
vec3 x1 = x0 - i1 + C.xxx;
vec3 x2 = x0 - i2 + C.yyy;
vec3 x3 = x0 - D.yyy;
i = mod289(i);
vec4 p = permute(permute(permute(
i.z + vec4(0.0, i1.z, i2.z, 1.0))
+ i.y + vec4(0.0, i1.y, i2.y, 1.0))
+ i.x + vec4(0.0, i1.x, i2.x, 1.0));
float n_ = 0.142857142857;
vec3 ns = n_ * D.wyz - D.xzx;
vec4 j = p - 49.0 * floor(p * ns.z * ns.z);
vec4 x_ = floor(j * ns.z);
vec4 y_ = floor(j - 7.0 * x_);
vec4 x = x_ * ns.x + ns.yyyy;
vec4 y = y_ * ns.x + ns.yyyy;
vec4 h = 1.0 - abs(x) - abs(y);
vec4 b0 = vec4(x.xy, y.xy);
vec4 b1 = vec4(x.zw, y.zw);
vec4 s0 = floor(b0)*2.0 + 1.0;
vec4 s1 = floor(b1)*2.0 + 1.0;
vec4 sh = -step(h, vec4(0.0));
vec4 a0 = b0.xzyw + s0.xzyw*sh.xxyy;
vec4 a1 = b1.xzyw + s1.xzyw*sh.zzww;
vec3 p0 = vec3(a0.xy, h.x);
vec3 p1 = vec3(a0.zw, h.y);
vec3 p2 = vec3(a1.xy, h.z);
vec3 p3 = vec3(a1.zw, h.w);
vec4 norm = taylorInvSqrt(vec4(dot(p0,p0),dot(p1,p1),dot(p2,p2),dot(p3,p3)));
p0 *= norm.x; p1 *= norm.y; p2 *= norm.z; p3 *= norm.w;
vec4 m = max(0.6 - vec4(dot(x0,x0),dot(x1,x1),dot(x2,x2),dot(x3,x3)), 0.0);
m = m * m;
return 42.0 * dot(m*m, vec4(dot(p0,x0),dot(p1,x1),dot(p2,x2),dot(p3,x3)));
}
vec3 curlNoise(vec3 p){
float e = 0.05;
float n1,n2;
vec3 curl;
n1 = snoise(p + vec3(0.0, e, 0.0));
n2 = snoise(p - vec3(0.0, e, 0.0));
float a = (n1 - n2) / (2.0 * e);
n1 = snoise(p + vec3(0.0, 0.0, e));
n2 = snoise(p - vec3(0.0, 0.0, e));
float b = (n1 - n2) / (2.0 * e);
curl.x = a - b;
n1 = snoise(p + vec3(0.0, 0.0, e));
n2 = snoise(p - vec3(0.0, 0.0, e));
a = (n1 - n2) / (2.0 * e);
n1 = snoise(p + vec3(e, 0.0, 0.0));
n2 = snoise(p - vec3(e, 0.0, 0.0));
b = (n1 - n2) / (2.0 * e);
curl.y = a - b;
n1 = snoise(p + vec3(e, 0.0, 0.0));
n2 = snoise(p - vec3(e, 0.0, 0.0));
a = (n1 - n2) / (2.0 * e);
n1 = snoise(p + vec3(0.0, e, 0.0));
n2 = snoise(p - vec3(0.0, e, 0.0));
b = (n1 - n2) / (2.0 * e);
curl.z = a - b;
return curl;
}
void main(){
vec4 pos = texture2D(uPosition, vUv);
vec4 data = texture2D(uData, vUv);
vec3 p = pos.xyz;
float phase = pos.w;
float radiusFrac = data.x;
float seed = data.y;
float orbSpeed = data.z;
float armIdxNorm = data.w;
float r = length(p.xy) + 0.0001;
float vTan = orbSpeed * (r / (r + 0.35));
float omega = vTan / r;
float dAngle = omega * uDelta;
float cosA = cos(dAngle);
float sinA = sin(dAngle);
float nx = p.x * cosA - p.y * sinA;
float ny = p.x * sinA + p.y * cosA;
p.x = nx;
p.y = ny;
float armBase = armIdxNorm * 6.28318;
float targetTheta = armBase + r * ${SMOKE_CFG.spiralTightness.toFixed(2)};
vec2 tangent = normalize(vec2(
cos(targetTheta) - ${SMOKE_CFG.spiralTightness.toFixed(2)} * r * sin(targetTheta),
sin(targetTheta) + ${SMOKE_CFG.spiralTightness.toFixed(2)} * r * cos(targetTheta)
));
p.xy += tangent * ${SMOKE_CFG.tangentFlow.toFixed(2)} * (0.85 + radiusFrac * 0.45) * uDelta;
float currentTheta = atan(p.y, p.x);
float angleDelta = atan(sin(targetTheta - currentTheta), cos(targetTheta - currentTheta));
vec2 radialDir = normalize(p.xy);
vec2 armNormal = vec2(-tangent.y, tangent.x);
p.xy += armNormal * angleDelta * r * ${SMOKE_CFG.armRestore.toFixed(2)} * uDelta;
p.xy += radialDir * ((radiusFrac * ${SMOKE_CFG.maxRadius.toFixed(2)}) - r) * ${SMOKE_CFG.radialRestore.toFixed(2)} * uDelta;
vec3 noiseCoord = p * ${SMOKE_CFG.noiseScale.toFixed(2)} + vec3(uTime * ${SMOKE_CFG.noiseSpeed.toFixed(2)});
vec3 curl = curlNoise(noiseCoord);
p.xy += curl.xy * ${SMOKE_CFG.noiseStrength.toFixed(3)} * uDelta;
p.z += curl.z * ${SMOKE_CFG.noiseStrength.toFixed(3)} * 0.08 * uDelta;
p.z *= 0.975;
p.z += sin(uTime * 0.12 + seed * 6.28318) * 0.00012;
phase = mod(phase + uDelta * (0.012 + seed * 0.006), 1.0);
gl_FragColor = vec4(p, phase);
}
`
const SMOKE_VERT = /* glsl */`
precision highp float;
uniform sampler2D uPosition;
uniform float uPixelRatio;
uniform float uTime;
attribute vec2 aRef;
attribute float aRadiusFrac;
attribute float aSeed;
varying float vRadiusFrac;
varying float vPhase;
varying float vSeed;
void main(){
vec4 posData = texture2D(uPosition, aRef);
vec3 pos = posData.xyz;
vPhase = posData.w;
vRadiusFrac = aRadiusFrac;
vSeed = aSeed;
vec4 mvPos = modelViewMatrix * vec4(pos, 1.0);
float depth = -mvPos.z;
float sizeFactor = mix(0.6, 1.0, 1.0 - aRadiusFrac);
float sz = ${SMOKE_CFG.particleSize.toFixed(1)} * sizeFactor * (0.7 + aSeed * 0.6);
sz *= (420.0 / max(depth, 0.1)) * uPixelRatio;
gl_PointSize = clamp(sz, 2.0, ${SMOKE_CFG.particleSize.toFixed(1)} * 3.0);
gl_Position = projectionMatrix * mvPos;
}
`
const SMOKE_FRAG = /* glsl */`
precision highp float;
varying float vRadiusFrac;
varying float vPhase;
varying float vSeed;
void main(){
vec2 uv = gl_PointCoord - 0.5;
uv.x *= 2.1;
uv.y *= 0.72;
float r = length(uv) * 2.0;
if(r > 1.0) discard;
float core = exp(-dot(uv, uv) * 3.4);
float halo = exp(-dot(uv, uv) * 0.75) * 0.9;
float shape = clamp(core + halo, 0.0, 1.0);
float streak = 0.72 + 0.28 * smoothstep(0.42, 0.0, abs(uv.y));
float radialFade = pow(1.0 - vRadiusFrac, 0.72);
float coreBright = smoothstep(0.32, 0.0, vRadiusFrac) * 0.22;
float intensity = clamp(radialFade + coreBright, 0.0, 1.0);
float flow = 0.88 + 0.12 * sin(vPhase * 6.28318 * 1.0 + vSeed * 8.0);
intensity *= flow * streak;
vec3 cCore = vec3(${SMOKE_CFG.colors.core[0].toFixed(2)}, ${SMOKE_CFG.colors.core[1].toFixed(2)}, ${SMOKE_CFG.colors.core[2].toFixed(2)}) * ${SMOKE_CFG.colorLevels.core.toFixed(2)};
vec3 cCyan = vec3(${SMOKE_CFG.colors.cyan[0].toFixed(2)}, ${SMOKE_CFG.colors.cyan[1].toFixed(2)}, ${SMOKE_CFG.colors.cyan[2].toFixed(2)}) * ${SMOKE_CFG.colorLevels.cyan.toFixed(2)};
vec3 cMagenta = vec3(${SMOKE_CFG.colors.magenta[0].toFixed(2)}, ${SMOKE_CFG.colors.magenta[1].toFixed(2)}, ${SMOKE_CFG.colors.magenta[2].toFixed(2)}) * ${SMOKE_CFG.colorLevels.magenta.toFixed(2)};
vec3 cViolet = vec3(${SMOKE_CFG.colors.violet[0].toFixed(2)}, ${SMOKE_CFG.colors.violet[1].toFixed(2)}, ${SMOKE_CFG.colors.violet[2].toFixed(2)}) * ${SMOKE_CFG.colorLevels.violet.toFixed(2)};
vec3 cOuter = vec3(${SMOKE_CFG.colors.outer[0].toFixed(2)}, ${SMOKE_CFG.colors.outer[1].toFixed(2)}, ${SMOKE_CFG.colors.outer[2].toFixed(2)}) * ${SMOKE_CFG.colorLevels.outer.toFixed(2)};
float colorNoise = fract(vSeed * 13.371 + vRadiusFrac * 2.71);
vec3 col = mix(cCore, cCyan, smoothstep(0.00, 0.28, vRadiusFrac));
col = mix(col, cMagenta, smoothstep(0.18, 0.52, vRadiusFrac + (colorNoise - 0.5) * 0.18));
col = mix(col, cViolet, smoothstep(0.42, 0.78, vRadiusFrac + (colorNoise - 0.5) * 0.22));
col = mix(col, cOuter, smoothstep(0.72, 1.00, vRadiusFrac));
float cyanMix = smoothstep(0.15, 0.85, sin(vSeed * 19.0 + vRadiusFrac * 11.0) * 0.5 + 0.5);
float magentaMix = smoothstep(0.2, 0.9, cos(vSeed * 23.0 - vRadiusFrac * 8.0) * 0.5 + 0.5);
col = mix(col, cCyan, cyanMix * 0.18);
col = mix(col, cMagenta, magentaMix * 0.22);
float alpha = shape * intensity * ${SMOKE_CFG.opacity.toFixed(3)};
gl_FragColor = vec4(col * alpha, alpha);
}
`
// Inline GPUCompute
class GPUCompute {
constructor(w, h, renderer) {
this.w = w
this.h = h
this.gl = renderer
this.vars = {}
// Re-use PlaneGeometry and OrthographicCamera instead of newing every frame
if (!GPUCompute.sharedGeo) GPUCompute.sharedGeo = new THREE.PlaneGeometry(2, 2)
if (!GPUCompute.sharedCam) GPUCompute.sharedCam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1)
if (!GPUCompute.sharedScene) GPUCompute.sharedScene = new THREE.Scene()
this._geo = GPUCompute.sharedGeo
this._cam = GPUCompute.sharedCam
this._scene = GPUCompute.sharedScene
}
_rt() {
return new THREE.WebGLRenderTarget(this.w, this.h, {
wrapS: THREE.ClampToEdgeWrapping, wrapT: THREE.ClampToEdgeWrapping,
minFilter: THREE.NearestFilter, magFilter: THREE.NearestFilter,
format: THREE.RGBAFormat, type: THREE.FloatType,
depthBuffer: false, stencilBuffer: false,
})
}
addVar(name, fragShader, initTex) {
const simMat = new THREE.ShaderMaterial({
uniforms: {
uPosition: { value: initTex },
uData: { value: null },
uDelta: { value: 0 },
uTime: { value: 0 },
},
vertexShader: /* glsl */`varying vec2 vUv; void main(){ vUv=uv; gl_Position=vec4(position,1.0); }`,
fragmentShader: fragShader,
})
const rtA = this._rt(), rtB = this._rt()
// One-time initTex → rtA
const blit = new THREE.Mesh(this._geo, new THREE.MeshBasicMaterial({ map: initTex }))
this._scene.add(blit)
this.gl.setRenderTarget(rtA); this.gl.render(this._scene, this._cam)
this._scene.remove(blit); blit.material.dispose()
this.gl.setRenderTarget(null)
this.vars[name] = { simMat, mesh: new THREE.Mesh(this._geo, simMat), rtA, rtB }
return this.vars[name]
}
compute(name, time, delta, dataTex) {
const v = this.vars[name]
v.simMat.uniforms.uTime.value = time
v.simMat.uniforms.uDelta.value = delta
v.simMat.uniforms.uData.value = dataTex
// swap ping-pong
const tmp = v.rtA; v.rtA = v.rtB; v.rtB = tmp
v.simMat.uniforms.uPosition.value = v.rtB.texture
this._scene.add(v.mesh)
this.gl.setRenderTarget(v.rtA); this.gl.render(this._scene, this._cam)
this._scene.remove(v.mesh)
this.gl.setRenderTarget(null)
return v.rtA.texture
}
dispose() {
Object.values(this.vars).forEach(v => {
v.rtA.dispose(); v.rtB.dispose(); v.simMat.dispose(); v.mesh.geometry.dispose()
})
/* Do not dispose static geo/cam/scene, they are shared */
}
}
// Seeded RNG (mulberry32)
function mulberry32(seed) {
let t = seed >>> 0
return () => {
t += 0x6D2B79F5
let r = Math.imul(t ^ (t >>> 15), 1 | t)
r ^= r + Math.imul(r ^ (r >>> 7), 61 | r)
return ((r ^ (r >>> 14)) >>> 0) / 4294967296
}
}
// Build DataTextures for initial positions and static data
function buildTextures(cfg) {
const { texSize: S, maxRadius, holeRadius, holeEdgeBand, arms, spiralTightness, armWidth,
diskHeight, coreRadius, coreHeight, seed } = cfg
const total = S * S
const posArr = new Float32Array(total * 4)
const dataArr = new Float32Array(total * 4)
const rand = mulberry32(seed)
for (let i = 0; i < total; i++) {
const r0 = rand()
let r, radiusFrac, inBulge = false
if (r0 < 0.18) {
r = Math.abs(rand() + rand() + rand() - 1.5) * coreRadius * 1.1
inBulge = true
} else {
r = -Math.log(1.0 - rand() * 0.9999) * (maxRadius * 0.35)
r = Math.min(r, maxRadius)
}
let inHoleEdge = false
if (r < holeRadius) {
r = holeRadius + rand() * holeEdgeBand
inBulge = false
inHoleEdge = true
}
radiusFrac = Math.min(r / maxRadius, 1.0)
const armIdx = Math.floor(rand() * arms)
const armBase = (armIdx / arms) * Math.PI * 2
let g = rand() + rand() + rand()
g = (g / 3 - 0.5) * 2.0
const scatter = armWidth * r * (inBulge ? 3.0 : 1.0)
const theta = inHoleEdge
? (rand() * Math.PI * 2 + g * (armWidth * holeRadius * 3.0))
: (armBase + r * spiralTightness + g * scatter)
let gz = rand() + rand() + rand()
gz = (gz / 3 - 0.5) * 2.0
const zScale = inBulge ? coreHeight : diskHeight * (0.5 + radiusFrac * 0.5)
const z = gz * zScale
const x = r * Math.cos(theta)
const y = r * Math.sin(theta)
posArr[i * 4] = x
posArr[i * 4 + 1] = y
posArr[i * 4 + 2] = z
posArr[i * 4 + 3] = rand()
const orbSpeed = inBulge ? 0.55 + rand() * 0.15 : 0.30 + radiusFrac * 0.22 + rand() * 0.08
dataArr[i * 4] = radiusFrac
dataArr[i * 4 + 1] = rand()
dataArr[i * 4 + 2] = orbSpeed
dataArr[i * 4 + 3] = armIdx / arms
}
const mkTex = (arr) => {
const t = new THREE.DataTexture(arr, S, S, THREE.RGBAFormat, THREE.FloatType)
t.needsUpdate = true
t.minFilter = t.magFilter = THREE.NearestFilter
return t
}
return { posTex: mkTex(posArr), dataTex: mkTex(dataArr) }
}
// Build particle geometry (attributes only — positions on GPU)
function buildGeo(cfg) {
const { texSize: S, maxRadius, holeRadius, coreRadius, seed } = cfg
const count = S * S
const refs = new Float32Array(count * 2)
const rfrac = new Float32Array(count)
const seeds = new Float32Array(count)
const colors = new Float32Array(count)
const rand = mulberry32(seed + 99)
for (let i = 0; i < count; i++) {
refs[i * 2] = ((i % S) + 0.5) / S
refs[i * 2 + 1] = (Math.floor(i / S) + 0.5) / S
const r0 = rand()
let r
if (r0 < 0.18) {
r = Math.abs(rand() + rand() + rand() - 1.5) * coreRadius * 1.1
} else {
r = -Math.log(1.0 - rand() * 0.9999) * (maxRadius * 0.35)
r = Math.min(r, maxRadius)
}
if (r < holeRadius) {
r = holeRadius + rand() * 0.06
}
rfrac[i] = Math.min(r / maxRadius, 1.0)
seeds[i] = rand()
colors[i] = rand()
}
const geo = new THREE.BufferGeometry()
geo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(count * 3), 3))
geo.setAttribute('aRef', new THREE.BufferAttribute(refs, 2))
geo.setAttribute('aRadiusFrac', new THREE.BufferAttribute(rfrac, 1))
geo.setAttribute('aSeed', new THREE.BufferAttribute(seeds, 1))
geo.setAttribute('aColor', new THREE.BufferAttribute(colors, 1))
return geo
}
// Build smoke DataTextures (initial positions + static data)
function buildSmokeTextures(cfg) {
const S = cfg.texSize
const total = S * S
const posArr = new Float32Array(total * 4)
const dataArr = new Float32Array(total * 4)
const rand = mulberry32(cfg.seed)
for (let i = 0; i < total; i++) {
let r = -Math.log(1.0 - rand() * 0.9999) * (cfg.maxRadius * 0.34)
r = Math.min(r, cfg.maxRadius)
let inHoleEdge = false
if (r < cfg.holeRadius) {
r = cfg.holeRadius + rand() * cfg.holeEdgeBand
inHoleEdge = true
}
const radiusFrac = Math.min(r / cfg.maxRadius, 1.0)
const armIdx = Math.floor(rand() * cfg.arms)
const armBase = (armIdx / cfg.arms) * Math.PI * 2
let g = rand() + rand() + rand()
g = (g / 3 - 0.5) * 2.0
const scatter = cfg.armWidth * r
const theta = inHoleEdge
? (rand() * Math.PI * 2 + g * (cfg.armWidth * cfg.holeRadius * 2.0))
: (armBase + r * cfg.spiralTightness + g * scatter)
let gz = rand() + rand() + rand()
gz = (gz / 3 - 0.5) * 2.0
const z = gz * cfg.diskHeight * (0.6 + radiusFrac * 0.4)
posArr[i * 4] = r * Math.cos(theta)
posArr[i * 4 + 1] = r * Math.sin(theta)
posArr[i * 4 + 2] = z
posArr[i * 4 + 3] = rand()
const orbSpeed = cfg.orbSpeedBase + radiusFrac * 0.08 + rand() * 0.04
dataArr[i * 4] = radiusFrac
dataArr[i * 4 + 1] = rand()
dataArr[i * 4 + 2] = orbSpeed
dataArr[i * 4 + 3] = armIdx / cfg.arms
}
const mkTex = (arr) => {
const t = new THREE.DataTexture(arr, S, S, THREE.RGBAFormat, THREE.FloatType)
t.needsUpdate = true
t.minFilter = t.magFilter = THREE.NearestFilter
return t
}
return { posTex: mkTex(posArr), dataTex: mkTex(dataArr) }
}
function buildSmokeGeo(cfg) {
const S = cfg.texSize
const count = S * S
const refs = new Float32Array(count * 2)
const rfrac = new Float32Array(count)
const seeds = new Float32Array(count)
const rand = mulberry32(cfg.seed + 200)
for (let i = 0; i < count; i++) {
refs[i * 2] = ((i % S) + 0.5) / S
refs[i * 2 + 1] = (Math.floor(i / S) + 0.5) / S
let r = -Math.log(1.0 - rand() * 0.9999) * (cfg.maxRadius * 0.34)
r = Math.min(r, cfg.maxRadius)
if (r < cfg.holeRadius) {
r = cfg.holeRadius + rand() * cfg.holeEdgeBand
}
rfrac[i] = Math.min(r / cfg.maxRadius, 1.0)
seeds[i] = rand()
}
const geo = new THREE.BufferGeometry()
geo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(count * 3), 3))
geo.setAttribute('aRef', new THREE.BufferAttribute(refs, 2))
geo.setAttribute('aRadiusFrac', new THREE.BufferAttribute(rfrac, 1))
geo.setAttribute('aSeed', new THREE.BufferAttribute(seeds, 1))
return geo
}
// SmokeFlow — GPGPU-driven flowing nebula layer
const SmokeFlow = React.memo(function SmokeFlow() {
const { gl } = useThree()
const gpuRef = useRef(null)
const matRef = useRef(null)
const dataRef = useRef(null)
const { geo, posTex, dataTex } = useMemo(() => {
const { posTex, dataTex } = buildSmokeTextures(SMOKE_CFG)
const geo = buildSmokeGeo(SMOKE_CFG)
return { geo, posTex, dataTex }
}, [])
const mat = useMemo(() => new THREE.ShaderMaterial({
uniforms: {
uPosition: { value: posTex },
uPixelRatio: { value: Math.min(typeof window !== 'undefined' ? window.devicePixelRatio : 1, 2) },
uTime: { value: 0 },
},
vertexShader: SMOKE_VERT,
fragmentShader: SMOKE_FRAG,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
}), [posTex])
useEffect(() => {
const gpu = new GPUCompute(SMOKE_CFG.texSize, SMOKE_CFG.texSize, gl)
gpu.addVar('smokePos', SMOKE_SIM_FRAG, posTex)
gpuRef.current = gpu
matRef.current = mat
dataRef.current = dataTex
return () => {
gpu.dispose()
posTex.dispose()
dataTex.dispose()
geo.dispose()
mat.dispose()
matRef.current = null
}
}, [gl]) // eslint-disable-line react-hooks/exhaustive-deps
useFrame((state, rawDelta) => {
const gpu = gpuRef.current
const material = matRef.current
if (!gpu || !material) return
const dt = Math.min(rawDelta, 0.05)
const tex = gpu.compute('smokePos', state.clock.elapsedTime, dt, dataRef.current)
material.uniforms.uPosition.value = tex
material.uniforms.uTime.value = state.clock.elapsedTime
})
return (
<group
scale={1.65}
position={[-1.45, 0.5, 0]}
rotation={[degToRad(40), degToRad(0), degToRad(-5)]}
>
<points geometry={geo} material={mat} />
</group>
)
})
const GALAXY_BASE_ROT = [degToRad(110), degToRad(-10), degToRad(0)]
/** Max euler offset (rad) from mouse at screen edges — keep small for subtle parallax */
const MOUSE_TILT = { x: 0.1, y: 0.12, z: 0.03 }
/** How fast smoothed mouse catches the cursor (frame-rate independent lerp) */
const MOUSE_LERP_LAMBDA = 1
function GalaxyMouseGroup({ children }) {
const groupRef = useRef(null)
const mouseRef = useRef({ x: 0, y: 0 })
const smoothRef = useRef({ x: 0, y: 0 })
useEffect(() => {
const onMove = (e) => {
const w = window.innerWidth || 1
const h = window.innerHeight || 1
mouseRef.current.x = (e.clientX / w) * 2 - 1
mouseRef.current.y = (e.clientY / h) * 2 - 1
}
window.addEventListener('mousemove', onMove)
return () => window.removeEventListener('mousemove', onMove)
}, [])
useFrame((_, dt) => {
const g = groupRef.current
if (!g) return
const m = mouseRef.current
const s = smoothRef.current
const t = 1 - Math.exp(-MOUSE_LERP_LAMBDA * dt)
s.x = THREE.MathUtils.lerp(s.x, m.x, t)
s.y = THREE.MathUtils.lerp(s.y, m.y, t)
g.rotation.x = GALAXY_BASE_ROT[0] - s.y * MOUSE_TILT.x
g.rotation.y = GALAXY_BASE_ROT[1] - s.x * MOUSE_TILT.y
// g.rotation.z = GALAXY_BASE_ROT[2] + s.x * s.y * MOUSE_TILT.z
})
return (
<group ref={groupRef} position={[-2, 2.2, 0]} rotation={GALAXY_BASE_ROT}>
{children}
</group>
)
}
const MilkyWayGPGPU = React.memo(function MilkyWayGPGPU() {
const { gl } = useThree()
const groupRef = useRef()
const gpuRef = useRef(null)
const matRef = useRef(null)
const dataRef = useRef(null)
const { geo, posTex, dataTex } = useMemo(() => {
const { posTex, dataTex } = buildTextures(CFG)
const geo = buildGeo(CFG)
return { geo, posTex, dataTex }
}, [])
const mat = useMemo(() => new THREE.ShaderMaterial({
uniforms: {
uPosition: { value: posTex },
uPixelRatio: { value: Math.min(typeof window !== 'undefined' ? window.devicePixelRatio : 1, 2) },
},
vertexShader: PARTICLE_VERT,
fragmentShader: PARTICLE_FRAG,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
}), [posTex])
useEffect(() => {
const gpu = new GPUCompute(CFG.texSize, CFG.texSize, gl)
gpu.addVar('pos', SIM_FRAG, posTex)
gpuRef.current = gpu
dataRef.current = dataTex
matRef.current = mat
return () => {
gpu.dispose()
posTex.dispose()
dataTex.dispose()
geo.dispose()
mat.dispose()
matRef.current = null
}
}, [gl]) // eslint-disable-line react-hooks/exhaustive-deps
useFrame((state, rawDelta) => {
const gpu = gpuRef.current
const material = matRef.current
if (!gpu || !material) return
const dt = Math.min(rawDelta, 0.05)
const tex = gpu.compute('pos', state.clock.elapsedTime, dt, dataRef.current)
material.uniforms.uPosition.value = tex
})
return (
<group
ref={groupRef}
scale={1.65}
position={[-1.45, 0.5, 0]}
rotation={[degToRad(40), degToRad(0), degToRad(-5)]}
>
<points geometry={geo} material={mat} />
</group>
)
})
const BackgroundStars = React.memo(function BackgroundStars() {
const mesh = useMemo(() => {
const count = 4000
const pos = new Float32Array(count * 3)
const rand = mulberry32(12345)
for (let i = 0; i < count; i++) {
const theta = rand() * Math.PI * 2
const phi = Math.acos(2 * rand() - 1)
const r = 40 + rand() * 20
pos[i * 3] = r * Math.sin(phi) * Math.cos(theta)
pos[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta)
pos[i * 3 + 2] = r * Math.cos(phi)
}
const geo = new THREE.BufferGeometry()
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3))
const mat = new THREE.PointsMaterial({
color: 0xffffff, size: 0.055, sizeAttenuation: true,
transparent: true, opacity: 0.7, depthWrite: false,
})
return new THREE.Points(geo, mat)
}, [])
return <primitive object={mesh} />
})
const MOBILE_QUERY = '(max-width: 1024px)'
function SceneReady({ onReady }) {
const frameCountRef = useRef(0)
const readyRef = useRef(false)
useFrame(() => {
if (readyRef.current) return
frameCountRef.current += 1
if (frameCountRef.current < 24) return
readyRef.current = true
onReady()
})
return null
}
export default function MilkyWay() {
const [isPageLoaded, setIsPageLoaded] = useState(false)
const [viewport, setViewport] = useState(null)
const [isSceneReady, setIsSceneReady] = useState(false)
useLayoutEffect(() => {
const markLoaded = () => {
queueMicrotask(() => {
setIsPageLoaded(true);
});
};
if (document.readyState === 'complete') {
markLoaded()
} else {
window.addEventListener('load', markLoaded, { once: true })
}
const updateViewport = () => {
queueMicrotask(() => {
setViewport({
dpr: Math.max(1, Math.min(2, window.devicePixelRatio || 1)),
isMobile: window.matchMedia(MOBILE_QUERY).matches,
});
});
}
updateViewport()
window.addEventListener('resize', updateViewport)
const mobileQuery = window.matchMedia(MOBILE_QUERY)
mobileQuery.addEventListener('change', updateViewport)
return () => {
window.removeEventListener('load', markLoaded)
window.removeEventListener('resize', updateViewport)
mobileQuery.removeEventListener('change', updateViewport)
}
}, [])
useEffect(() => {
if (viewport?.isMobile) {
queueMicrotask(() => {
setIsSceneReady(false);
});
}
}, [viewport?.isMobile])
const showDesktopScene = isPageLoaded && viewport !== null && !viewport.isMobile
const showMobileFallback = isPageLoaded && viewport !== null && viewport.isMobile
const showDesktopContent = showDesktopScene && isSceneReady
const showMobileContent = showMobileFallback
return (
<section style={{ width: '100%', height: '100vh', background: '#000' }}>
{showDesktopScene ? (
<Canvas
dpr={viewport.dpr}
gl={{ antialias: false, powerPreference: 'high-performance' }}
camera={{ position: [-1, -1.8, 4], fov: 45, near: 0.01, far: 200 }}
style={{ opacity: isSceneReady ? 1 : 0 }}
>
<SceneReady onReady={() => setIsSceneReady(true)} />
<BackgroundStars />
<Center rotation={[degToRad(-10), degToRad(0), degToRad(0)]} position={[-1.2, 0.5, 0]}>
<GalaxyMouseGroup>
<MilkyWayGPGPU />
{isSceneReady && <SmokeFlow />}
</GalaxyMouseGroup>
</Center>
<EffectComposer>
{/* <Bloom
luminanceThreshold={0.05}
luminanceSmoothing={0.85}
intensity={2.2}
radius={0.80}
/> */}
<EdgeBlurEffect blurStrength={1.2} blurStart={0.2} />
<EdgeBlurEffect blurType='classic' blurStrength={.3} blurStart={0.1} />
<Vignette opacity={.5} offset={.8} darkness={.7} />
</EffectComposer>
</Canvas>
) : showMobileContent ? (
<div className='absolute inset-0 flex items-center justify-center px-6 text-center text-white'>
<div className='flex flex-col items-center gap-6'>
<p className='rounded-full bg-white/10 px-5 py-3 w-full max-w-[60vw] max-md:max-w-[70vw] max-sm:max-w-full text-2xl max-sm:text-base backdrop-blur-sm'>
Open in desktop to experience this effect.
</p>
<Link
href={"/effects"}
className='rounded-full bg-primary px-6 py-3 max-sm:text-sm font-medium text-base text-white'
>
Explore effects
</Link>
</div>
</div>
) : null}
{(showDesktopContent || showMobileContent ) && (
<div className=" h-screen w-full absolute left-0 top-0 max-md:top-5 z-299 flex items-center justify-center max-md:hidden">
<div className='h-full w-full relative'>
{/* Navbar (leave unchanged) */}
<div className='w-full absolute top-0 left-0 pt-[2vw] px-[2vw] flex items-center max-md:flex-col max-md:gap-5 justify-between h-fit'>
<p className='text-4xl font-medium'>Galaxy</p>
<div className='w-60 space-y-2'>
<p className='leading-[1.2]'>
Move your cursor around and watch the scene respond.
</p>
<button className="pr-4 py-1.5 flex items-center gap-1 rounded-full hover:text-primary cursor-pointer transition-all duration-300 text-white/80">
<Link href={"/effects"}>Explore Platform</Link>
<ArrowRight className='h-4 w-4' />
</button>
</div>
</div>
{/* Enhanced cosmic hero section */}
<div className="absolute bottom-[4vw] max-md:bottom-[12vw] left-[4vw] max-w-[70vw]">
<p className='flex items-center bg-white/20 max-md:text-sm backdrop-blur-sm rounded-full text-xs px-[1vw] py-[.5vw] w-fit gap-2'><Stars size={12} />Data Driven And Creative</p>
<h1 className="text-[7vw] mt-[1vw] max-md:mt-[3vw] font leading-[1.1] text-white drop-shadow-[0_0_32px_rgba(100,96,255,0.17)]">
Particles Galaxy
</h1>
<h1 className='text-[7vw] -mt-[1vw] drop-shadow-[0_0_32px_rgba(100,96,255,0.17)]'>Milkyway</h1>
</div>
{!viewport?.isMobile && (
<div className='h-fit cursor-pointer w-fit absolute bottom-[2vw] right-[4vw] p-[1.5vw]'>
<CircularText
text="EXPLORE THE GALAXY "
spinDuration={20}
onHover="speedUp"
/>
</div>
)}
</div>
</div>
)}
</section>
)
}
import { useMemo, useEffect } from"react";
import { Effect } from"postprocessing";
import { Uniform, Vector2 } from"three";
const classicBlurFragmentShader = /* glsl */ `
uniform float uBlurStrength;
uniform float uBlurStart;
uniform vec2 uResolution;
void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) {
vec2 center = uv - 0.5;
float dist = length(center);
float blurAmount = smoothstep(uBlurStart, 0.75, dist) * uBlurStrength;
if (blurAmount < 0.001) {
outputColor = inputColor;
return;
}
float aspect = uResolution.x / uResolution.y;
vec2 texelSize = vec2(1.0 / uResolution.x, 1.0 / uResolution.y);
const int TAPS = 12;
vec2 offsets[12];
offsets[0] = vec2(-0.326, -0.406);
offsets[1] = vec2(-0.840, -0.074);
offsets[2] = vec2(-0.696, 0.457);
offsets[3] = vec2(-0.203, 0.621);
offsets[4] = vec2( 0.962, -0.195);
offsets[5] = vec2( 0.473, -0.480);
offsets[6] = vec2( 0.519, 0.767);
offsets[7] = vec2( 0.185, -0.893);
offsets[8] = vec2( 0.507, 0.064);
offsets[9] = vec2( 0.896, 0.412);
offsets[10] = vec2(-0.322, -0.933);
offsets[11] = vec2(-0.792, -0.598);
float radius = blurAmount * 12.0;
vec4 blurred = inputColor;
float totalWeight = 1.0;
for (int i = 0; i < TAPS; i++) {
vec2 offset = offsets[i] * radius * texelSize;
vec4 s = texture2D(inputBuffer, uv + offset);
blurred += s;
totalWeight += 1.0;
}
blurred /= totalWeight;
outputColor = blurred;
}
`;
const frostedBlurFragmentShader = /* glsl */ `
uniform float uBlurStrength;
uniform float uBlurStart;
uniform vec2 uResolution;
void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) {
vec2 center = uv - 0.5;
float dist = length(center);
float blurAmount = smoothstep(uBlurStart, 0.75, dist) * uBlurStrength;
if (blurAmount < 0.001) {
outputColor = inputColor;
return;
}
float aspect = uResolution.x / uResolution.y;
vec2 texelSize = vec2(1.0 / uResolution.x, 1.0 / uResolution.y);
const int TAPS = 12;
vec2 offsets[12];
offsets[0] = vec2(-0.326, -0.406);
offsets[1] = vec2(-0.840, -0.074);
offsets[2] = vec2(-0.696, 0.457);
offsets[3] = vec2(-0.203, 0.621);
offsets[4] = vec2( 0.962, -0.195);
offsets[5] = vec2( 0.473, -0.480);
offsets[6] = vec2( 0.519, 0.767);
offsets[7] = vec2( 0.185, -0.893);
offsets[8] = vec2( 0.507, 0.064);
offsets[9] = vec2( 0.896, 0.412);
offsets[10] = vec2(-0.322, -0.933);
offsets[11] = vec2(-0.792, -0.598);
float radius = blurAmount * 12.0;
vec4 blurred = inputColor;
float totalWeight = 1.0;
for (int i = 0; i < TAPS; i++) {
vec2 offset = offsets[i] * radius * texelSize;
vec4 s = texture2D(inputBuffer, uv + offset);
blurred += s;
totalWeight += 1.0;
}
blurred /= totalWeight;
// White, semi-transparent veil for frosted-edge effect.
vec3 white = vec3(1.0, 1.0, 1.0);
float veilAlpha = 0.4 * blurAmount; // Max 0.4 opacity at the strongest blur
// Correct GLSL: mix(vec3, vec3, float)
vec3 outRgb = mix(blurred.rgb, white, veilAlpha);
float outAlpha = blurred.a;
outputColor = vec4(outRgb, outAlpha);
}
`;
/**
* EdgeBlurEffect component
* @param {'classic'|'frosted'} blurType Use"frosted" (default) for white edge,"classic" for pure blur
* @param {number} blurStrength
* @param {number} blurStart
*/
class EdgeBlurEffectImpl extends Effect {
constructor({
blurStrength = 1.0,
blurStart = 0.25,
resolution = [1280, 720],
frag = frostedBlurFragmentShader,
} = {}) {
const uniforms = new Map([
["uBlurStrength", new Uniform(blurStrength)],
["uBlurStart", new Uniform(blurStart)],
["uResolution", new Uniform(new Vector2(resolution[0], resolution[1]))],
]);
super("EdgeBlurEffect", frag, { uniforms });
}
setResolution(width, height) {
const uRes = this.uniforms.get("uResolution");
if (uRes) uRes.value.set(width, height);
}
update(renderer) {
if (renderer && renderer.getSize) {
const size = renderer.getSize(new Vector2());
this.setResolution(size.x, size.y);
}
}
}
export default function EdgeBlurEffect({
blurType ="classic", //"frosted" |"classic"
blurStrength = 1.0,
blurStart = 0.25,
}) {
const frag =
blurType ==="classic"
? classicBlurFragmentShader
: frostedBlurFragmentShader;
const effect = useMemo(() => {
let width = 1280,
height = 720;
if (typeof window !=="undefined") {
width = window.innerWidth;
height = window.innerHeight;
}
return new EdgeBlurEffectImpl({
blurStrength,
blurStart,
resolution: [width, height],
frag,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [blurStrength, blurStart, frag]);
useEffect(() => {
if (typeof window ==="undefined") return;
const update = () => effect.setResolution(window.innerWidth, window.innerHeight);
window.addEventListener("resize", update);
update();
return () => window.removeEventListener("resize", update);
}, [effect]);
return <primitive object={effect} dispose={null} />;
}
'use client'
import { useEffect, useRef } from'react'
import gsap from'gsap'
import { Stars } from'lucide-react'
const CircularText = ({
text ='',
spinDuration = 20,
onHover ='speedUp',
className ='',
radius = 80,
}) => {
const containerRef = useRef(null)
const tweenRef = useRef(null)
const letters = Array.from(text)
useEffect(() => {
if (!containerRef.current) return
tweenRef.current?.kill()
tweenRef.current = gsap.to(containerRef.current, {
rotation: 360,
duration: spinDuration,
ease:'none',
repeat: -1,
transformOrigin:'50% 50%',
})
return () => tweenRef.current?.kill()
}, [spinDuration, text])
const handleEnter = () => {
if (!tweenRef.current) return
switch (onHover) {
case'slowDown':
gsap.to(tweenRef.current, { timeScale: 0.5, duration: 0.3 })
break
case'speedUp':
gsap.to(tweenRef.current, { timeScale: 2, duration: 0.3 })
break
case'pause':
tweenRef.current.pause()
break
case'goBonkers':
gsap.to(tweenRef.current, { timeScale: 6, duration: 0.3 })
gsap.to(containerRef.current, {
scale: 0.85,
duration: 0.3,
ease:'power2.out',
})
break
}
}
const handleLeave = () => {
if (!tweenRef.current) return
tweenRef.current.resume()
gsap.to(tweenRef.current, { timeScale: 1, duration: 0.3 })
gsap.to(containerRef.current, {
scale: 1,
duration: 0.3,
ease:'power2.out',
})
}
return (
<div>
<span
style={{
position:'absolute',
left:'50%',
top:'50%',
width: 56,
height: 56,
transform:'translate(-50%, -50%)',
zIndex: 2,
userSelect:'none',
pointerEvents:'none',
display:'flex',
alignItems:'center',
justifyContent:'center',
}}
>
{/* Replace with your preferred Lucide icon below */}
{/* Make sure to import { CupSoda } from'lucide-react' at the top of this file */}
<Stars size={32} strokeWidth={1} />
</span>
<div
ref={containerRef}
className={className}
onMouseEnter={handleEnter}
onMouseLeave={handleLeave}
style={{
width: 180,
height: 180,
position:'relative',
}}
>
{letters.map((letter, i) => {
const angle = (360 / letters.length) * i
return (
<span
key={i}
style={{
position:'absolute',
left:'50%',
top:'50%',
transform: `
rotate(${angle}deg)
translate(${radius}px)
rotate(90deg)
`,
transformOrigin:'0 0',
fontSize: 20,
fontWeight: 600,
whiteSpace:'pre',
pointerEvents:'none',
zIndex: 3,
}}
>
{letter}
</span>
)
})}
</div>
</div>
)
}
export default CircularTextExample Production Use Case
Use this as WebGL production guidance. Verify the shipped source, rendering stack, dependency list, shader assets, resource disposal, pause/offscreen behavior, DPR strategy, poster fallback, and reduced-motion state before relying on exact props, defaults, imports, or installation steps.
Best Used For
- Hero sections where atmospheric scale matters more than direct interaction.
- AI and technical launch pages where a controlled starfield supports the first claim.
- Portfolio or campaign pages where the field is ambience, not content.
Not For
Not for low-power-first pages, dense content, dashboards, checkout, or routes where performance is the main conversion lever.
Performance Budget
Cap DPR at 1.0 on touch/mobile and up to 1.5 on mid-range desktop. Pause Milky Way offscreen and in hidden tabs, reduce postprocessing before shipping, and keep texture sizes controlled.
Accessibility and Mobile
Keep meaningful content in HTML outside the canvas. On mobile, reduce shader quality and switch to a poster if the scene cannot hold frame rate.
Common Mistakes
- Using particle density where a static starfield would carry the same atmosphere.
- Making the starfield the only place the visual identity exists.
- Leaving a continuous particle loop running below the fold.
Frequently Asked Questions
What GPU budget should Milky Way use on mobile and desktop?
What poster fallback should replace Milky Way when WebGL fails?
How should Milky Way pause when offscreen or in a hidden tab?
Which content should stay outside the Milky Way canvas?
When should I choose a static image instead of Milky Way?
Request a Custom WebGL Animation
Need a custom effect? Tell us what to create.



