import React, { useEffect, useRef } from 'react'; import { Renderer, Program, Mesh, Geometry } from 'ogl'; import './GradientBlinds.css'; export interface GradientBlindsProps { className?: string; dpr?: number; paused?: boolean; gradientColors?: string[]; angle?: number; noise?: number; blindCount?: number; blindMinWidth?: number; mouseDampening?: number; mirrorGradient?: boolean; spotlightRadius?: number; spotlightSoftness?: number; spotlightOpacity?: number; distortAmount?: number; shineDirection?: 'left' | 'right'; mixBlendMode?: string; } const MAX_COLORS = 8; const hexToRGB = (hex: string): [number, number, number] => { const c = hex.replace('#', '').padEnd(6, '0'); const r = parseInt(c.slice(0, 2), 16) / 255; const g = parseInt(c.slice(2, 4), 16) / 255; const b = parseInt(c.slice(4, 6), 16) / 255; return [r, g, b]; }; const prepStops = (stops?: string[]) => { const base = (stops && stops.length ? stops : ['#FF9FFC', '#5227FF']).slice(0, MAX_COLORS); if (base.length === 1) base.push(base[0]); while (base.length < MAX_COLORS) base.push(base[base.length - 1]); const arr: [number, number, number][] = []; for (let i = 0; i < MAX_COLORS; i++) arr.push(hexToRGB(base[i])); const count = Math.max(2, Math.min(MAX_COLORS, stops?.length ?? 2)); return { arr, count }; }; const GradientBlinds: React.FC = ({ className, dpr, paused = false, gradientColors, angle = 0, noise = 0.3, blindCount = 16, blindMinWidth = 60, mouseDampening = 0.15, mirrorGradient = false, spotlightRadius = 0.5, spotlightSoftness = 1, spotlightOpacity = 1, distortAmount = 0, shineDirection = 'left', mixBlendMode = 'lighten' }) => { const containerRef = useRef(null); const rafRef = useRef(null); const programRef = useRef(null); const meshRef = useRef(null); const geometryRef = useRef(null); const rendererRef = useRef(null); const mouseTargetRef = useRef<[number, number]>([0, 0]); // Optimization: store raw pointer position (viewport coords) to decouple event handling from calculation const pointerPosRef = useRef<{ x: number; y: number } | null>(null); const isMobileRef = useRef(false); const lastTimeRef = useRef(0); const firstResizeRef = useRef(true); const rectRef = useRef(null); const scrollPosRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); useEffect(() => { const container = containerRef.current; if (!container) return; const renderer = new Renderer({ dpr: dpr ?? (typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1), alpha: true, antialias: true }); rendererRef.current = renderer; const gl = renderer.gl; const canvas = gl.canvas as HTMLCanvasElement; canvas.style.width = '100%'; canvas.style.height = '100%'; canvas.style.display = 'block'; container.appendChild(canvas); const vertex = ` attribute vec2 position; attribute vec2 uv; varying vec2 vUv; void main() { vUv = uv; gl_Position = vec4(position, 0.0, 1.0); } `; const fragment = ` #ifdef GL_ES precision highp float; #endif uniform vec3 iResolution; uniform vec2 iMouse; uniform float iTime; uniform float uAngle; uniform float uNoise; uniform float uBlindCount; uniform float uSpotlightRadius; uniform float uSpotlightSoftness; uniform float uSpotlightOpacity; uniform float uMirror; uniform float uDistort; uniform float uShineFlip; uniform vec3 uColor0; uniform vec3 uColor1; uniform vec3 uColor2; uniform vec3 uColor3; uniform vec3 uColor4; uniform vec3 uColor5; uniform vec3 uColor6; uniform vec3 uColor7; uniform int uColorCount; varying vec2 vUv; float rand(vec2 co){ return fract(sin(dot(co, vec2(12.9898,78.233))) * 43758.5453); } vec2 rotate2D(vec2 p, float a){ float c = cos(a); float s = sin(a); return mat2(c, -s, s, c) * p; } vec3 getGradientColor(float t){ float tt = clamp(t, 0.0, 1.0); int count = uColorCount; if (count < 2) count = 2; float scaled = tt * float(count - 1); float seg = floor(scaled); float f = fract(scaled); if (seg < 1.0) return mix(uColor0, uColor1, f); if (seg < 2.0 && count > 2) return mix(uColor1, uColor2, f); if (seg < 3.0 && count > 3) return mix(uColor2, uColor3, f); if (seg < 4.0 && count > 4) return mix(uColor3, uColor4, f); if (seg < 5.0 && count > 5) return mix(uColor4, uColor5, f); if (seg < 6.0 && count > 6) return mix(uColor5, uColor6, f); if (seg < 7.0 && count > 7) return mix(uColor6, uColor7, f); if (count > 7) return uColor7; if (count > 6) return uColor6; if (count > 5) return uColor5; if (count > 4) return uColor4; if (count > 3) return uColor3; if (count > 2) return uColor2; return uColor1; } void mainImage( out vec4 fragColor, in vec2 fragCoord ) { vec2 uv0 = fragCoord.xy / iResolution.xy; float aspect = iResolution.x / iResolution.y; vec2 p = uv0 * 2.0 - 1.0; p.x *= aspect; vec2 pr = rotate2D(p, uAngle); pr.x /= aspect; vec2 uv = pr * 0.5 + 0.5; vec2 uvMod = uv; if (uDistort > 0.0) { float a = uvMod.y * 6.0; float b = uvMod.x * 6.0; float w = 0.01 * uDistort; uvMod.x += sin(a) * w; uvMod.y += cos(b) * w; } float t = uvMod.x; if (uMirror > 0.5) { t = 1.0 - abs(1.0 - 2.0 * fract(t)); } vec3 base = getGradientColor(t); vec2 offset = vec2(iMouse.x/iResolution.x, iMouse.y/iResolution.y); float d = length(uv0 - offset); float r = max(uSpotlightRadius, 1e-4); float dn = d / r; float spot = (1.0 - 2.0 * pow(dn, uSpotlightSoftness)) * uSpotlightOpacity; vec3 cir = vec3(spot); float stripe = fract(uvMod.x * max(uBlindCount, 1.0)); if (uShineFlip > 0.5) stripe = 1.0 - stripe; vec3 ran = vec3(stripe); vec3 col = cir + base - ran; col += (rand(gl_FragCoord.xy + iTime) - 0.5) * uNoise; fragColor = vec4(col, 1.0); } void main() { vec4 color; mainImage(color, vUv * iResolution.xy); gl_FragColor = color; } `; const { arr: colorArr, count: colorCount } = prepStops(gradientColors); const uniforms: { iResolution: { value: [number, number, number] }; iMouse: { value: [number, number] }; iTime: { value: number }; uAngle: { value: number }; uNoise: { value: number }; uBlindCount: { value: number }; uSpotlightRadius: { value: number }; uSpotlightSoftness: { value: number }; uSpotlightOpacity: { value: number }; uMirror: { value: number }; uDistort: { value: number }; uShineFlip: { value: number }; uColor0: { value: [number, number, number] }; uColor1: { value: [number, number, number] }; uColor2: { value: [number, number, number] }; uColor3: { value: [number, number, number] }; uColor4: { value: [number, number, number] }; uColor5: { value: [number, number, number] }; uColor6: { value: [number, number, number] }; uColor7: { value: [number, number, number] }; uColorCount: { value: number }; } = { iResolution: { value: [gl.drawingBufferWidth, gl.drawingBufferHeight, 1] }, iMouse: { value: [0, 0] }, iTime: { value: 0 }, uAngle: { value: (angle * Math.PI) / 180 }, uNoise: { value: noise }, uBlindCount: { value: Math.max(1, blindCount) }, uSpotlightRadius: { value: spotlightRadius }, uSpotlightSoftness: { value: spotlightSoftness }, uSpotlightOpacity: { value: spotlightOpacity }, uMirror: { value: mirrorGradient ? 1 : 0 }, uDistort: { value: distortAmount }, uShineFlip: { value: shineDirection === 'right' ? 1 : 0 }, uColor0: { value: colorArr[0] }, uColor1: { value: colorArr[1] }, uColor2: { value: colorArr[2] }, uColor3: { value: colorArr[3] }, uColor4: { value: colorArr[4] }, uColor5: { value: colorArr[5] }, uColor6: { value: colorArr[6] }, uColor7: { value: colorArr[7] }, uColorCount: { value: colorCount } }; const program = new Program(gl, { vertex, fragment, uniforms }); programRef.current = program; // Create a quad (two triangles) to ensure full screen coverage without relying on a single large triangle // which might face precision or clipping issues on some GPU/browser combinations. const geometry = new Geometry(gl, { position: { size: 2, data: new Float32Array([-1, -1, 3, -1, -1, 3]) }, uv: { size: 2, data: new Float32Array([0, 0, 2, 0, 0, 2]) } }); geometryRef.current = geometry; const mesh = new Mesh(gl, { geometry, program }); meshRef.current = mesh; const resize = () => { const rect = container.getBoundingClientRect(); rectRef.current = rect; if (typeof window !== 'undefined') { isMobileRef.current = window.innerWidth <= 768; scrollPosRef.current = { x: window.scrollX, y: window.scrollY }; } renderer.setSize(rect.width, rect.height); uniforms.iResolution.value = [gl.drawingBufferWidth, gl.drawingBufferHeight, 1]; if (blindMinWidth && blindMinWidth > 0) { const maxByMinWidth = Math.max(1, Math.floor(rect.width / blindMinWidth)); const effective = blindCount ? Math.min(blindCount, maxByMinWidth) : maxByMinWidth; uniforms.uBlindCount.value = Math.max(1, effective); } else { uniforms.uBlindCount.value = Math.max(1, blindCount); } if (firstResizeRef.current) { firstResizeRef.current = false; const cx = gl.drawingBufferWidth / 2; const cy = gl.drawingBufferHeight / 2; uniforms.iMouse.value = [cx, cy]; mouseTargetRef.current = [cx, cy]; } }; resize(); const ro = new ResizeObserver(resize); ro.observe(container); const onPointerMove = (e: PointerEvent) => { if (isMobileRef.current) { // On mobile, calculate relative position immediately and store in target. // This prevents the "ghost drift" effect when scrolling with inertia after lifting finger, // because we won't be updating the target based on scroll position in the loop. const scale = (renderer as unknown as { dpr?: number }).dpr || 1; let x, y; if (rectRef.current) { const dx = window.scrollX - scrollPosRef.current.x; const dy = window.scrollY - scrollPosRef.current.y; const rectLeft = rectRef.current.left - dx; const rectTop = rectRef.current.top - dy; x = (e.clientX - rectLeft) * scale; y = (rectRef.current.height - (e.clientY - rectTop)) * scale; } else { const rect = canvas.getBoundingClientRect(); x = (e.clientX - rect.left) * scale; y = (rect.height - (e.clientY - rect.top)) * scale; } mouseTargetRef.current = [x, y]; pointerPosRef.current = null; // Ensure loop doesn't override } else { pointerPosRef.current = { x: e.clientX, y: e.clientY }; } }; const loop = (t: number) => { rafRef.current = requestAnimationFrame(loop); uniforms.iTime.value = t * 0.001; // Update target based on pointer position and scroll offset if (pointerPosRef.current) { const scale = (renderer as unknown as { dpr?: number }).dpr || 1; let x, y; if (rectRef.current) { const dx = window.scrollX - scrollPosRef.current.x; const dy = window.scrollY - scrollPosRef.current.y; const rectLeft = rectRef.current.left - dx; const rectTop = rectRef.current.top - dy; x = (pointerPosRef.current.x - rectLeft) * scale; y = (rectRef.current.height - (pointerPosRef.current.y - rectTop)) * scale; } else { // Fallback if rectRef missing const rect = canvas.getBoundingClientRect(); x = (pointerPosRef.current.x - rect.left) * scale; y = (rect.height - (pointerPosRef.current.y - rect.top)) * scale; } mouseTargetRef.current = [x, y]; } if (mouseDampening > 0) { if (!lastTimeRef.current) lastTimeRef.current = t; const dt = (t - lastTimeRef.current) / 1000; lastTimeRef.current = t; const tau = Math.max(1e-4, mouseDampening); let factor = 1 - Math.exp(-dt / tau); if (factor > 1) factor = 1; const target = mouseTargetRef.current; const cur = uniforms.iMouse.value; cur[0] += (target[0] - cur[0]) * factor; cur[1] += (target[1] - cur[1]) * factor; } else { if (pointerPosRef.current || isMobileRef.current) { uniforms.iMouse.value = mouseTargetRef.current; } lastTimeRef.current = t; } if (!paused && programRef.current && meshRef.current) { try { renderer.render({ scene: meshRef.current }); } catch (e) { console.error(e); } } }; const observer = new IntersectionObserver(([entry]) => { if (entry.isIntersecting) { if (!rafRef.current) { lastTimeRef.current = 0; rafRef.current = requestAnimationFrame(loop); } window.addEventListener('pointermove', onPointerMove); } else { if (rafRef.current) { cancelAnimationFrame(rafRef.current); rafRef.current = null; } window.removeEventListener('pointermove', onPointerMove); } }); observer.observe(container); return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); window.removeEventListener('pointermove', onPointerMove); observer.disconnect(); ro.disconnect(); if (canvas.parentElement === container) { container.removeChild(canvas); } const callIfFn = (obj: T | null, key: K) => { if (obj && typeof obj[key] === 'function') { (obj[key] as unknown as () => void).call(obj); } }; callIfFn(programRef.current, 'remove'); callIfFn(geometryRef.current, 'remove'); callIfFn(meshRef.current as unknown as { remove?: () => void }, 'remove'); callIfFn(rendererRef.current as unknown as { destroy?: () => void }, 'destroy'); programRef.current = null; geometryRef.current = null; meshRef.current = null; rendererRef.current = null; }; }, [ dpr, paused, gradientColors, angle, noise, blindCount, blindMinWidth, mouseDampening, mirrorGradient, spotlightRadius, spotlightSoftness, spotlightOpacity, distortAmount, shineDirection ]); return (
); }; export default React.memo(GradientBlinds);