--- title: Infinite Drift 2 description: An advanced WebGL-powered 3D inverted dome lens gallery featuring procedural infinite tiled image grids, real-time radial curvature distortion, multi-touch inertial drag physics, automatic smooth drift panning, and customizable tile rotation, zoom, vignette, and grayscale filters. --- ## Installation CLI Manual ```bash npx lightswind@latest add infinite-drift-2 ``` Copy and paste the following code into your project. ```tsx "use client"; import React, { useRef, useEffect, useCallback, forwardRef, useImperativeHandle, } from "react"; import { cn } from "@/components/lib/utils"; export interface InfiniteDrift2Props { /** Array of image URLs to tile across the 3D dome */ images?: string[]; /** Tile width in pixels */ tileWidth?: number; /** Tile height in pixels */ tileHeight?: number; /** Gap between tiles in pixels */ gap?: number; /** Rounded corner radius for each tile in pixels */ borderRadius?: number; /** Curve lens distortion factor (0 = flat grid, 0.35 = subtle dome, 0.8+ = deep fisheye) */ curve?: number; /** Radial reach threshold for the dome curve */ curveRadius?: number; /** Zoom scale multiplier */ zoom?: number; /** Rotation angle in degrees for individual tiles */ tileRotation?: number; /** Grayscale amount (0.0 = full color, 1.0 = pure monochrome) */ grayscale?: number; /** Vignette edge darkening intensity (0.0 to 1.0) */ vignette?: number; /** Automatic continuous scrolling speed along X axis */ autoScrollX?: number; /** Automatic continuous scrolling speed along Y axis */ autoScrollY?: number; /** Enable mouse & touch drag panning */ enableDrag?: boolean; /** Enable mouse wheel panning */ enableWheel?: boolean; /** Allowed panning axis */ axis?: "both" | "x" | "y"; /** Sensitivity factor for dragging */ dragSensitivity?: number; /** Sensitivity factor for mouse wheel */ wheelSensitivity?: number; /** Friction / inertia decay factor (0.80 to 0.98) */ friction?: number; /** Background color behind the canvas */ backgroundColor?: string; /** Maximum device pixel ratio for sharp rendering */ dpr?: number; /** Container height in px or string */ height?: number | string; /** Container width in px or string */ width?: number | string; /** Custom container className */ className?: string; /** Optional overlay children (e.g. hero text, buttons) */ children?: React.ReactNode; } export interface InfiniteDrift2Ref { resetOffset: () => void; setOffset: (x: number, y: number) => void; getOffset: () => { x: number; y: number }; } // Backwards-compatibility alias export type InvertedDomeProps = InfiniteDrift2Props; const DEFAULT_IMAGES = [ "https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?auto=format&fit=crop&w=600&q=80", "https://images.unsplash.com/photo-1507679799987-c73779587ccf?auto=format&fit=crop&w=600&q=80", "https://images.unsplash.com/photo-1579783900882-c0d3dad7b119?auto=format&fit=crop&w=600&q=80", "https://images.unsplash.com/photo-1550684848-fac1c5b4e853?auto=format&fit=crop&w=600&q=80", "https://images.unsplash.com/photo-1541701494587-cb58502866ab?auto=format&fit=crop&w=600&q=80", "https://images.unsplash.com/photo-1518770660439-4636190af475?auto=format&fit=crop&w=600&q=80", "https://images.unsplash.com/photo-1550684399-3f0f745771d1?auto=format&fit=crop&w=600&q=80", "https://images.unsplash.com/photo-1563089145-599997674d42?auto=format&fit=crop&w=600&q=80", "https://images.unsplash.com/photo-1513694203232-719a280e022f?auto=format&fit=crop&w=600&q=80", ]; // Vertex Shader: Fullscreen quad const VS_SOURCE = ` attribute vec2 aPosition; varying vec2 vUv; void main() { vUv = aPosition * 0.5 + 0.5; gl_Position = vec4(aPosition, 0.0, 1.0); } `; // Fragment Shader: 3D Inverted Dome Lens Distortion & Procedural Tiled Grid const FS_SOURCE = ` precision highp float; varying vec2 vUv; uniform vec2 uResolution; uniform vec2 uOffset; uniform vec2 uTileSize; uniform float uGap; uniform float uBorderRadius; uniform float uCurve; uniform float uCurveRadius; uniform float uZoom; uniform float uTileRotation; uniform float uGrayscale; uniform float uVignette; uniform sampler2D uAtlas; uniform vec2 uAtlasGrid; // Signed distance function for rounded rectangle float sdRoundedBox(vec2 p, vec2 b, float r) { vec2 q = abs(p) - b + r; return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r; } void main() { // 1. Normalized center coordinates (-1 to 1 based on minimal dimension) vec2 p = (gl_FragCoord.xy - 0.5 * uResolution) / min(uResolution.x, uResolution.y); float r = length(p); // 2. Inverted Dome / Lens Distortion Formula float maxDist = max(0.001, uCurveRadius); float normR = clamp(r / maxDist, 0.0, 1.0); float distortion = 1.0 + uCurve * pow(normR, 2.0) - uZoom; vec2 distortedCoord = p * distortion; // Convert back to pixel coordinate space with dynamic panning offset vec2 worldPos = distortedCoord * min(uResolution.x, uResolution.y) + uOffset; // 3. Tile Calculations vec2 stepSize = uTileSize + vec2(uGap); vec2 cell = floor(worldPos / stepSize); vec2 localPos = mod(worldPos, stepSize) - 0.5 * stepSize; // Tile individual rotation float angle = radians(uTileRotation); mat2 rot = mat2(cos(angle), -sin(angle), sin(angle), cos(angle)); vec2 rotatedLocal = rot * localPos; // Anti-aliased rounded rectangle mask float d = sdRoundedBox(rotatedLocal, uTileSize * 0.5, uBorderRadius); float alpha = 1.0 - smoothstep(-0.8, 0.8, d); if (alpha <= 0.0) { discard; } // 4. Map cell coordinates to atlas texture grid (3x3 default) vec2 tileUv = clamp((rotatedLocal / uTileSize) + 0.5, 0.0, 1.0); // Atlas index with positive modulo vec2 atlasCell = mod(mod(cell, uAtlasGrid) + uAtlasGrid, uAtlasGrid); // Invert row index to match Canvas 2D top-down vs WebGL bottom-up float atlasRow = uAtlasGrid.y - 1.0 - atlasCell.y; vec2 uv = (vec2(atlasCell.x, atlasRow) + tileUv) / uAtlasGrid; vec4 texColor = texture2D(uAtlas, uv); // Grayscale transition float gray = dot(texColor.rgb, vec3(0.299, 0.587, 0.114)); vec3 color = mix(texColor.rgb, vec3(gray), clamp(uGrayscale, 0.0, 1.0)); // Vignette lighting float vigFactor = smoothstep(0.35, 1.15, r * (1.0 + uVignette * 0.5)); color = mix(color, color * (1.0 - uVignette * vigFactor), clamp(uVignette, 0.0, 1.0)); gl_FragColor = vec4(color, texColor.a * alpha); } `; export const InfiniteDrift2 = forwardRef< InfiniteDrift2Ref, InfiniteDrift2Props >( ( { images = DEFAULT_IMAGES, tileWidth = 220, tileHeight = 220, gap = 18, borderRadius = 18, curve = 0.35, curveRadius = 1.5, zoom = 0.08, tileRotation = 0, grayscale = 0.0, vignette = 0.25, autoScrollX = 0.4, autoScrollY = 0.15, enableDrag = true, enableWheel = true, axis = "both", dragSensitivity = 1.0, wheelSensitivity = 0.6, friction = 0.92, backgroundColor = "transparent", dpr = 2, height = 620, width = "100%", className = "", children, }, ref ) => { const canvasRef = useRef(null); const containerRef = useRef(null); const offset = useRef({ x: 0, y: 0 }); const velocity = useRef({ x: 0, y: 0 }); const isDragging = useRef(false); const lastPointer = useRef({ x: 0, y: 0 }); useImperativeHandle(ref, () => ({ resetOffset: () => { offset.current = { x: 0, y: 0 }; velocity.current = { x: 0, y: 0 }; }, setOffset: (x: number, y: number) => { offset.current = { x, y }; }, getOffset: () => ({ ...offset.current }), })); const propsRef = useRef({ tileWidth, tileHeight, gap, borderRadius, curve, curveRadius, zoom, tileRotation, grayscale, vignette, autoScrollX, autoScrollY, axis, friction, dpr, }); useEffect(() => { propsRef.current = { tileWidth, tileHeight, gap, borderRadius, curve, curveRadius, zoom, tileRotation, grayscale, vignette, autoScrollX, autoScrollY, axis, friction, dpr, }; }); // WebGL Context & Shaders Setup useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; let isMounted = true; const gl = (canvas.getContext("webgl", { alpha: true, antialias: true, premultipliedAlpha: false, }) || canvas.getContext("experimental-webgl")) as WebGLRenderingContext | null; if (!gl) return; // Helper: Compile GLSL shader const compileShader = (source: string, type: number) => { 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(gl.getShaderInfoLog(shader)); gl.deleteShader(shader); return null; } return shader; }; const vs = compileShader(VS_SOURCE, gl.VERTEX_SHADER); const fs = compileShader(FS_SOURCE, gl.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(gl.getProgramInfoLog(program)); return; } gl.useProgram(program); // Fullscreen quad buffer const buffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buffer); gl.bufferData( gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW ); const aPos = gl.getAttribLocation(program, "aPosition"); gl.enableVertexAttribArray(aPos); gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0); // Build 3x3 Texture Atlas const atlasCanvas = document.createElement("canvas"); const atlasDim = 1536; const gridCount = 3; const cellDim = atlasDim / gridCount; atlasCanvas.width = atlasDim; atlasCanvas.height = atlasDim; const ctx = atlasCanvas.getContext("2d", { willReadFrequently: true }); // Initialize with sleek, rich procedural gradient artwork as instant fallback if (ctx) { const palette = [ ["#38bdf8", "#1d4ed8"], ["#ec4899", "#8b5cf6"], ["#10b981", "#06b6d4"], ["#f59e0b", "#ef4444"], ["#a855f7", "#ec4899"], ["#6366f1", "#4338ca"], ["#06b6d4", "#0284c7"], ["#f43f5e", "#fb923c"], ["#14b8a6", "#047857"], ]; const titles = [ "CYBER", "NEBULA", "SPECTRUM", "KINETIC", "SPATIAL", "LUMINA", "PRISM", "HORIZON", "AURORA" ]; for (let i = 0; i < 9; i++) { const gx = i % gridCount; const gy = Math.floor(i / gridCount); const x = gx * cellDim; const y = gy * cellDim; const grad = ctx.createLinearGradient(x, y, x + cellDim, y + cellDim); const [c1, c2] = palette[i % palette.length]; grad.addColorStop(0, c1); grad.addColorStop(1, c2); ctx.fillStyle = grad; ctx.fillRect(x, y, cellDim, cellDim); // Subtle inner vignette const radGrad = ctx.createRadialGradient( x + cellDim / 2, y + cellDim / 2, cellDim * 0.1, x + cellDim / 2, y + cellDim / 2, cellDim * 0.7 ); radGrad.addColorStop(0, "rgba(255,255,255,0.2)"); radGrad.addColorStop(0.7, "rgba(0,0,0,0.15)"); radGrad.addColorStop(1, "rgba(0,0,0,0.5)"); ctx.fillStyle = radGrad; ctx.fillRect(x, y, cellDim, cellDim); // Grid line pattern ctx.strokeStyle = "rgba(255,255,255,0.12)"; ctx.lineWidth = 3; for (let step = 64; step < cellDim; step += 64) { ctx.beginPath(); ctx.moveTo(x + step, y); ctx.lineTo(x + step, y + cellDim); ctx.stroke(); ctx.beginPath(); ctx.moveTo(x, y + step); ctx.lineTo(x + cellDim, y + step); ctx.stroke(); } // Badge title ctx.fillStyle = "#ffffff"; ctx.font = "bold 44px sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(titles[i], x + cellDim / 2, y + cellDim / 2); } } const texture = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, texture); gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true); // NOTE: For NPOT textures in WebGL 1.0, wrap modes MUST be CLAMP_TO_EDGE. // Using REPEAT will cause the texture to be incomplete and render solid black! gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); gl.texImage2D( gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, atlasCanvas ); // Asynchronously load real images into the atlas const imageList = images && images.length > 0 ? images : DEFAULT_IMAGES; imageList.slice(0, 9).forEach((url, i) => { const img = new Image(); img.crossOrigin = "anonymous"; img.onload = () => { if (!isMounted || !ctx || !gl) return; const gx = i % gridCount; const gy = Math.floor(i / gridCount); const x = gx * cellDim; const y = gy * cellDim; // Cover crop const imgAspect = img.naturalWidth / (img.naturalHeight || 1); let sx = 0, sy = 0, sw = img.naturalWidth, sh = img.naturalHeight; if (imgAspect > 1) { sw = sh; sx = (img.naturalWidth - sw) / 2; } else { sh = sw; sy = (img.naturalHeight - sh) / 2; } ctx.drawImage(img, sx, sy, sw, sh, x, y, cellDim, cellDim); try { gl.bindTexture(gl.TEXTURE_2D, texture); gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true); gl.texImage2D( gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, atlasCanvas ); } catch (e) { console.warn("Could not upload texture to WebGL:", e); } }; img.src = url; }); // Uniform Locations const uRes = gl.getUniformLocation(program, "uResolution"); const uOff = gl.getUniformLocation(program, "uOffset"); const uTile = gl.getUniformLocation(program, "uTileSize"); const uG = gl.getUniformLocation(program, "uGap"); const uRad = gl.getUniformLocation(program, "uBorderRadius"); const uCurv = gl.getUniformLocation(program, "uCurve"); const uCurvRad = gl.getUniformLocation(program, "uCurveRadius"); const uZm = gl.getUniformLocation(program, "uZoom"); const uRot = gl.getUniformLocation(program, "uTileRotation"); const uGray = gl.getUniformLocation(program, "uGrayscale"); const uVig = gl.getUniformLocation(program, "uVignette"); const uAtlas = gl.getUniformLocation(program, "uAtlas"); const uAtlasGrid = gl.getUniformLocation(program, "uAtlasGrid"); gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); let animId: number; let lastTime = performance.now(); const render = (time: number) => { const dt = Math.min(0.1, (time - lastTime) / 1000); lastTime = time; const p = propsRef.current; // Inertia & Auto-scroll if (!isDragging.current) { if (p.axis !== "y") { offset.current.x += (velocity.current.x + p.autoScrollX * 60) * dt; } if (p.axis !== "x") { offset.current.y += (velocity.current.y + p.autoScrollY * 60) * dt; } velocity.current.x *= Math.pow(p.friction, dt * 60); velocity.current.y *= Math.pow(p.friction, dt * 60); } // Viewport resize handling const targetDpr = Math.min(window.devicePixelRatio || 1, p.dpr); const w = Math.floor(canvas.clientWidth * targetDpr); const h = Math.floor(canvas.clientHeight * targetDpr); if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; gl.viewport(0, 0, w, h); } gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT); // Bind active texture unit 0 gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, texture); gl.uniform1i(uAtlas, 0); gl.uniform2f(uRes, w, h); gl.uniform2f(uOff, offset.current.x, offset.current.y); gl.uniform2f(uTile, p.tileWidth, p.tileHeight); gl.uniform1f(uG, p.gap); gl.uniform1f(uRad, p.borderRadius); gl.uniform1f(uCurv, p.curve); gl.uniform1f(uCurvRad, p.curveRadius); gl.uniform1f(uZm, p.zoom); gl.uniform1f(uRot, p.tileRotation); gl.uniform1f(uGray, p.grayscale); gl.uniform1f(uVig, p.vignette); gl.uniform2f(uAtlasGrid, gridCount, gridCount); gl.drawArrays(gl.TRIANGLES, 0, 6); animId = requestAnimationFrame(render); }; animId = requestAnimationFrame(render); return () => { isMounted = false; cancelAnimationFrame(animId); gl.deleteProgram(program); gl.deleteShader(vs); gl.deleteShader(fs); gl.deleteTexture(texture); gl.deleteBuffer(buffer); }; }, [images]); // Pointer Drag Handling const handlePointerDown = (e: React.PointerEvent) => { if (!enableDrag) return; isDragging.current = true; lastPointer.current = { x: e.clientX, y: e.clientY }; velocity.current = { x: 0, y: 0 }; try { (e.target as HTMLElement).setPointerCapture(e.pointerId); } catch {} }; const handlePointerMove = (e: React.PointerEvent) => { if (!isDragging.current) return; const dx = (e.clientX - lastPointer.current.x) * dragSensitivity; const dy = (e.clientY - lastPointer.current.y) * dragSensitivity; if (axis !== "y") { offset.current.x -= dx; velocity.current.x = -dx * 20; } if (axis !== "x") { offset.current.y += dy; velocity.current.y = dy * 20; } lastPointer.current = { x: e.clientX, y: e.clientY }; }; const handlePointerUp = (e: React.PointerEvent) => { isDragging.current = false; try { (e.target as HTMLElement).releasePointerCapture(e.pointerId); } catch {} }; const handleWheel = useCallback( (e: WheelEvent) => { if (!enableWheel) return; e.preventDefault(); if (axis !== "y") offset.current.x += e.deltaX * wheelSensitivity; if (axis !== "x") offset.current.y -= e.deltaY * wheelSensitivity; }, [enableWheel, axis, wheelSensitivity] ); useEffect(() => { const container = containerRef.current; if (!container || !enableWheel) return; container.addEventListener("wheel", handleWheel, { passive: false }); return () => container.removeEventListener("wheel", handleWheel); }, [handleWheel, enableWheel]); return (
{children && (
{children}
)}
); } ); InfiniteDrift2.displayName = "InfiniteDrift2"; // Backwards-compatible named export export const InvertedDome = InfiniteDrift2; export default InfiniteDrift2; ```
## Usage ```tsx import InfiniteDrift2 from "@/components/lightswind/infinite-drift-2"; ``` ```tsx import InfiniteDrift2 from "@/components/lightswind/infinite-drift-2"; export function Gallery() { return (
); } ```