--- title: 3d Smokey Frame description: An interactive WebGL volumetric noise smokey frame shader featuring customizable frame width, wave propagation speed, color palettes, atmospheric glow, and cursor-reactive turbulence displacement. --- ## Installation CLI Manual ```bash npx lightswind@latest add 3d-smokey-frame ``` Copy and paste the following code into your project. ```tsx "use client"; import React, { useRef, useEffect, useCallback, PropsWithChildren, CSSProperties, forwardRef, useImperativeHandle } from "react"; import { useTheme } from "next-themes"; import { cn } from "@/components/lib/utils"; export interface ThreeDSmokeyFrameProps extends PropsWithChildren { /** Color of the animated smokey frame (Hex or RGB/RGBA, default: "#00F5FF" Cosmic Cyan) */ frameColor?: string; /** Background interior base color (used when transparentBg is false) */ frameBgColor?: string; /** Whether the interior background is completely transparent (default: true) */ transparentBg?: boolean; /** Normalized frame width from edge toward center (0.01 - 0.50, default: 0.30) */ frameWidth?: number; /** Animation wave and smoke propagation speed multiplier (default: 0.15) */ speed?: number; /** Edge falloff curve exponent (higher values make the frame edge sharper, default: 6.0) */ falloff?: number; /** Granularity and density of the procedural noise smoke (default: 3.0) */ noiseScale?: number; /** Amount of noise turbulence modulating the frame (0.0 - 1.0, default: 1.0) */ noiseStrength?: number; /** Brightness and emission intensity of the smoke (default: 1.2) */ intensity?: number; /** Gamma curve contrast adjustment (default: 2.0) */ gamma?: number; /** Overall canvas opacity (0.0 - 1.0, default: 1.0) */ opacity?: number; /** Whether cursor movement dynamically displaces the smokey field (default: true) */ interactive?: boolean; /** Enable external atmospheric colored glow behind the frame container (default: true) */ glow?: boolean; /** Blur radius for external atmospheric glow in pixels (default: 36) */ glowBlur?: number; /** Opacity for external ambient glow (default: 0.45) */ glowOpacity?: number; /** Border radius for the frame container (default: "16px") */ radius?: string | number; /** Optional class name for the outer container */ className?: string; /** Optional class name for the canvas element */ canvasClassName?: string; /** Optional inline styles */ style?: CSSProperties; /** Maximum device pixel ratio to use for rendering (default: 2) */ dpr?: number; } export interface ThreeDSmokeyFrameHandle { getCanvas: () => HTMLCanvasElement | null; getGL: () => WebGLRenderingContext | null; } /** Utility to parse Hex or RGB strings to normalized [r, g, b] float vectors */ function parseColorToRgb(color: string, fallback: [number, number, number] = [0, 0.96, 1]): [number, number, number] { if (!color) return fallback; const clean = color.trim(); if (clean.startsWith("#")) { let hex = clean.replace("#", ""); if (hex.length === 3) { hex = hex.split("").map((c) => c + c).join(""); } const num = parseInt(hex, 16); if (isNaN(num)) return fallback; return [ ((num >> 16) & 255) / 255, ((num >> 8) & 255) / 255, (num & 255) / 255, ]; } const match = clean.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i); if (match) { return [ parseInt(match[1], 10) / 255, parseInt(match[2], 10) / 255, parseInt(match[3], 10) / 255, ]; } return fallback; } const VERTEX_SHADER = ` attribute vec2 a_position; varying vec2 v_uv; void main() { v_uv = (a_position + 1.0) * 0.5; gl_Position = vec4(a_position, 0.0, 1.0); } `; const FRAGMENT_SHADER = ` precision highp float; varying vec2 v_uv; uniform vec2 u_resolution; uniform float u_time; uniform float u_speed; uniform float u_frameWidth; uniform float u_falloff; uniform float u_noiseScale; uniform float u_noiseStrength; uniform float u_intensity; uniform float u_gamma; uniform float u_opacity; uniform vec3 u_frameColor; uniform vec3 u_frameBgColor; uniform float u_transparentBg; uniform vec2 u_mouse; uniform float u_isHovered; // Simplex 2D noise generator 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 = (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); } // 5-Octave Fractional Brownian Motion for lush smoke tendrils float fbm(vec2 p) { float total = 0.0; float amp = 0.5; float freq = 1.0; for(int i = 0; i < 5; i++) { total += snoise(p * freq) * amp; freq *= 2.02; amp *= 0.5; } return total; } void main() { vec2 uv = v_uv; float aspect = u_resolution.x / u_resolution.y; // Aspect ratio correction for noise sampling vec2 noiseUV = uv; if (aspect > 1.0) { noiseUV.x *= aspect; } else { noiseUV.y /= aspect; } // Interactive mouse turbulence displacement if (u_isHovered > 0.0) { vec2 mouseUV = u_mouse; if (aspect > 1.0) mouseUV.x *= aspect; else mouseUV.y /= aspect; float dMouse = distance(noiseUV, mouseUV); float mouseInfluence = smoothstep(0.6, 0.0, dMouse); noiseUV += (noiseUV - mouseUV) * mouseInfluence * 0.12 * u_isHovered; } // Animated volumetric noise field float t = u_time * u_speed; float noise1 = fbm(noiseUV * u_noiseScale + vec2(t * 0.45, t * 0.28)); float noise2 = fbm(noiseUV * (u_noiseScale * 1.5) - vec2(t * 0.32, -t * 0.4)); float combinedNoise = (noise1 * 0.65 + noise2 * 0.35 + 1.0) * 0.5; // Distance calculation from all 4 boundaries (0 at boundary, 0.5 at center) vec2 distToEdge = min(uv, 1.0 - uv); float minAxisDist = min(distToEdge.x, distToEdge.y); // Normalize distance based on the frameWidth parameter float frameDist = clamp(minAxisDist / max(u_frameWidth, 0.0001), 0.0, 1.0); // Invert so frame edge = 1.0, interior core = 0.0 float edgeStrength = 1.0 - frameDist; edgeStrength = pow(edgeStrength, u_falloff); // Modulate edge with procedural smoke noise float modulatedFrame = mix(edgeStrength, edgeStrength * combinedNoise, u_noiseStrength); // Apply intensity multiplier and gamma curve for rich contrast float finalGlow = pow(modulatedFrame * u_intensity, u_gamma); finalGlow = clamp(finalGlow, 0.0, 1.0); // Render with transparent background or blended solid background if (u_transparentBg > 0.5) { float alpha = finalGlow * u_opacity; gl_FragColor = vec4(u_frameColor, alpha); } else { vec3 finalColor = mix(u_frameBgColor, u_frameColor, finalGlow); gl_FragColor = vec4(finalColor, u_opacity); } } `; function createShader(gl: WebGLRenderingContext, type: number, source: string): WebGLShader | null { const shader = gl.createShader(type); if (!shader) return null; gl.shaderSource(shader, source); gl.compileShader(shader); if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { console.error("Shader compile error:", gl.getShaderInfoLog(shader)); gl.deleteShader(shader); return null; } return shader; } export const ThreeDSmokeyFrame = forwardRef(({ children, frameColor = "#00F5FF", frameBgColor, transparentBg = true, frameWidth = 0.30, speed = 0.15, falloff = 6.0, noiseScale = 3.0, noiseStrength = 1.0, intensity = 1.2, gamma = 2.0, opacity = 1.0, interactive = true, glow = true, glowBlur = 36, glowOpacity = 0.45, radius = "16px", className, canvasClassName, style, dpr = 2, }, ref) => { const containerRef = useRef(null); const canvasRef = useRef(null); const glRef = useRef(null); const animFrameRef = useRef(null); const isVisibleRef = useRef(true); const { resolvedTheme, theme } = useTheme(); const isLightMode = resolvedTheme === "light" || theme === "light"; const effectiveBgColor = frameBgColor ?? (isLightMode ? "#ffffff" : "#08080a"); const mousePosRef = useRef<{ x: number; y: number }>({ x: 0.5, y: 0.5 }); const isHoveredRef = useRef(0); const startTimeRef = useRef(performance.now()); const parsedRadius = typeof radius === "number" ? `${radius}px` : radius; useImperativeHandle(ref, () => ({ getCanvas: () => canvasRef.current, getGL: () => glRef.current, })); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const gl = canvas.getContext("webgl", { alpha: true, antialias: true, depth: false, preserveDrawingBuffer: false, }); if (!gl) { console.warn("WebGL not supported for ThreeDSmokeyFrame"); return; } glRef.current = gl; gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); const vs = createShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER); const fs = createShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER); if (!vs || !fs) return; const program = gl.createProgram(); if (!program) return; gl.attachShader(program, vs); gl.attachShader(program, fs); gl.linkProgram(program); if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { console.error("Program link error:", gl.getProgramInfoLog(program)); return; } gl.useProgram(program); // Quad Geometry Buffers const positionBuffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); gl.bufferData( gl.ARRAY_BUFFER, new Float32Array([ -1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1, ]), gl.STATIC_DRAW ); const positionLocation = gl.getAttribLocation(program, "a_position"); gl.enableVertexAttribArray(positionLocation); gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0); // Uniform Locations const uniforms = { resolution: gl.getUniformLocation(program, "u_resolution"), time: gl.getUniformLocation(program, "u_time"), speed: gl.getUniformLocation(program, "u_speed"), frameWidth: gl.getUniformLocation(program, "u_frameWidth"), falloff: gl.getUniformLocation(program, "u_falloff"), noiseScale: gl.getUniformLocation(program, "u_noiseScale"), noiseStrength: gl.getUniformLocation(program, "u_noiseStrength"), intensity: gl.getUniformLocation(program, "u_intensity"), gamma: gl.getUniformLocation(program, "u_gamma"), opacity: gl.getUniformLocation(program, "u_opacity"), frameColor: gl.getUniformLocation(program, "u_frameColor"), frameBgColor: gl.getUniformLocation(program, "u_frameBgColor"), transparentBg: gl.getUniformLocation(program, "u_transparentBg"), mouse: gl.getUniformLocation(program, "u_mouse"), isHovered: gl.getUniformLocation(program, "u_isHovered"), }; const handleResize = () => { if (!canvas || !gl) return; const targetDpr = Math.min(window.devicePixelRatio || 1, dpr); const displayWidth = Math.round(canvas.clientWidth * targetDpr); const displayHeight = Math.round(canvas.clientHeight * targetDpr); if (canvas.width !== displayWidth || canvas.height !== displayHeight) { canvas.width = Math.max(1, displayWidth); canvas.height = Math.max(1, displayHeight); gl.viewport(0, 0, canvas.width, canvas.height); } }; handleResize(); const resizeObserver = new ResizeObserver(() => { handleResize(); }); resizeObserver.observe(canvas); const intersectionObserver = new IntersectionObserver( ([entry]) => { isVisibleRef.current = entry.isIntersecting; }, { threshold: 0.05 } ); intersectionObserver.observe(canvas); let currentHover = 0; const render = () => { if (isVisibleRef.current && gl && canvas) { handleResize(); const time = (performance.now() - startTimeRef.current) * 0.001; const fColor = parseColorToRgb(frameColor, [0, 0.96, 1]); const bColor = parseColorToRgb(effectiveBgColor, [0.04, 0.04, 0.04]); // Smooth hover transition const targetHover = isHoveredRef.current; currentHover += (targetHover - currentHover) * 0.1; gl.uniform2f(uniforms.resolution, canvas.width, canvas.height); gl.uniform1f(uniforms.time, time); gl.uniform1f(uniforms.speed, speed); gl.uniform1f(uniforms.frameWidth, frameWidth); gl.uniform1f(uniforms.falloff, falloff); gl.uniform1f(uniforms.noiseScale, noiseScale); gl.uniform1f(uniforms.noiseStrength, noiseStrength); gl.uniform1f(uniforms.intensity, intensity); gl.uniform1f(uniforms.gamma, gamma); gl.uniform1f(uniforms.opacity, opacity); gl.uniform3f(uniforms.frameColor, fColor[0], fColor[1], fColor[2]); gl.uniform3f(uniforms.frameBgColor, bColor[0], bColor[1], bColor[2]); gl.uniform1f(uniforms.transparentBg, transparentBg ? 1.0 : 0.0); gl.uniform2f(uniforms.mouse, mousePosRef.current.x, mousePosRef.current.y); gl.uniform1f(uniforms.isHovered, currentHover); gl.drawArrays(gl.TRIANGLES, 0, 6); } animFrameRef.current = requestAnimationFrame(render); }; animFrameRef.current = requestAnimationFrame(render); return () => { if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current); resizeObserver.disconnect(); intersectionObserver.disconnect(); if (gl) { gl.deleteProgram(program); gl.deleteShader(vs); gl.deleteShader(fs); gl.deleteBuffer(positionBuffer); } }; }, [ frameColor, effectiveBgColor, transparentBg, frameWidth, speed, falloff, noiseScale, noiseStrength, intensity, gamma, opacity, dpr, ]); const handleMouseMove = useCallback((e: React.MouseEvent) => { if (!interactive || !containerRef.current) return; const rect = containerRef.current.getBoundingClientRect(); const x = (e.clientX - rect.left) / rect.width; const y = 1.0 - (e.clientY - rect.top) / rect.height; // Invert for WebGL UV coords mousePosRef.current = { x, y }; }, [interactive]); const handleMouseEnter = useCallback(() => { if (interactive) isHoveredRef.current = 1.0; }, [interactive]); const handleMouseLeave = useCallback(() => { if (interactive) isHoveredRef.current = 0.0; }, [interactive]); return (
{/* Ambient Atmosphere Glow Backdrop */} {glow && (
)} {/* WebGL Canvas Shader Output */} {/* Slotted Children Content Layer */} {children && (
{children}
)}
); }); ThreeDSmokeyFrame.displayName = "ThreeDSmokeyFrame"; export default ThreeDSmokeyFrame; ``` ## Usage ```tsx import ThreeDSmokeyFrame from "@/components/lightswind/3d-smokey-frame"; ``` ```tsx import ThreeDSmokeyFrame from "@/components/lightswind/3d-smokey-frame"; export default function Example() { return (
); } ```