Elixir UI

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-frame
pnpm dlx @praveenlodhi/elixir-ui add liquid-frame
yarn dlx @praveenlodhi/elixir-ui add liquid-frame
bun x @praveenlodhi/elixir-ui add liquid-frame

Install dependencies

npm install three
pnpm add three
yarn add three
bun add three

Install dev-dependency

If you are using TypeScript, install @types/three as a dev dependency.

npm install -D @types/three
pnpm add -D @types/three
yarn add -D @types/three
bun add -D @types/three

Add LiquidFrame component

Create a file at components/ui/liquid-frame.tsx and paste the LiquidFrame source.

components/ui/liquid-frame.tsx
"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.

lib/liquid-frame.ts
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

On this page