Liquid Frame
Liquid Frame is an interactive image component that creates fluid ripple effects in response to mouse movement, combining WebGL shaders with three.js for smooth wave propagation and distortion animations.
Installation
Install with CLI
Choose your package manager.
npx @praveenlodhi/elixir-ui add liquid-framepnpm dlx @praveenlodhi/elixir-ui add liquid-frameyarn dlx @praveenlodhi/elixir-ui add liquid-framebun x @praveenlodhi/elixir-ui add liquid-frameInstall dependencies
npm install threepnpm add threeyarn add threebun add threeInstall dev-dependency
If you are using TypeScript, install @types/three as a dev dependency.
npm install -D @types/threepnpm add -D @types/threeyarn add -D @types/threebun add -D @types/threeAdd LiquidFrame component
Create a file at components/ui/liquid-frame.tsx and paste the LiquidFrame
source.
"use client";
import { useEffect, useRef } from "react";
import * as THREE from "three";
import clsx from "clsx";
import {
renderFragmentShader,
renderVertexShader,
simulationFragmentShader,
simulationVertexShader,
} from "../lib/liquid-frame";
type ShaderSource = string;
type Uniform<T> = THREE.IUniform<T>;
type SimUniforms = {
textureA: Uniform<THREE.Texture | null>;
mouse: Uniform<THREE.Vector2>;
resolution: Uniform<THREE.Vector2>;
time: Uniform<number>;
frame: Uniform<number>;
};
type RenderUniforms = {
textureA: Uniform<THREE.Texture | null>;
textureB: Uniform<THREE.Texture | null>;
uImageAspect: Uniform<number>;
uCanvasAspect: Uniform<number>;
uFit: Uniform<number>;
};
export interface LiquidFrameProps {
src?: string;
alt?: string;
className?: string;
fit?: "cover" | "contain";
}
export function LiquidFrame({
src = "https://images.unsplash.com/photo-1511447333015-45b65e60f6d5",
alt = "Liquid Frame",
className,
fit = "cover",
}: LiquidFrameProps) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!containerRef.current || !src) return;
const container = containerRef.current;
// ---------------------------
// Renderer
// ---------------------------
const renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setClearColor(0x000000, 0);
container.appendChild(renderer.domElement);
Object.assign(renderer.domElement.style, {
position: "absolute",
inset: "0",
width: "100%",
height: "100%",
opacity: "0",
transition: "opacity 0.4s ease",
});
// ---------------------------
// Scene
// ---------------------------
const scene = new THREE.Scene();
const simScene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 5);
const mouse = new THREE.Vector2();
let frame = 0;
const getSize = () => ({
w: container.clientWidth,
h: container.clientHeight,
});
let { w, h } = getSize();
renderer.setSize(w, h);
// ---------------------------
// Render Targets
// ---------------------------
const options: THREE.RenderTargetOptions = {
format: THREE.RGBAFormat,
type: THREE.FloatType,
minFilter: THREE.LinearFilter,
magFilter: THREE.LinearFilter,
depthBuffer: false,
stencilBuffer: false,
};
let rtA = new THREE.WebGLRenderTarget(
w * window.devicePixelRatio,
h * window.devicePixelRatio,
options
);
let rtB = new THREE.WebGLRenderTarget(
w * window.devicePixelRatio,
h * window.devicePixelRatio,
options
);
// ---------------------------
// Simulation Material
// ---------------------------
const simMaterial = new THREE.ShaderMaterial({
uniforms: {
textureA: { value: null },
mouse: { value: mouse },
resolution: {
value: new THREE.Vector2(
w * window.devicePixelRatio,
h * window.devicePixelRatio
),
},
time: { value: 0 },
frame: { value: 0 },
},
vertexShader: simulationVertexShader as ShaderSource,
fragmentShader: simulationFragmentShader as ShaderSource,
});
const simUniforms = simMaterial.uniforms as SimUniforms;
// ---------------------------
// Texture Load
// ---------------------------
const textureLoader = new THREE.TextureLoader();
const renderMaterial = new THREE.ShaderMaterial({
uniforms: {
textureA: { value: null },
textureB: { value: null },
uImageAspect: { value: 1 },
uCanvasAspect: { value: w / h },
uFit: { value: fit === "cover" ? 0 : 1 },
},
vertexShader: renderVertexShader as ShaderSource,
fragmentShader: renderFragmentShader as ShaderSource,
transparent: true,
});
const renderUniforms = renderMaterial.uniforms as RenderUniforms;
const imageTexture = textureLoader.load(src, (tex) => {
const img = tex.image as HTMLImageElement;
if (img?.width && img?.height) {
const imageAspect = img.width / img.height;
const canvasAspect = container.clientWidth / container.clientHeight;
renderUniforms.uImageAspect.value = imageAspect;
renderUniforms.uCanvasAspect.value = canvasAspect;
container.style.aspectRatio = `${img.width} / ${img.height}`;
}
renderUniforms.textureB.value = tex;
renderer.domElement.style.opacity = "1";
});
imageTexture.minFilter = THREE.LinearFilter;
imageTexture.magFilter = THREE.LinearFilter;
// ---------------------------
// Meshes
// ---------------------------
const plane = new THREE.PlaneGeometry(2, 2);
const simQuad = new THREE.Mesh(plane, simMaterial);
const renderQuad = new THREE.Mesh(plane, renderMaterial);
simScene.add(simQuad);
scene.add(renderQuad);
// ---------------------------
// Resize
// ---------------------------
const handleResize = () => {
const size = getSize();
w = size.w;
h = size.h;
renderer.setSize(w, h);
const cw = w * window.devicePixelRatio;
const ch = h * window.devicePixelRatio;
rtA.setSize(cw, ch);
rtB.setSize(cw, ch);
simUniforms.resolution.value.set(cw, ch);
renderUniforms.uCanvasAspect.value = w / h;
};
window.addEventListener("resize", handleResize);
// ---------------------------
// Mouse
// ---------------------------
const handleMouseMove = (e: MouseEvent) => {
const rect = renderer.domElement.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = rect.height - (e.clientY - rect.top);
if (x >= 0 && x <= rect.width && y >= 0 && y <= rect.height) {
mouse.x = x * window.devicePixelRatio;
mouse.y = y * window.devicePixelRatio;
} else {
mouse.set(0, 0);
}
};
window.addEventListener("mousemove", handleMouseMove);
// ---------------------------
// Animation
// ---------------------------
let rafId = 0;
const animate = () => {
simUniforms.frame.value = frame++;
simUniforms.time.value = performance.now() / 1000;
simUniforms.textureA.value = rtA.texture;
renderer.setRenderTarget(rtB);
renderer.render(simScene, camera);
renderUniforms.textureA.value = rtB.texture;
renderer.setRenderTarget(null);
renderer.render(scene, camera);
[rtA, rtB] = [rtB, rtA];
rafId = requestAnimationFrame(animate);
};
animate();
return () => {
cancelAnimationFrame(rafId);
window.removeEventListener("resize", handleResize);
window.removeEventListener("mousemove", handleMouseMove);
renderer.dispose();
rtA.dispose();
rtB.dispose();
simMaterial.dispose();
renderMaterial.dispose();
plane.dispose();
if (container.contains(renderer.domElement)) {
container.removeChild(renderer.domElement);
}
};
}, [src, fit]);
return (
<div
ref={containerRef}
className={clsx(
className,
"relative h-full w-full overflow-hidden rounded-2xl border bg-transparent md:max-w-4xl"
)}
style={{
position: "relative",
width: "100%",
height: "100%",
aspectRatio: "16 / 9",
overflow: "hidden",
background: "transparent",
}}
/>
);
}Add shader utilities
Create a file at lib/liquid-frame.ts and paste the shaders.
export const simulationVertexShader = `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
export const simulationFragmentShader = `
uniform sampler2D textureA;
uniform vec2 mouse;
uniform vec2 resolution;
uniform float time;
uniform int frame;
varying vec2 vUv;
const float delta = 1.4;
void main() {
vec2 uv = vUv;
if (frame == 0) {
gl_FragColor = vec4(0.0);
return;
}
vec4 data = texture2D(textureA, uv);
float pressure = data.x;
float pVel = data.y;
vec2 texelSize = 1.0 / resolution;
float p_right = texture2D(textureA, uv + vec2(texelSize.x, 0.0)).x;
float p_left = texture2D(textureA, uv + vec2(-texelSize.x, 0.0)).x;
float p_up = texture2D(textureA, uv + vec2(0.0, texelSize.y)).x;
float p_down = texture2D(textureA, uv + vec2(0.0, -texelSize.y)).x;
if (uv.x <= texelSize.x) p_left = p_right;
if (uv.x >= 1.0 - texelSize.x) p_right = p_left;
if (uv.y <= texelSize.y) p_down = p_up;
if (uv.y >= 1.0 - texelSize.y) p_up = p_down;
// Enhanced wave equation matching ShaderToy
pVel += delta * (-2.0 * pressure + p_right + p_left) / 4.0;
pVel += delta * (-2.0 * pressure + p_up + p_down) / 4.0;
pressure += delta * pVel;
pVel -= 0.005 * delta * pressure;
pVel *= 1.0 - 0.002 * delta;
// changed pressure
pressure *= 0.95;
vec2 mouseUV = mouse / resolution;
if (mouse.x > 0.0) {
float dist = distance(uv, mouseUV);
// changed pressure & radius
if (dist <= 0.02) { // Smaller radius for more precise ripples
pressure += 1.5 * (1.0 - dist / 0.02); // Increased intensity
}
}
gl_FragColor = vec4(pressure, pVel, (p_right - p_left) / 2.0, (p_up - p_down) / 2.0);
}
`;
export const renderVertexShader = `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
export const renderFragmentShader = `
uniform sampler2D textureA;
uniform sampler2D textureB;
uniform float uImageAspect;
uniform float uCanvasAspect;
varying vec2 vUv;
// True CSS-like "cover"
vec2 getCoverUV(vec2 uv) {
float imageAspect = uImageAspect;
float canvasAspect = uCanvasAspect;
vec2 scale = vec2(1.0);
if (canvasAspect > imageAspect) {
scale.x = canvasAspect / imageAspect;
} else {
scale.y = imageAspect / canvasAspect;
}
return (uv - 0.5) / scale + 0.5;
}
void main() {
vec4 data = texture2D(textureA, vUv);
// ripple distortion
vec2 distortion = 0.3 * data.zw;
// apply cover AFTER distortion
vec2 uv = getCoverUV(vUv + distortion);
vec4 color = texture2D(textureB, uv);
// lighting
vec3 normal = normalize(vec3(-data.z * 2.0, 0.5, -data.w * 2.0));
vec3 lightDir = normalize(vec3(-3.0, 10.0, 3.0));
float specular = pow(max(0.0, dot(normal, lightDir)), 60.0) * 1.5;
gl_FragColor = color + vec4(specular);
}
`;Usage
import { LiquidFrame } from "@workspace/ui/components/liquid-frame";
export function Demo() {
return <LiquidFrame src="/path/to/image.png" alt="My Image" fit="cover" />;
}Props
Prop
Type
Funnel Gallery
FunnelGallery is a 3D animated image carousel built with React Three Fiber. It arranges up to three images on curved panels with smooth rotation and bloom, making it ideal for rich hero sections and featured visual storytelling.
Vision Glass Card
A visually rich glass-style card component with smooth tilt interaction and depth-based motion effects, powered by motion and VanillaTilt. It creates a dynamic 3D illusion using parallax shifts, specular highlights, and spring-based animations.