Skip to main content

Interaction

Portrait morph

WebGL hover image transition

Client · OGL

Live preview

Interact with the component below — same behavior as on the About page.

Summary

A grayscale portrait container that morphs between two images on hover using a custom WebGL fragment shader. The transition origin follows the cursor entry point, ripple distortion runs along the wipe edge, and progress eases in and out with spring-like interpolation.

  • Custom GLSL wipe with fbm noise and edge ripple
  • Transition origin and direction follow pointer entry
  • ResizeObserver keeps canvas resolution in sync
  • Static fallback image while textures load

Add to your project

Run the CLI to copy files into your project. Pick your language and styling below — the command updates automatically.

1. Add via CLI (recommended)

npx @shashank-portfolio/cli add portrait-morph

2. Install dependencies

npm install ogl

Required packages

  • ogl ^1.0.11

3. Manual installation

Copy the component and provide two image URLs (same aspect ratio works best). Images must be CORS-accessible if served from another origin.

portrait-morph.tsx

"use client";

import { useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import { Renderer, Program, Mesh, Triangle, Transform, Texture } from "ogl";

export type PortraitMorphProps = {
  srcA: string;
  srcB: string;
  alt: string;
  className?: string;
};

const VERTEX_SHADER = `
attribute vec2 position;
varying vec2 vUv;
void main() {
  vUv = position * 0.5 + 0.5;
  gl_Position = vec4(position, 0.0, 1.0);
}
`;

const FRAGMENT_SHADER = `
precision highp float;

uniform sampler2D uTexA;
uniform sampler2D uTexB;
uniform float uProgress;
uniform float uTime;
uniform vec2 uResolution;
uniform vec2 uImageSize;
uniform vec2 uOrigin;
uniform vec2 uDirection;

varying vec2 vUv;

vec2 coverUv(vec2 uv) {
  vec2 ratio = vec2(
    min((uResolution.x / uResolution.y) / (uImageSize.x / uImageSize.y), 1.0),
    min((uResolution.y / uResolution.x) / (uImageSize.y / uImageSize.x), 1.0)
  );
  return vec2(
    uv.x * ratio.x + (1.0 - ratio.x) * 0.5,
    uv.y * ratio.y + (1.0 - ratio.y) * 0.5
  );
}

float hash(vec2 p) {
  return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}

float noise(vec2 p) {
  vec2 i = floor(p);
  vec2 f = fract(p);
  f = f * f * (3.0 - 2.0 * f);
  float a = hash(i);
  float b = hash(i + vec2(1.0, 0.0));
  float c = hash(i + vec2(0.0, 1.0));
  float d = hash(i + vec2(1.0, 1.0));
  return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
}

float fbm(vec2 p) {
  float v = 0.0;
  float a = 0.5;
  for (int i = 0; i < 5; i++) {
    v += a * noise(p);
    p *= 2.0;
    a *= 0.5;
  }
  return v;
}

void main() {
  vec2 uv = vUv;
  vec2 baseUv = coverUv(uv);

  float p = uProgress;
  float bell = 4.0 * p * (1.0 - p);

  vec2 dir = normalize(uDirection + vec2(0.0001));
  float along = dot(uv - uOrigin, dir);
  float distGradient = (along + 1.4) / 2.8;

  float warpLow = fbm(uv * 1.8 + uTime * 0.05) - 0.5;
  float warpHi = fbm(uv * 5.5 - uTime * 0.04 + 13.0) - 0.5;
  float warp = warpLow * 0.55 + warpHi * 0.18;

  float field = distGradient + warp;

  float remapped = mix(-0.25, 1.25, p);
  float edgeWidth = 0.07;
  float mask = smoothstep(remapped - edgeWidth, remapped + edgeWidth, field);
  mask = 1.0 - mask;

  vec2 perp = vec2(-dir.y, dir.x);
  float ripplePhase = (field - remapped) * 14.0;
  float ripple = sin(ripplePhase) * 0.5 + 0.5;
  float edgeBand = 1.0 - smoothstep(0.0, edgeWidth * 1.6, abs(field - remapped));
  float pushAmount = ripple * edgeBand * 0.025 * bell;
  vec2 pushUv = uv + perp * pushAmount;
  vec2 baseUvA = coverUv(pushUv);
  vec2 baseUvB = coverUv(pushUv);

  vec4 texA = texture2D(uTexA, baseUvA);
  vec4 texB = texture2D(uTexB, baseUvB);

  vec4 color = mix(texA, texB, mask);

  float darken = edgeBand * 0.35 * bell;
  color.rgb *= 1.0 - darken;

  gl_FragColor = color;
}
`;

export function PortraitMorph({
  srcA,
  srcB,
  alt,
  className,
}: PortraitMorphProps): ReactNode {
  const containerRef = useRef<HTMLDivElement | null>(null);
  const [ready, setReady] = useState(false);
  const hoverRef = useRef(false);
  const progressRef = useRef(0);
  const originRef = useRef<[number, number]>([0.5, 0.5]);
  const directionRef = useRef<[number, number]>([1, 0]);
  const lastPointerRef = useRef<{ x: number; y: number; t: number } | null>(null);

  useEffect(() => {
    const container = containerRef.current;
    if (!container) return;

    const renderer = new Renderer({
      alpha: true,
      premultipliedAlpha: false,
      dpr: Math.min(window.devicePixelRatio || 1, 2),
    });
    const gl = renderer.gl;
    const canvas = gl.canvas as HTMLCanvasElement;
    canvas.style.width = "100%";
    canvas.style.height = "100%";
    canvas.style.display = "block";
    container.appendChild(canvas);

    const scene = new Transform();

    const texA = new Texture(gl, { generateMipmaps: false });
    const texB = new Texture(gl, { generateMipmaps: false });

    const imageSize: [number, number] = [1, 1];

    const loadImage = (src: string, target: Texture): Promise<void> =>
      new Promise((resolve, reject) => {
        const img = new Image();
        img.crossOrigin = "anonymous";
        img.onload = () => {
          target.image = img;
          imageSize[0] = img.naturalWidth;
          imageSize[1] = img.naturalHeight;
          resolve();
        };
        img.onerror = reject;
        img.src = src;
      });

    const geometry = new Triangle(gl);
    const program = new Program(gl, {
      vertex: VERTEX_SHADER,
      fragment: FRAGMENT_SHADER,
      uniforms: {
        uTexA: { value: texA },
        uTexB: { value: texB },
        uProgress: { value: 0 },
        uTime: { value: 0 },
        uResolution: { value: [1, 1] as [number, number] },
        uImageSize: { value: imageSize },
        uOrigin: { value: [0.5, 0.5] as [number, number] },
        uDirection: { value: [1, 0] as [number, number] },
      },
      transparent: true,
    });
    const mesh = new Mesh(gl, { geometry, program });
    mesh.setParent(scene);

    const resize = () => {
      const w = container.clientWidth;
      const h = container.clientHeight;
      renderer.setSize(w, h);
      canvas.style.width = "100%";
      canvas.style.height = "100%";
      program.uniforms.uResolution.value = [
        w * renderer.dpr,
        h * renderer.dpr,
      ];
    };
    const ro = new ResizeObserver(resize);
    ro.observe(container);
    resize();

    let raf = 0;
    let last = performance.now();
    let time = 0;
    let running = true;
    let tabVisible = true;
    let onScreen = true;

    const onVisibility = (): void => {
      tabVisible = document.visibilityState === "visible";
    };
    document.addEventListener("visibilitychange", onVisibility);

    const visibilityIo = new IntersectionObserver(
      (entries) => {
        for (const e of entries) onScreen = e.isIntersecting;
      },
      { rootMargin: "100px" }
    );
    visibilityIo.observe(container);

    const tick = () => {
      if (!running) return;
      const now = performance.now();
      const active = tabVisible && onScreen;

      if (active) {
        const dt = Math.min((now - last) / 1000, 0.05);
        last = now;
        time += dt;

        const target = hoverRef.current ? 1 : 0;
        const stiffness = hoverRef.current ? 2.4 : 2.0;
        const k = 1 - Math.exp(-stiffness * dt);
        progressRef.current += (target - progressRef.current) * k;

        program.uniforms.uTime.value = time;
        program.uniforms.uProgress.value = progressRef.current;
        program.uniforms.uOrigin.value = originRef.current;
        program.uniforms.uDirection.value = directionRef.current;
        program.uniforms.uImageSize.value = imageSize;

        renderer.render({ scene });
      } else {
        last = now;
      }

      raf = requestAnimationFrame(tick);
    };

    Promise.all([loadImage(srcA, texA), loadImage(srcB, texB)])
      .then(() => {
        setReady(true);
        last = performance.now();
        tick();
      })
      .catch(() => {
        setReady(false);
      });

    const computeEdgeDirection = (x: number, y: number): [number, number] => {
      const dxLeft = x;
      const dxRight = 1 - x;
      const dyBottom = y;
      const dyTop = 1 - y;
      const minDist = Math.min(dxLeft, dxRight, dyBottom, dyTop);
      if (minDist === dxLeft) return [1, 0];
      if (minDist === dxRight) return [-1, 0];
      if (minDist === dyBottom) return [0, 1];
      return [0, -1];
    };

    const onPointerEnter = (e: PointerEvent) => {
      const rect = container.getBoundingClientRect();
      const x = (e.clientX - rect.left) / rect.width;
      const y = 1 - (e.clientY - rect.top) / rect.height;
      originRef.current = [x, y];
      directionRef.current = computeEdgeDirection(x, y);
      lastPointerRef.current = { x, y, t: performance.now() };
      hoverRef.current = true;
    };
    const onPointerLeave = (e: PointerEvent) => {
      const rect = container.getBoundingClientRect();
      const x = (e.clientX - rect.left) / rect.width;
      const y = 1 - (e.clientY - rect.top) / rect.height;
      originRef.current = [x, y];
      directionRef.current = computeEdgeDirection(x, y).map((v) => -v) as [
        number,
        number,
      ];
      hoverRef.current = false;
    };
    const onPointerMove = (e: PointerEvent) => {
      const rect = container.getBoundingClientRect();
      const x = (e.clientX - rect.left) / rect.width;
      const y = 1 - (e.clientY - rect.top) / rect.height;
      const last = lastPointerRef.current;
      if (last && performance.now() - last.t < 80 && progressRef.current < 0.15) {
        const vx = x - last.x;
        const vy = y - last.y;
        const mag = Math.hypot(vx, vy);
        if (mag > 0.01) {
          directionRef.current = [vx / mag, vy / mag];
        }
      }
      lastPointerRef.current = { x, y, t: performance.now() };
    };

    container.addEventListener("pointerenter", onPointerEnter);
    container.addEventListener("pointerleave", onPointerLeave);
    container.addEventListener("pointermove", onPointerMove);

    return () => {
      running = false;
      cancelAnimationFrame(raf);
      document.removeEventListener("visibilitychange", onVisibility);
      visibilityIo.disconnect();
      ro.disconnect();
      container.removeEventListener("pointerenter", onPointerEnter);
      container.removeEventListener("pointerleave", onPointerLeave);
      container.removeEventListener("pointermove", onPointerMove);
      const ext = gl.getExtension("WEBGL_lose_context");
      if (ext) ext.loseContext();
      if (canvas.parentNode === container) container.removeChild(canvas);
    };
  }, [srcA, srcB]);

  return (
    <div
      ref={containerRef}
      role="img"
      aria-label={alt}
      className={className}
      style={{ position: "relative", width: "100%", height: "100%", filter: "grayscale(100%)" }}
    >
      {!ready ? (
        <img
          src={srcA}
          alt={alt}
          draggable={false}
          className="absolute inset-0 h-full w-full select-none object-cover"
        />
      ) : null}
    </div>
  );
}

4. Use in your app

"use client";

import { PortraitMorph } from "@/components/hero/portrait-morph";

export function HeroPortrait() {
  return (
    <div className="relative aspect-square w-full max-w-md overflow-hidden rounded-4xl">
      <PortraitMorph
        srcA="/portrait.png"
        srcB="/portrait-hover.png"
        alt="Portrait"
      />
    </div>
  );
}

Let’s connect

I’m always open to discussing new projects, creative ideas, or opportunities to be part of your visions. Just reach out!

2026 © Built with Next.js

By Love ❤️