Liquid Image Reveal

A liquid-style image reveal component that creates an organic, fluid reveal effect using SVG filters and GSAP animations. Features Safari compatibility with fallback rendering.

React
Tailwind CSS
SVG
GSAP
Animation
Filters

Using CLI

npx dimaac add LiquidImageReveal

Manual Installation

npm install react @gsap/react

lib/utils.ts

import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
} 

components/ui/LiquidImageReveal.tsx

'use client';

import { useEffect, useRef, useId } from 'react';
import { gsap } from 'gsap';
import { cn } from '@/lib/utils';

interface LiquidImageRevealProps {
  src: string;
  alt: string;
  width?: number;
  height?: number;
  duration?: number;
  delay?: number;
  className?: string;
  centerX?: number;
  centerY?: number;
  turbulenceFrequency?: number;
  turbulenceOctaves?: number;
  displacementScale?: number;
  maxRadius?: number;
}


const LiquidImageReveal: React.FC<LiquidImageRevealProps> = ({
  src,
  width = 600,
  height = 400,
  duration = 2,
  delay = 0,
  className = '',
  centerX,
  centerY,
  turbulenceFrequency = 0.03,
  turbulenceOctaves = 10,
  displacementScale = 100,
  maxRadius,
}) => {
  const svgRef = useRef<SVGSVGElement>(null);
  const circleRef = useRef<SVGCircleElement>(null);
  const id = useId();
  

  const maskId = `liquid-mask-${id}`;
  const filterId = `liquid-filter-${id}`;


  const defaultCenterX = centerX ?? width / 2;
  const defaultCenterY = centerY ?? height / 2;
  // Increase radius to ensure full coverage - use diagonal distance plus some buffer
  const defaultMaxRadius = maxRadius ?? Math.sqrt(width * width + height * height) * 0.6;

  useEffect(() => {
    if (!circleRef.current) return;

    // GSAP animation for the circle radius
    const tl = gsap.timeline({ delay });
    
    tl.fromTo(circleRef.current, 
      { 
        attr: { r: 0 } 
      },
      {
        attr: { r: defaultMaxRadius },
        duration,
        ease: "power2.out"
      }
    );

    return () => {
      tl.kill();
    };
  }, [duration, delay, defaultMaxRadius]);

  return (
    <div className={cn("inline-block", className)}>
      <svg 
        ref={svgRef}
        width={width} 
        height={height} 
        xmlns="http://www.w3.org/2000/svg"
        viewBox={`0 0 ${width} ${height}`}
      >
        <defs>
          {/* Liquid effect filter - now rendered for all browsers */}
          <filter id={filterId}>
            <feTurbulence 
              type="fractalNoise" 
              baseFrequency={turbulenceFrequency} 
              numOctaves={turbulenceOctaves} 
              result="noise" 
            />
            <feDisplacementMap 
              in="SourceGraphic" 
              in2="noise" 
              scale={displacementScale} 
              xChannelSelector="R" 
              yChannelSelector="G" 
            />
          </filter>

          {/* Circle mask with liquid filter applied */}
          <mask id={maskId}>
            <circle 
              ref={circleRef}
              cx={defaultCenterX} 
              cy={defaultCenterY} 
              r="0" 
              fill="white" 
              filter={`url(#${filterId})`}
            />
          </mask>
        </defs>

        {/* Image with mask applied */}
        <image 
          href={src}
          mask={`url(#${maskId})`}
          width={width} 
          height={height}
          preserveAspectRatio="xMidYMid slice"
        />
      </svg>
    </div>
  );
};

export default LiquidImageReveal;