LightPillar
Vertical pillar of light with glow effects.
Live preview
Loading demo…
Installation
npx shadcn@latest add https://reactbits.dev/r/LightPillar-TS-TW.json
Dependencies: three
Source
'use client';
import React, { useRef, useEffect, useState } from 'react';
import * as THREE from 'three';
interface LightPillarProps {
topColor?: string;
bottomColor?: string;
intensity?: number;
rotationSpeed?: number;
interactive?: boolean;
className?: string;
glowAmount?: number;
pillarWidth?: number;
pillarHeight?: number;
noiseIntensity?: number;
mixBlendMode?: React.CSSProperties['mixBlendMode'];
pillarRotation?: number;
quality?: 'low' | 'medium' | 'high';
lightMode?: boolean;
}
const LightPillar: React.FC<LightPillarProps> = ({
topColor = '#5227FF',
bottomColor = '#FF9FFC',
intensity = 1.0,
rotationSpeed = 0.3,
interactive = false,
className = '',
glowAmount = 0.005,
pillarWidth = 3.0,
pillarHeight = 0.4,
noiseIntensity = 0.5,
mixBlendMode = 'screen',
pillarRotation = 0,
quality = 'high',
lightMode = false
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const rafRef = useRef<number | null>(null);
const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
const materialRef = useRef<THREE.ShaderMaterial | null>(null);
const sceneRef = useRef<THREE.Scene | null>(null);
const cameraRef = useRef<THREE.OrthographicCamera | null>(null);
const geometryRef = useRef<THREE.PlaneGeometry | null>(null);
const mouseRef = useRef<THREE.Vector2>(new THREE.Vector2(0, 0));
const timeRef = useRef(0);
const rotationSpeedRef = useRef(rotationSpeed);
const [webGLSupported, setWebGLSupported] = useState<boolean>(true);
// Check WebGL support
useEffect(() => {
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
if (!gl) {
setWebGLSupported(false);
}
}, []);
useEffect(() => {
if (!containerRef.current || !webGLSupported) return;
const container = containerRef.current;
const width = container.clientWidth;
const height = container.clientHeight;
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
const isLowEndDevice = isMobile || (navigator.hardwareConcurrency && navigator.hardwareConcurrency <= 4);
let effectiveQuality = quality;
if (isLowEndDevice && quality === 'high') effectiveQuality = 'medium';
if (isMobile && quality !== 'low') effectiveQuality = 'low';
const qualitySettings = {
low: { iterations: 24, waveIterations: 1, pixelRatio: 0.5, precision: 'mediump', stepMultiplier: 1.5 },
medium: { iterations: 40, waveIterations: 2, pixelRatio: 0.65, precision: 'mediump', stepMultiplier: 1.2 },
high: {
iterations: 80,
waveIterations: 4,
pixelRatio: Math.min(window.devicePixelRatio, 2),
precision: 'highp',
stepMultiplier: 1.0
}
};
const settings = qualitySettings[effectiveQuality] || qualitySettings.medium;
// Scene setup
const scene = new THREE.Scene();
sceneRef.current = scene;
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
cameraRef.current = camera;
let renderer: THREE.WebGLRenderer;
try {
renderer = new THREE.WebGLRenderer({
antialias: false,
alpha: true,
powerPreference: effectiveQuality === 'low' ? 'low-power' : 'high-performance',
precision: settings.precision,
stencil: false,
depth: false
});
} catch (error) {
console.error('Failed to create WebGL renderer:', error);
setWebGLSupported(false);
return;
}
renderer.setSize(width, height);
renderer.setPixelRatio(settings.pixelRatio);
container.appendChild(renderer.domElement);
rendererRef.current = renderer;
// Convert hex colors to RGB
const parseColor = (hex: string): THREE.Vector3 => {
const color = new THREE.Color(hex);
return new THREE.Vector3(color.r, color.g, color.b);
};
// Shader material
const vertexShader = `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position, 1.0);
}
`;
const fragmentShader = `
precision ${settings.precision} float;
uniform float uTime;
uniform vec2 uResolution;
uniform vec2 uMouse;
uniform vec3 uTopColor;
uniform vec3 uBottomColor;
uniform float uIntensity;
uniform bool uInteractive;
uniform float uGlowAmount;
uniform float uPillarWidth;
uniform float uPillarHeight;
uniform float uNoiseIntensity;
uniform float uLightMode;
uniform float uRotCos;
uniform float uRotSin;
uniform float uPillarRotCos;
uniform float uPillarRotSin;
uniform float uWaveSin;
uniform float uWaveCos;
varying vec2 vUv;
const float STEP_MULT = ${settings.stepMultiplier.toFixed(1)};
const int MAX_ITER = ${settings.iterations};
const int WAVE_ITER = ${settings.waveIterations};
void main() {
vec2 uv = (vUv * 2.0 - 1.0) * vec2(uResolution.x / uResolution.y, 1.0);
uv = vec2(uPillarRotCos * uv.x - uPillarRotSin * uv.y, uPillarRotSin * uv.x + uPillarRotCos * uv.y);
vec3 ro = vec3(0.0, 0.0, -10.0);
vec3 rd = normalize(vec3(uv, 1.0));
float rotC = uRotCos;
float rotS = uRotSin;
if(uInteractive && (uMouse.x != 0.0 || uMouse.y != 0.0)) {
float a = uMouse.x * 6.283185;
rotC = cos(a);
rotS = sin(a);
}
vec3 col = vec3(0.0);
float t = 0.1;
for(int i = 0; i < MAX_ITER; i++) {
vec3 p = ro + rd * t;
p.xz = vec2(rotC * p.x - rotS * p.z, rotS * p.x + rotC * p.z);
vec3 q = p;
q.y = p.y * uPillarHeight + uTime;
float freq = 1.0;
float amp = 1.0;
for(int j = 0; j < WAVE_ITER; j++) {
q.xz = vec2(uWaveCos * q.x - uWaveSin * q.z, uWaveSin * q.x + uWaveCos * q.z);
q += cos(q.zxy * freq - uTime * float(j) * 2.0) * amp;
freq *= 2.0;
amp *= 0.5;
}
float d = length(cos(q.xz)) - 0.2;
float bound = length(p.xz) - uPillarWidth;
float k = 4.0;
float h = max(k - abs(d - bound), 0.0);
d = max(d, bound) + h * h * 0.0625 / k;
d = abs(d) * 0.15 + 0.01;
float grad = clamp((15.0 - p.y) / 30.0, 0.0, 1.0);
col += mix(uBottomColor, uTopColor, grad) / d;
t += d * STEP_MULT;
if(t > 50.0) break;
}
float widthNorm = uPillarWidth / 3.0;
col = tanh(col * uGlowAmount / widthNorm);
col -= fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453) / 15.0 * uNoiseIntensity;
vec3 result = clamp(col * uIntensity, 0.0, 1.0);
if (uLightMode > 0.5) {
float energy = max(result.r, max(result.g, result.b));
vec3 hue = result / max(energy, 0.001);
float coverage = smoothstep(0.025, 0.95, energy);
hue = pow(clamp(hue, 0.0, 1.0), vec3(1.25));
result = mix(vec3(1.0), hue, coverage * 0.94);
}
gl_FragColor = vec4(result, 1.0);
}
`;
// Pre-compute wave rotation values
const waveAngle = 0.4;
const waveSinValues = new Float32Array(4);
const waveCosValues = new Float32Array(4);
for (let i = 0; i < 4; i++) {
waveSinValues[i] = Math.sin(waveAngle);
waveCosValues[i] = Math.cos(waveAngle);
}
// Pre-compute pillar rotation
const pillarRotRad = (pillarRotation * Math.PI) / 180.0;
const pillarRotCos = Math.cos(pillarRotRad);
const pillarRotSin = Math.sin(pillarRotRad);
const material = new THREE.ShaderMaterial({
vertexShader,
fragmentShader,
uniforms: {
uTime: { value: 0 },
uResolution: { value: new THREE.Vector2(width, height) },
uMouse: { value: mouseRef.current },
uTopColor: { value: parseColor(topColor) },
uBottomColor: { value: parseColor(bottomColor) },
uIntensity: { value: intensity },
uInteractive: { value: interactive },
uGlowAmount: { value: glowAmount },
uPillarWidth: { value: pillarWidth },
uPillarHeight: { value: pillarHeight },
uNoiseIntensity: { value: noiseIntensity },
uPillarRotation: { value: pillarRotation },
uRotCos: { value: 1.0 },
uRotSin: { value: 0.0 },
uPillarRotCos: { value: pillarRotCos },
uPillarRotSin: { value: pillarRotSin },
uWaveSin: { value: waveSinValues },
uWaveCos: { value: waveCosValues },
uLightMode: { value: lightMode ? 1 : 0 }
},
transparent: true,
depthWrite: false,
depthTest: false
});
materialRef.current = material;
const geometry = new THREE.PlaneGeometry(2, 2);
geometryRef.current = geometry;
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
// Mouse interaction - throttled for performance
let mouseMoveTimeout: number | null = null;
const handleMouseMove = (event: MouseEvent) => {
if (!interactive) return;
if (mouseMoveTimeout) return;
mouseMoveTimeout = window.setTimeout(() => {
mouseMoveTimeout = null;
}, 16); // ~60fps throttle
const rect = container.getBoundingClientRect();
const x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
const y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
mouseRef.current.set(x, y);
};
if (interactive) {
container.addEventListener('mousemove', handleMouseMove, { passive: true });
}
// Animation loop with fixed timestep
let lastTime = performance.now();
const targetFPS = effectiveQuality === 'low' ? 30 : 60;
const frameTime = 1000 / targetFPS;
const animate = (currentTime: number) => {
if (!materialRef.current || !rendererRef.current || !sceneRef.current || !cameraRef.current) return;
const deltaTime = currentTime - lastTime;
if (deltaTime >= frameTime) {
timeRef.current += 0.016 * rotationSpeedRef.current;
materialRef.current.uniforms.uTime.value = timeRef.current;
// Pre-compute rotation on CPU
const rotAngle = timeRef.current * 0.3;
materialRef.current.uniforms.uRotCos.value = Math.cos(rotAngle);
materialRef.current.uniforms.uRotSin.value = Math.sin(rotAngle);
rendererRef.current.render(sceneRef.current, cameraRef.current);
lastTime = currentTime - (deltaTime % frameTime);
}
rafRef.current = requestAnimationFrame(animate);
};
rafRef.current = requestAnimationFrame(animate);
// Handle resize with debouncing
let resizeTimeout: number | null = null;
const handleResize = () => {
if (resizeTimeout) {
clearTimeout(resizeTimeout);
}
resizeTimeout = window.setTimeout(() => {
if (!rendererRef.current || !materialRef.current || !containerRef.current) return;
const newWidth = containerRef.current.clientWidth;
const newHeight = containerRef.current.clientHeight;
rendererRef.current.setSize(newWidth, newHeight);
materialRef.current.uniforms.uResolution.value.set(newWidth, newHeight);
}, 150);
};
window.addEventListener('resize', handleResize, { passive: true });
// Cleanup
return () => {
window.removeEventListener('resize', handleResize);
if (interactive) {
container.removeEventListener('mousemove', handleMouseMove);
}
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
}
if (rendererRef.current) {
rendererRef.current.dispose();
rendererRef.current.forceContextLoss();
if (container.contains(rendererRef.current.domElement)) {
container.removeChild(rendererRef.current.domElement);
}
}
if (materialRef.current) {
materialRef.current.dispose();
}
if (geometryRef.current) {
geometryRef.current.dispose();
}
rendererRef.current = null;
materialRef.current = null;
sceneRef.current = null;
cameraRef.current = null;
geometryRef.current = null;
rafRef.current = null;
};
}, [webGLSupported, quality]);
useEffect(() => {
rotationSpeedRef.current = rotationSpeed;
}, [rotationSpeed]);
useEffect(() => {
if (!materialRef.current) return;
const parseColor = (hex: string) => {
const color = new THREE.Color(hex);
return new THREE.Vector3(color.r, color.g, color.b);
};
materialRef.current.uniforms.uTopColor.value = parseColor(topColor);
}, [topColor]);
useEffect(() => {
if (!materialRef.current) return;
const parseColor = (hex: string) => {
const color = new THREE.Color(hex);
return new THREE.Vector3(color.r, color.g, color.b);
};
materialRef.current.uniforms.uBottomColor.value = parseColor(bottomColor);
}, [bottomColor]);
useEffect(() => {
if (!materialRef.current) return;
materialRef.current.uniforms.uIntensity.value = intensity;
}, [intensity]);
useEffect(() => {
if (!materialRef.current) return;
materialRef.current.uniforms.uInteractive.value = interactive;
}, [interactive]);
useEffect(() => {
if (!materialRef.current) return;
materialRef.current.uniforms.uGlowAmount.value = glowAmount;
}, [glowAmount]);
useEffect(() => {
if (!materialRef.current) return;
materialRef.current.uniforms.uPillarWidth.value = pillarWidth;
}, [pillarWidth]);
useEffect(() => {
if (!materialRef.current) return;
materialRef.current.uniforms.uPillarHeight.value = pillarHeight;
}, [pillarHeight]);
useEffect(() => {
if (!materialRef.current) return;
materialRef.current.uniforms.uNoiseIntensity.value = noiseIntensity;
}, [noiseIntensity]);
useEffect(() => {
if (!materialRef.current) return;
materialRef.current.uniforms.uLightMode.value = lightMode ? 1 : 0;
}, [lightMode]);
useEffect(() => {
if (!materialRef.current) return;
const pillarRotRad = (pillarRotation * Math.PI) / 180;
materialRef.current.uniforms.uPillarRotCos.value = Math.cos(pillarRotRad);
materialRef.current.uniforms.uPillarRotSin.value = Math.sin(pillarRotRad);
}, [pillarRotation]);
if (!webGLSupported) {
return (
<div
className={`w-full h-full absolute top-0 left-0 flex items-center justify-center bg-black/10 text-gray-500 text-sm ${className}`}
style={{ mixBlendMode }}
>
WebGL not supported
</div>
);
}
return (
<div ref={containerRef} className={`w-full h-full absolute top-0 left-0 ${className}`} style={{ mixBlendMode }} />
);
};
export default LightPillar;