{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "cursor-web-fluid", "title": "Cursor Web Fluid", "description": "A fluid animation that follows the cursor.", "dependencies": [ "three" ], "files": [ { "path": "registry/new-york/components/cursor-web-fluid/cursor-web-fluid.tsx", "type": "registry:component", "content": "\"use client\";\n\nimport { useEffect, useRef, CSSProperties } from \"react\";\nimport * as THREE from \"three\";\n\n/* ─── shader sources ──────────────────────────────────────────────── */\n\nconst v = `varying vec2 vUv; void main() { vUv = uv; gl_Position = vec4(position, 1.0); }`;\nconst p = `precision highp float; `;\nconst s = `precision mediump sampler2D; `;\n\nconst shaders = {\n splat: [v, `${p}${s}\nuniform sampler2D uTarget; uniform float aspectRatio; uniform vec3 color; uniform vec2 point; uniform float radius; varying vec2 vUv;\nvoid main(){vec2 p=vUv-point;p.x*=aspectRatio;gl_FragColor=vec4(texture2D(uTarget,vUv).xyz+exp(-dot(p,p)/radius)*color,1.0);}`],\n advection: [v, `${p}${s}\nuniform sampler2D uVelocity,uSource;uniform vec2 texelSize;uniform float dt,dissipation;varying vec2 vUv;\nvoid main(){vec2 coord=vUv-dt*texture2D(uVelocity,vUv).xy*texelSize;gl_FragColor=dissipation*texture2D(uSource,coord);}`],\n divergence: [v, `${p}${s}\nuniform sampler2D uVelocity;uniform vec2 texelSize;varying vec2 vUv;\nvec2 vel(vec2 uv){vec2 e=vec2(1.);if(uv.x<0.){uv.x=0.;e.x=-1.;}if(uv.x>1.){uv.x=1.;e.x=-1.;}if(uv.y<0.){uv.y=0.;e.y=-1.;}if(uv.y>1.){uv.y=1.;e.y=-1.;}return texture2D(uVelocity,uv).xy*e;}\nvoid main(){vec2 L=vUv-vec2(texelSize.x,0.),R=vUv+vec2(texelSize.x,0.),T=vUv+vec2(0.,texelSize.y),B=vUv-vec2(0.,texelSize.y);gl_FragColor=vec4(0.5*(vel(R).x-vel(L).x+vel(T).y-vel(B).y),0.,0.,1.);}`],\n curl: [v, `${p}${s}\nuniform sampler2D uVelocity;uniform vec2 texelSize;varying vec2 vUv;\nvoid main(){vec2 L=vUv-vec2(texelSize.x,0.),R=vUv+vec2(texelSize.x,0.),T=vUv+vec2(0.,texelSize.y),B=vUv-vec2(0.,texelSize.y);float vorticity=texture2D(uVelocity,R).y-texture2D(uVelocity,L).y-texture2D(uVelocity,T).x+texture2D(uVelocity,B).x;gl_FragColor=vec4(vorticity,0.,0.,1.);}`],\n vorticity: [v, `${p}${s}\nuniform sampler2D uVelocity,uCurl;uniform vec2 texelSize;uniform float curlStrength,dt;varying vec2 vUv;\nvoid main(){vec2 L=vUv-vec2(texelSize.x,0.),R=vUv+vec2(texelSize.x,0.),T=vUv+vec2(0.,texelSize.y),B=vUv-vec2(0.,texelSize.y);vec2 force=vec2(abs(texture2D(uCurl,T).x)-abs(texture2D(uCurl,B).x),abs(texture2D(uCurl,R).x)-abs(texture2D(uCurl,L).x));force=normalize(force+0.0001)*curlStrength*texture2D(uCurl,vUv).x;gl_FragColor=vec4(texture2D(uVelocity,vUv).xy+force*dt,0.,1.);}`],\n pressure: [v, `${p}${s}\nuniform sampler2D uPressure,uDivergence;uniform vec2 texelSize;varying vec2 vUv;\nvoid main(){vec2 L=clamp(vUv-vec2(texelSize.x,0.),0.,1.),R=clamp(vUv+vec2(texelSize.x,0.),0.,1.),T=clamp(vUv+vec2(0.,texelSize.y),0.,1.),B=clamp(vUv-vec2(0.,texelSize.y),0.,1.);gl_FragColor=vec4(0.25*(texture2D(uPressure,L).x+texture2D(uPressure,R).x+texture2D(uPressure,T).x+texture2D(uPressure,B).x-texture2D(uDivergence,vUv).x),0.,0.,1.);}`],\n gradientSubtract: [v, `${p}${s}\nuniform sampler2D uPressure,uVelocity;uniform vec2 texelSize;varying vec2 vUv;\nvoid main(){float pL=texture2D(uPressure,clamp(vUv-vec2(texelSize.x,0.),0.,1.)).x,pR=texture2D(uPressure,clamp(vUv+vec2(texelSize.x,0.),0.,1.)).x,pT=texture2D(uPressure,clamp(vUv+vec2(0.,texelSize.y),0.,1.)).x,pB=texture2D(uPressure,clamp(vUv-vec2(0.,texelSize.y),0.,1.)).x;gl_FragColor=vec4(texture2D(uVelocity,vUv).xy-vec2(pR-pL,pT-pB),0.,1.);}`],\n clear: [v, `${p}${s}\nuniform sampler2D uTexture;uniform float value;varying vec2 vUv;\nvoid main(){gl_FragColor=value*texture2D(uTexture,vUv);}`],\n display: [v, `${p}\nuniform sampler2D uTexture;uniform float threshold,edgeSoftness;uniform vec3 inkColor;varying vec2 vUv;\nvoid main(){float d=clamp(length(texture2D(uTexture,vUv).rgb),0.,1.);float a=edgeSoftness>0.?smoothstep(threshold+edgeSoftness*.5,threshold-edgeSoftness*.5,d):step(threshold,d);gl_FragColor=vec4(inkColor*a,a);}`],\n};\n\n/* ─── types ───────────────────────────────────────────────────────── */\n\ninterface FluidConfig {\n simResolution: number;\n dyeResolution: number;\n curl: number;\n pressureIterations: number;\n velocityDissipation: number;\n dyeDissipation: number;\n splatRadius: number;\n forceStrength: number;\n pressureDecay: number;\n threshold: number;\n edgeSoftness: number;\n inkColor: THREE.Color;\n}\n\ninterface DoubleFBO {\n read: THREE.WebGLRenderTarget;\n write: THREE.WebGLRenderTarget;\n swap: () => void;\n}\n\n/* ─── simulation class ────────────────────────────────────────────── */\n\nclass FluidSimulation {\n config: FluidConfig;\n renderer!: THREE.WebGLRenderer;\n dpr!: number;\n width!: number;\n height!: number;\n scene!: THREE.Scene;\n camera!: THREE.OrthographicCamera;\n quad!: THREE.Mesh;\n simSize!: { w: number; h: number };\n dyeSize!: { w: number; h: number };\n velocity!: DoubleFBO;\n dye!: DoubleFBO;\n divergence!: THREE.WebGLRenderTarget;\n curl!: THREE.WebGLRenderTarget;\n pressure!: DoubleFBO;\n simTexel!: THREE.Vector2;\n dyeTexel!: THREE.Vector2;\n material!: Record;\n mouse!: { x: number; y: number; velocityX: number; velocityY: number; moved: boolean };\n private _animId = 0;\n private _onResize: () => void;\n private _onMouseMove: (e: MouseEvent) => void;\n private _onTouchMove: (e: TouchEvent) => void;\n\n private _resizeObserver!: ResizeObserver;\n\n constructor(canvas: HTMLCanvasElement, config: FluidConfig) {\n this.config = config;\n this._onResize = () => {};\n this._onMouseMove = () => {};\n this._onTouchMove = () => {};\n this._setupRenderer(canvas);\n this._setupScene();\n this._setupTargets();\n this._setupMaterials();\n this._setupInput();\n this._loop();\n }\n\n _setupRenderer(canvas: HTMLCanvasElement) {\n this.renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: false, premultipliedAlpha: true });\n this.renderer.setClearColor(0x000000, 0);\n this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));\n \n // Initial size\n let width = canvas.parentElement ? canvas.parentElement.clientWidth : canvas.clientWidth;\n let height = canvas.parentElement ? canvas.parentElement.clientHeight : canvas.clientHeight;\n if (width === 0) width = window.innerWidth;\n if (height === 0) height = window.innerHeight;\n \n this.renderer.setSize(width, height);\n this.dpr = this.renderer.getPixelRatio();\n this.width = width * this.dpr;\n this.height = height * this.dpr;\n\n this._resizeObserver = new ResizeObserver((entries) => {\n for (const entry of entries) {\n const { width, height } = entry.contentRect;\n if (width === 0 || height === 0) continue;\n this.renderer.setSize(width, height);\n this.width = width * this.dpr;\n this.height = height * this.dpr;\n }\n });\n \n if (canvas.parentElement) {\n this._resizeObserver.observe(canvas.parentElement);\n } else {\n this._resizeObserver.observe(canvas);\n }\n }\n\n _setupScene() {\n this.scene = new THREE.Scene();\n this.camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);\n this.quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2));\n this.scene.add(this.quad);\n }\n\n _setupTargets() {\n const { simResolution: simRes, dyeResolution: dyeRes } = this.config;\n const aspect = this.width / this.height;\n const opts: THREE.RenderTargetOptions = {\n type: THREE.HalfFloatType,\n depthBuffer: false,\n minFilter: THREE.LinearFilter,\n magFilter: THREE.LinearFilter,\n };\n const single = (w: number, h: number) => new THREE.WebGLRenderTarget(w, h, opts);\n const double = (w: number, h: number): DoubleFBO => ({\n read: single(w, h),\n write: single(w, h),\n swap() { [this.read, this.write] = [this.write, this.read]; },\n });\n this.simSize = { w: simRes, h: Math.round(simRes / aspect) };\n this.dyeSize = { w: dyeRes, h: Math.round(dyeRes / aspect) };\n this.velocity = double(this.simSize.w, this.simSize.h);\n this.dye = double(this.dyeSize.w, this.dyeSize.h);\n this.divergence = single(this.simSize.w, this.simSize.h);\n this.curl = single(this.simSize.w, this.simSize.h);\n this.pressure = double(this.simSize.w, this.simSize.h);\n this.simTexel = new THREE.Vector2(1 / this.simSize.w, 1 / this.simSize.h);\n this.dyeTexel = new THREE.Vector2(1 / this.dyeSize.w, 1 / this.dyeSize.h);\n }\n\n _setupMaterials() {\n const make = ([vert, frag]: string[], uniforms: Record) =>\n new THREE.ShaderMaterial({ vertexShader: vert, fragmentShader: frag, uniforms, depthWrite: false, depthTest: false });\n const tex = (): THREE.IUniform => ({ value: null });\n const num = (v = 0): THREE.IUniform => ({ value: v });\n const vec2 = (): THREE.IUniform => ({ value: new THREE.Vector2() });\n this.material = {\n splat: make(shaders.splat, { uTarget: tex(), aspectRatio: num(), radius: num(), color: { value: new THREE.Vector3() }, point: vec2() }),\n advection: make(shaders.advection, { uVelocity: tex(), uSource: tex(), texelSize: vec2(), dt: num(), dissipation: num() }),\n divergence: make(shaders.divergence, { uVelocity: tex(), texelSize: { value: this.simTexel } }),\n curl: make(shaders.curl, { uVelocity: tex(), texelSize: { value: this.simTexel } }),\n vorticity: make(shaders.vorticity, { uVelocity: tex(), uCurl: tex(), texelSize: { value: this.simTexel }, curlStrength: num(), dt: num() }),\n pressure: make(shaders.pressure, { uPressure: tex(), uDivergence: tex(), texelSize: { value: this.simTexel } }),\n gradientSubtract: make(shaders.gradientSubtract, { uPressure: tex(), uVelocity: tex(), texelSize: { value: this.simTexel } }),\n clear: make(shaders.clear, { uTexture: tex(), value: num() }),\n display: make(shaders.display, { uTexture: tex(), threshold: num(), edgeSoftness: num(), inkColor: { value: this.config.inkColor } }),\n };\n }\n\n _setupInput() {\n this.mouse = { x: 0, y: 0, velocityX: 0, velocityY: 0, moved: false };\n const getRect = () => this.renderer.domElement.getBoundingClientRect();\n \n const onMove = (x: number, y: number) => {\n const rect = getRect();\n const localX = x - rect.left;\n const localY = y - rect.top;\n \n // Ignore if outside the canvas bounds\n if (localX < 0 || localX > rect.width || localY < 0 || localY > rect.height) return;\n\n const sx = localX * this.dpr;\n const sy = localY * this.dpr;\n \n this.mouse.velocityX = (sx - this.mouse.x) * this.config.forceStrength;\n this.mouse.velocityY = (sy - this.mouse.y) * this.config.forceStrength;\n this.mouse.x = sx;\n this.mouse.y = sy;\n this.mouse.moved = true;\n };\n \n this._onMouseMove = (e: MouseEvent) => onMove(e.clientX, e.clientY);\n this._onTouchMove = (e: TouchEvent) => { e.preventDefault(); onMove(e.touches[0].clientX, e.touches[0].clientY); };\n \n window.addEventListener(\"mousemove\", this._onMouseMove);\n window.addEventListener(\"touchmove\", this._onTouchMove, { passive: false });\n }\n\n _pass(mat: THREE.ShaderMaterial, target: THREE.WebGLRenderTarget | null) {\n this.quad.material = mat;\n this.renderer.setRenderTarget(target);\n this.renderer.render(this.scene, this.camera);\n }\n\n _splat(x: number, y: number, vx: number, vy: number) {\n const u = this.material.splat.uniforms;\n u.aspectRatio.value = this.width / this.height;\n u.point.value.set(x / this.width, 1 - y / this.height);\n u.radius.value = this.config.splatRadius / 100;\n u.uTarget.value = this.velocity.read.texture;\n u.color.value.set(vx, -vy, 0);\n this._pass(this.material.splat, this.velocity.write);\n this.velocity.swap();\n u.uTarget.value = this.dye.read.texture;\n u.color.value.set(3, 3, 3);\n this._pass(this.material.splat, this.dye.write);\n this.dye.swap();\n }\n\n _simulate(dt: number) {\n const m = this.material;\n m.curl.uniforms.uVelocity.value = this.velocity.read.texture;\n this._pass(m.curl, this.curl);\n m.vorticity.uniforms.uVelocity.value = this.velocity.read.texture;\n m.vorticity.uniforms.uCurl.value = this.curl.texture;\n m.vorticity.uniforms.curlStrength.value = this.config.curl;\n m.vorticity.uniforms.dt.value = dt;\n this._pass(m.vorticity, this.velocity.write);\n this.velocity.swap();\n m.divergence.uniforms.uVelocity.value = this.velocity.read.texture;\n this._pass(m.divergence, this.divergence);\n m.clear.uniforms.uTexture.value = this.pressure.read.texture;\n m.clear.uniforms.value.value = this.config.pressureDecay;\n this._pass(m.clear, this.pressure.write);\n this.pressure.swap();\n m.pressure.uniforms.uDivergence.value = this.divergence.texture;\n for (let i = 0; i < this.config.pressureIterations; i++) {\n m.pressure.uniforms.uPressure.value = this.pressure.read.texture;\n this._pass(m.pressure, this.pressure.write);\n this.pressure.swap();\n }\n m.gradientSubtract.uniforms.uPressure.value = this.pressure.read.texture;\n m.gradientSubtract.uniforms.uVelocity.value = this.velocity.read.texture;\n this._pass(m.gradientSubtract, this.velocity.write);\n this.velocity.swap();\n m.advection.uniforms.uVelocity.value = this.velocity.read.texture;\n m.advection.uniforms.uSource.value = this.velocity.read.texture;\n m.advection.uniforms.texelSize.value = this.simTexel;\n m.advection.uniforms.dt.value = dt;\n m.advection.uniforms.dissipation.value = this.config.velocityDissipation;\n this._pass(m.advection, this.velocity.write);\n this.velocity.swap();\n m.advection.uniforms.uSource.value = this.dye.read.texture;\n m.advection.uniforms.texelSize.value = this.dyeTexel;\n m.advection.uniforms.dissipation.value = this.config.dyeDissipation;\n this._pass(m.advection, this.dye.write);\n this.dye.swap();\n }\n\n _render() {\n const u = this.material.display.uniforms;\n u.uTexture.value = this.dye.read.texture;\n u.threshold.value = this.config.threshold;\n u.edgeSoftness.value = this.config.edgeSoftness;\n u.inkColor.value = this.config.inkColor;\n this._pass(this.material.display, null);\n }\n\n _loop() {\n let lastTime = performance.now();\n const tick = () => {\n const now = performance.now();\n const dt = Math.min((now - lastTime) / 1000, 0.016);\n lastTime = now;\n if (this.mouse.moved) {\n this._splat(this.mouse.x, this.mouse.y, this.mouse.velocityX, this.mouse.velocityY);\n this.mouse.moved = false;\n }\n this._simulate(dt);\n this._render();\n this._animId = requestAnimationFrame(tick);\n };\n tick();\n }\n\n dispose() {\n cancelAnimationFrame(this._animId);\n if (this._resizeObserver) {\n this._resizeObserver.disconnect();\n }\n window.removeEventListener(\"mousemove\", this._onMouseMove);\n window.removeEventListener(\"touchmove\", this._onTouchMove);\n this.velocity.read.dispose(); this.velocity.write.dispose();\n this.dye.read.dispose(); this.dye.write.dispose();\n this.divergence.dispose(); this.curl.dispose();\n this.pressure.read.dispose(); this.pressure.write.dispose();\n Object.values(this.material).forEach((m) => m.dispose());\n this.quad.geometry.dispose();\n this.renderer.dispose();\n }\n}\n\n/* ─── CSS-var → THREE.Color helper ───────────────────────────────── */\n\n/**\n * Resolves a CSS custom property (e.g. \"--foreground\") or any valid\n * CSS color string (e.g. \"#fff\", \"rgb(0,255,128)\") to a THREE.Color.\n * Uses the browser canvas parser so modern CSS color formats work reliably.\n * Falls back to white if resolution fails.\n */\nfunction resolveCssColor(value: string, fallback = \"#ffffff\"): THREE.Color {\n const cssColor = value.startsWith(\"--\")\n ? getComputedStyle(document.documentElement).getPropertyValue(value).trim()\n : value;\n\n try {\n const context = document.createElement(\"canvas\").getContext(\"2d\");\n if (!context) return new THREE.Color(fallback);\n\n context.fillStyle = fallback;\n context.fillStyle = cssColor || fallback;\n return new THREE.Color(context.fillStyle);\n } catch {\n return new THREE.Color(fallback);\n }\n}\n\n/* ─── Public props ────────────────────────────────────────────────── */\n\nexport interface CursorWebFluidProps {\n /**\n * Ink color for the fluid trail.\n * - Pass a CSS variable name like `\"--foreground\"` or `\"--primary\"` to use theme tokens.\n * - Pass any valid CSS color string like `\"#ff0080\"` or `\"rgb(255,0,128)\"`.\n * @default \"--foreground\"\n */\n inkColor?: string;\n\n /**\n * Resolution of the velocity simulation grid.\n * Higher = more detail, lower = better performance.\n * @default 256\n */\n simResolution?: number;\n\n /**\n * Resolution of the dye (ink) texture.\n * @default 1024\n */\n dyeResolution?: number;\n\n /**\n * Curl / vorticity strength — how much the fluid spins.\n * @default 25\n */\n curl?: number;\n\n /**\n * Number of pressure solver iterations per frame.\n * Higher = more accurate, lower = better performance.\n * @default 50\n */\n pressureIterations?: number;\n\n /**\n * How quickly velocity dissipates each frame (0–1).\n * Values closer to 1 keep trails longer.\n * @default 0.95\n */\n velocityDissipation?: number;\n\n /**\n * How quickly the dye/ink fades each frame (0–1).\n * Values closer to 1 keep ink visible longer.\n * @default 0.95\n */\n dyeDissipation?: number;\n\n /**\n * Radius of the fluid splat on cursor interaction.\n * @default 0.275\n */\n splatRadius?: number;\n\n /**\n * Multiplier for the cursor velocity force applied to the fluid.\n * @default 7.5\n */\n forceStrength?: number;\n\n /**\n * How much pressure is retained each frame (0–1).\n * @default 0.75\n */\n pressureDecay?: number;\n\n /**\n * Display threshold for the ink rendering.\n * @default 1.0\n */\n threshold?: number;\n\n /**\n * Softness of ink edges (0 = hard, >0 = feathered).\n * @default 0.0\n */\n edgeSoftness?: number;\n\n /**\n * CSS mix-blend-mode applied to the canvas.\n * Difference preserves the translucent, inverted trail effect across\n * different surfaces.\n * @default \"difference\"\n */\n blendMode?: CSSProperties[\"mixBlendMode\"];\n\n /**\n * z-index of the canvas overlay.\n * @default 100\n */\n zIndex?: number;\n\n /**\n * When true the canvas fills its nearest positioned parent instead of the viewport.\n * Useful for contained demo previews.\n * @default false (fixed, covers full viewport)\n */\n contained?: boolean;\n\n /** Extra inline styles forwarded to the canvas element. */\n style?: CSSProperties;\n\n /** Extra class names forwarded to the canvas element. */\n className?: string;\n}\n\n/* ─── React component ─────────────────────────────────────────────── */\n\nexport default function CursorWebFluid({\n inkColor = \"--foreground\",\n simResolution = 256,\n dyeResolution = 1024,\n curl = 25,\n pressureIterations = 50,\n velocityDissipation = 0.95,\n dyeDissipation = 0.95,\n splatRadius = 0.275,\n forceStrength = 7.5,\n pressureDecay = 0.75,\n threshold = 1.0,\n edgeSoftness = 0.0,\n blendMode = \"difference\",\n zIndex = 100,\n contained = false,\n style,\n className,\n}: CursorWebFluidProps) {\n const canvasRef = useRef(null);\n\n useEffect(() => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n\n const resolvedInkColor = resolveCssColor(inkColor);\n\n const config: FluidConfig = {\n simResolution,\n dyeResolution,\n curl,\n pressureIterations,\n velocityDissipation,\n dyeDissipation,\n splatRadius,\n forceStrength,\n pressureDecay,\n threshold,\n edgeSoftness,\n inkColor: resolvedInkColor,\n };\n\n const sim = new FluidSimulation(canvas, config);\n return () => sim.dispose();\n }, [\n inkColor,\n simResolution,\n dyeResolution,\n curl,\n pressureIterations,\n velocityDissipation,\n dyeDissipation,\n splatRadius,\n forceStrength,\n pressureDecay,\n threshold,\n edgeSoftness,\n ]);\n\n const canvasStyle: CSSProperties = {\n position: contained ? \"absolute\" : \"fixed\",\n inset: 0,\n width: contained ? \"100%\" : \"100vw\",\n height: contained ? \"100%\" : \"100vh\",\n pointerEvents: \"none\",\n zIndex,\n mixBlendMode: blendMode,\n ...style,\n };\n\n return ;\n}\n" } ], "type": "registry:component" }