{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "liquid-text", "type": "registry:ui", "dependencies": [], "files": [ { "path": "components/ui/liquid-text.tsx", "content": "\"use client\";\r\n\r\nimport { useEffect, useRef } from \"react\";\r\nimport * as THREE from \"three\";\r\nimport { cn } from \"@/lib/utils\";\r\n\r\ninterface LiquidTextProps {\r\n /** Text to display */\r\n text?: string;\r\n /** Font size in pixels */\r\n fontSize?: number;\r\n /** Font family */\r\n font?: string;\r\n /** Fixed text color (overrides theme colors) */\r\n color?: string;\r\n /** Text color in light mode */\r\n lightColor?: string;\r\n /** Text color in dark mode */\r\n darkColor?: string;\r\n /** Additional CSS classes */\r\n className?: string;\r\n}\r\n\r\nconst createTextTexture = (text: string, size: number, font: string, color: string): THREE.Texture => {\r\n const canvas = document.createElement(\"canvas\");\r\n canvas.width = 2048;\r\n canvas.height = 2048;\r\n const ctx = canvas.getContext(\"2d\");\r\n if (!ctx) return new THREE.CanvasTexture(canvas);\r\n\r\n ctx.clearRect(0, 0, canvas.width, canvas.height);\r\n ctx.font = `bold ${size}px ${font}`;\r\n ctx.fillStyle = color;\r\n ctx.textAlign = \"center\";\r\n ctx.textBaseline = \"middle\";\r\n ctx.fillText(text, canvas.width / 2, canvas.height / 2);\r\n\r\n const texture = new THREE.CanvasTexture(canvas);\r\n texture.needsUpdate = true;\r\n return texture;\r\n};\r\n\r\nconst vertexShader = `\r\n varying vec2 vUv;\r\n uniform vec3 uDisplacement;\r\n\r\n float easeInOutCubic(float x) {\r\n return x < 0.5 ? 4.0 * x * x * x : 1.0 - pow(-2.0 * x + 2.0, 3.0) / 2.0;\r\n }\r\n\r\n float map(float value, float min1, float max1, float min2, float max2) {\r\n return min2 + (value - min1) * (max2 - min2) / (max1 - min1);\r\n }\r\n\r\n void main() {\r\n vUv = uv;\r\n vec3 displaced = position;\r\n vec4 worldPosition = modelMatrix * vec4(position, 1.0);\r\n float dist = length(uDisplacement - worldPosition.rgb);\r\n float minDistance = 3.0;\r\n\r\n if (dist < minDistance) {\r\n float mapped = map(dist, 0.0, minDistance, 1.0, 0.0);\r\n displaced.z += easeInOutCubic(mapped);\r\n }\r\n\r\n gl_Position = projectionMatrix * modelViewMatrix * vec4(displaced, 1.0);\r\n }\r\n`;\r\n\r\nconst fragmentShader = `\r\n varying vec2 vUv;\r\n uniform sampler2D uTexture;\r\n\r\n void main() {\r\n gl_FragColor = texture2D(uTexture, vUv);\r\n }\r\n`;\r\n\r\nexport function LiquidText({\r\n text = \"Liquid Text\",\r\n fontSize = 200,\r\n font = \"Inter, sans-serif\",\r\n color,\r\n lightColor = \"#000000\",\r\n darkColor = \"#ffffff\",\r\n className,\r\n}: LiquidTextProps) {\r\n const containerRef = useRef(null);\r\n\r\n useEffect(() => {\r\n const container = containerRef.current;\r\n if (!container) return;\r\n\r\n const rect = container.getBoundingClientRect();\r\n const width = rect.width || 1;\r\n const height = rect.height || 1;\r\n if (height === 0) return;\r\n\r\n const scene = new THREE.Scene();\r\n scene.background = null;\r\n\r\n const cameraDistance = 8;\r\n const aspect = width / height;\r\n const camera = new THREE.OrthographicCamera(\r\n -cameraDistance * aspect, cameraDistance * aspect,\r\n cameraDistance, -cameraDistance, 0.01, 1000\r\n );\r\n camera.position.set(0, -10, 5);\r\n camera.lookAt(0, 0, 0);\r\n\r\n const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });\r\n renderer.setClearColor(0x000000, 0);\r\n renderer.setPixelRatio(window.devicePixelRatio);\r\n renderer.setSize(width, height, false);\r\n renderer.domElement.style.width = \"100%\";\r\n renderer.domElement.style.height = \"100%\";\r\n container.appendChild(renderer.domElement);\r\n\r\n const geometry = new THREE.PlaneGeometry(15, 15, 100, 100);\r\n const getActiveColor = () => color || (document.documentElement.classList.contains(\"dark\") ? darkColor : lightColor);\r\n\r\n let currentColor = getActiveColor();\r\n let textTexture = createTextTexture(text, fontSize, font, currentColor);\r\n\r\n const shaderMaterial = new THREE.ShaderMaterial({\r\n uniforms: {\r\n uTexture: { value: textTexture },\r\n uDisplacement: { value: new THREE.Vector3(0, 0, 0) },\r\n },\r\n vertexShader,\r\n fragmentShader,\r\n transparent: true,\r\n depthWrite: false,\r\n side: THREE.DoubleSide,\r\n });\r\n\r\n const plane = new THREE.Mesh(geometry, shaderMaterial);\r\n plane.rotation.z = Math.PI / 4;\r\n scene.add(plane);\r\n\r\n const hitPlane = new THREE.Mesh(\r\n new THREE.PlaneGeometry(500, 500),\r\n new THREE.MeshBasicMaterial({ transparent: true, opacity: 0 })\r\n );\r\n scene.add(hitPlane);\r\n\r\n const raycaster = new THREE.Raycaster();\r\n const pointer = new THREE.Vector2();\r\n\r\n const onPointerMove = (e: PointerEvent) => {\r\n const bounds = container.getBoundingClientRect();\r\n pointer.x = ((e.clientX - bounds.left) / bounds.width) * 2 - 1;\r\n pointer.y = -((e.clientY - bounds.top) / bounds.height) * 2 + 1;\r\n raycaster.setFromCamera(pointer, camera);\r\n const [hit] = raycaster.intersectObject(hitPlane);\r\n if (hit) (shaderMaterial.uniforms.uDisplacement.value as THREE.Vector3).copy(hit.point);\r\n };\r\n\r\n container.addEventListener(\"pointermove\", onPointerMove);\r\n\r\n const handleResize = () => {\r\n const r = container.getBoundingClientRect();\r\n if (r.height === 0) return;\r\n const a = r.width / r.height;\r\n camera.left = -cameraDistance * a;\r\n camera.right = cameraDistance * a;\r\n camera.updateProjectionMatrix();\r\n renderer.setSize(r.width, r.height, false);\r\n };\r\n\r\n window.addEventListener(\"resize\", handleResize);\r\n\r\n let animationId = 0;\r\n const render = () => {\r\n animationId = requestAnimationFrame(render);\r\n renderer.render(scene, camera);\r\n };\r\n render();\r\n\r\n const observer = new MutationObserver(() => {\r\n const next = getActiveColor();\r\n if (next !== currentColor) {\r\n const tex = createTextTexture(text, fontSize, font, next);\r\n shaderMaterial.uniforms.uTexture.value = tex;\r\n textTexture.dispose();\r\n textTexture = tex;\r\n currentColor = next;\r\n }\r\n });\r\n if (!color) observer.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\"] });\r\n\r\n return () => {\r\n window.removeEventListener(\"resize\", handleResize);\r\n container.removeEventListener(\"pointermove\", onPointerMove);\r\n cancelAnimationFrame(animationId);\r\n observer.disconnect();\r\n if (renderer.domElement.parentNode === container) container.removeChild(renderer.domElement);\r\n renderer.dispose();\r\n textTexture.dispose();\r\n geometry.dispose();\r\n shaderMaterial.dispose();\r\n };\r\n }, [text, fontSize, font, color, lightColor, darkColor]);\r\n\r\n return
;\r\n}\r\n\r\nexport default LiquidText;\r\n", "type": "registry:ui", "target": "components/ui/liquid-text.tsx" } ] }