---
title: Morph
description: Soft GPU melt between images on click.
group: Media
order: 21
new: true
---

Images dissolve into each other with a soft melt. Step the index with a button. Tune duration and intensity.

```tsx
import { useState } from "react"
import { Morph } from "@/components/motion/morph"
import { Button } from "@/components/ui/button"

export function Example() {
  const [index, setIndex] = useState(0)

  return (
    <>
      <Morph
        items={["/one.png", "/two.png", "/three.png"]}
        index={index}
        className="aspect-[4/3] w-full max-w-md"
      />
      <Button onClick={() => setIndex((i) => (i + 1) % 3)}>Morph</Button>
    </>
  )
}
```

## Installation

```bash
npx @arctis-sh/@arctis-sh/ui@latest add morph
```

## Usage

```tsx
import { useState } from "react"
import { Morph } from "@/components/motion/morph"
import { Button } from "@/components/ui/button"
```

```tsx
const [index, setIndex] = useState(0)

<>
  <Morph
    items={["/one.png", "/two.png", "/three.png"]}
    index={index}
    className="aspect-[4/3] w-full max-w-md"
  />
  <Button onClick={() => setIndex((i) => (i + 1) % 3)}>Morph</Button>
</>
```

## API Reference

### Morph

| Prop | Type | Default |
| --- | --- | --- |
| items | (string | { src: string; alt?: string })[] |
| index | number | 0 |
| duration | number | 1.1 |
| intensity | number | 0.55 |
| scale | number | 2.4 |
| aberration | number | 0.35 |
| drift | number | 0.4 |
| overlayColor | string | "#05060a" |
| radius | number | 16 |
| className | string | — |

Also accepts the other native `div` attributes. Duration is in seconds. Uses `ogl` for the WebGL melt.

## Source

```tsx
"use client";

import "@/lib/motion-styles";

import {
  useEffect,
  useRef,
  type ComponentProps,
  type CSSProperties,
} from "react";
import { Mesh, Program, Renderer, Texture, Triangle } from "ogl";
import { cn } from "@/lib/utils";

export type MorphItem = {
  src: string;
  alt?: string;
};

export type MorphProps = Omit<ComponentProps<"div">, "children"> & {
  items: Array<MorphItem | string>;
  index?: number;
  /** Seconds for one morph. */
  duration?: number;
  intensity?: number;
  scale?: number;
  aberration?: number;
  drift?: number;
  overlayColor?: string;
  radius?: number;
};

const VERT = `
attribute vec2 position;
attribute vec2 uv;
varying vec2 vUv;
void main() {
  vUv = uv;
  gl_Position = vec4(position, 0.0, 1.0);
}
`;

const FRAG = `
precision highp float;

uniform sampler2D uFrom;
uniform sampler2D uTo;
uniform vec2 uResolution;
uniform vec2 uFromSize;
uniform vec2 uToSize;
uniform float uProgress;
uniform float uIntensity;
uniform float uScale;
uniform float uAberration;
uniform float uDrift;
uniform float uTime;
uniform float uFlat;
uniform vec3 uVignette;

varying vec2 vUv;

float hash2(vec2 p) {
  vec3 p3 = fract(vec3(p.xyx) * 0.1031);
  p3 += dot(p3, p3.yzx + 33.33);
  return fract((p3.x + p3.y) * p3.z);
}

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

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

vec2 cover(vec2 uv, vec2 res, vec2 img) {
  float frame = res.x / max(res.y, 1.0);
  float media = img.x / max(img.y, 1.0);
  vec2 s = vec2(1.0);
  float ratio = frame / max(media, 0.0001);
  if (ratio > 1.0) s.y = 1.0 / ratio;
  else s.x = ratio;
  return (uv - 0.5) * s + 0.5;
}

void main() {
  float p = clamp(uProgress, 0.0, 1.0);
  float envelope = sin(p * 3.14159265);

  vec2 uv = vUv;
  uv += vec2(
    sin(uTime * 0.23 + uv.y * 3.7),
    cos(uTime * 0.19 + uv.x * 3.9)
  ) * uDrift * 0.0075;
  uv = (uv - 0.5) * (1.0 - uDrift * 0.018 * sin(uTime * 0.37)) + 0.5;

  vec2 uvA = uv;
  vec2 uvB = uv;
  float mixAmt = smoothstep(0.0, 1.0, p);

  if (uFlat < 0.5) {
    float n = fbm(uv * uScale + uTime * 0.028);
    float w = fbm(uv * uScale * 1.65 - uTime * 0.018);
    vec2 g = vec2(n, w) - 0.5;
    uvA = uv + g * uIntensity * 0.48 * p;
    uvB = uv - g * uIntensity * 0.48 * (1.0 - p);
    mixAmt = smoothstep(n - 0.14, n + 0.14, p);
  }

  vec2 sampleA = cover(uvA, uResolution, uFromSize);
  vec2 sampleB = cover(uvB, uResolution, uToSize);
  float split = uFlat < 0.5 ? uAberration * envelope * 0.028 : 0.0;

  vec3 colA = vec3(
    texture2D(uFrom, sampleA + vec2(split, 0.0)).r,
    texture2D(uFrom, sampleA).g,
    texture2D(uFrom, sampleA - vec2(split, 0.0)).b
  );
  vec3 colB = vec3(
    texture2D(uTo, sampleB + vec2(split, 0.0)).r,
    texture2D(uTo, sampleB).g,
    texture2D(uTo, sampleB - vec2(split, 0.0)).b
  );

  vec3 color = mix(colA, colB, mixAmt);
  float vig = smoothstep(1.2, 0.28, length(uv - 0.5));
  color = mix(color, uVignette, (1.0 - vig) * 0.26);
  gl_FragColor = vec4(color, 1.0);
}
`;

function asItems(items: Array<MorphItem | string>): MorphItem[] {
  return items.map((item) =>
    typeof item === "string" ? { src: item } : item,
  );
}

function parseHex(hex: string): [number, number, number] {
  let h = (hex || "#05060a").replace("#", "");
  if (h.length === 3) {
    h = h
      .split("")
      .map((c) => c + c)
      .join("");
  }
  const n = Number.parseInt(h, 16);
  return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
}

function easeInOut(t: number) {
  return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
}

function placeholderTexture(gl: Renderer["gl"]) {
  const size = 4;
  const data = new Uint8Array(size * size * 4);
  for (let i = 0; i < size * size; i++) {
    data[i * 4] = 18;
    data[i * 4 + 1] = 18;
    data[i * 4 + 2] = 22;
    data[i * 4 + 3] = 255;
  }
  return new Texture(gl, {
    image: data,
    width: size,
    height: size,
    generateMipmaps: false,
  });
}

function Morph({
  className,
  items,
  index = 0,
  duration = 1.1,
  intensity = 0.55,
  scale = 2.4,
  aberration = 0.35,
  drift = 0.4,
  overlayColor = "#05060a",
  radius = 16,
  style,
  ...props
}: MorphProps) {
  const hostRef = useRef<HTMLDivElement>(null);
  const apiRef = useRef<{
    goTo: (next: number) => void;
    destroy: () => void;
  } | null>(null);
  const shownRef = useRef(index);
  const optsRef = useRef({
    duration,
    intensity,
    scale,
    aberration,
    drift,
    overlayColor,
  });
  optsRef.current = {
    duration,
    intensity,
    scale,
    aberration,
    drift,
    overlayColor,
  };

  const list = asItems(items);
  const sourcesKey = list.map((item) => item.src).join("\0");

  useEffect(() => {
    const host = hostRef.current;
    const slides = sourcesKey
      ? sourcesKey.split("\0").map((src) => ({ src }))
      : [];
    if (!host || slides.length === 0) return;

    const reduced = window.matchMedia(
      "(prefers-reduced-motion: reduce)",
    ).matches;
    const start =
      ((shownRef.current % slides.length) + slides.length) % slides.length;
    shownRef.current = start;

    const renderer = new Renderer({
      alpha: false,
      antialias: true,
      dpr: Math.min(window.devicePixelRatio || 1, 2),
    });
    const gl = renderer.gl;
    gl.clearColor(0.04, 0.04, 0.05, 1);
    const canvas = gl.canvas;
    canvas.className = "arctis-morph-canvas";
    host.appendChild(canvas);

    const geometry = new Triangle(gl);
    const textures = slides.map(() => placeholderTexture(gl));
    const sizes = slides.map(() => [1, 1] as [number, number]);
    let current = start;
    let busy = false;
    let progressFrame = 0;
    let pending: number | null = null;

    const seed = optsRef.current;
    const program = new Program(gl, {
      vertex: VERT,
      fragment: FRAG,
      uniforms: {
        uFrom: { value: textures[current] },
        uTo: { value: textures[current] },
        uResolution: { value: [1, 1] },
        uFromSize: { value: sizes[current] },
        uToSize: { value: sizes[current] },
        uProgress: { value: 0 },
        uIntensity: { value: seed.intensity },
        uScale: { value: seed.scale },
        uAberration: { value: seed.aberration },
        uDrift: { value: seed.drift },
        uTime: { value: 0 },
        uFlat: { value: reduced ? 1 : 0 },
        uVignette: { value: parseHex(seed.overlayColor) },
      },
    });
    const mesh = new Mesh(gl, { geometry, program });

    slides.forEach((slide, i) => {
      const image = new Image();
      image.decoding = "async";
      image.src = slide.src;
      image.onload = () => {
        const texture = new Texture(gl, { generateMipmaps: false });
        texture.image = image;
        textures[i] = texture;
        sizes[i] = [image.naturalWidth || 1, image.naturalHeight || 1];
        if (i === current && !busy) {
          program.uniforms.uFrom.value = texture;
          program.uniforms.uFromSize.value = sizes[i];
        }
      };
    });

    function resize() {
      const el = hostRef.current;
      if (!el) return;
      const width = Math.max(el.offsetWidth, 1);
      const height = Math.max(el.offsetHeight, 1);
      renderer.setSize(width, height);
      program.uniforms.uResolution.value = [gl.canvas.width, gl.canvas.height];
    }

    const observer = new ResizeObserver(resize);
    observer.observe(host);
    resize();

    let frame = 0;
    const tick = (t: number) => {
      frame = requestAnimationFrame(tick);
      const live = optsRef.current;
      program.uniforms.uTime.value = t * 0.001;
      if (!busy) {
        program.uniforms.uIntensity.value = live.intensity;
        program.uniforms.uScale.value = live.scale;
        program.uniforms.uAberration.value = live.aberration;
        program.uniforms.uDrift.value = live.drift;
        program.uniforms.uVignette.value = parseHex(live.overlayColor);
      }
      renderer.render({ scene: mesh });
    };
    frame = requestAnimationFrame(tick);

    function wrap(i: number) {
      return ((i % slides.length) + slides.length) % slides.length;
    }

    function goTo(nextRaw: number) {
      if (slides.length < 2) return;
      const next = wrap(nextRaw);
      if (next === current && !busy) {
        shownRef.current = next;
        return;
      }
      if (busy) {
        pending = next;
        return;
      }

      const live = optsRef.current;
      program.uniforms.uIntensity.value = live.intensity;
      program.uniforms.uScale.value = live.scale;
      program.uniforms.uAberration.value = live.aberration;
      program.uniforms.uDrift.value = live.drift;
      program.uniforms.uVignette.value = parseHex(live.overlayColor);
      program.uniforms.uFrom.value = textures[current];
      program.uniforms.uFromSize.value = sizes[current];
      program.uniforms.uTo.value = textures[next];
      program.uniforms.uToSize.value = sizes[next];

      const seconds = reduced ? Math.min(live.duration, 0.35) : live.duration;
      const total = Math.max(seconds, 0.05) * 1000;
      busy = true;
      shownRef.current = next;
      const began = performance.now();
      cancelAnimationFrame(progressFrame);

      const step = (now: number) => {
        const t = Math.min((now - began) / total, 1);
        program.uniforms.uProgress.value = easeInOut(t);
        if (t < 1) {
          progressFrame = requestAnimationFrame(step);
          return;
        }
        current = next;
        program.uniforms.uFrom.value = textures[next];
        program.uniforms.uFromSize.value = sizes[next];
        program.uniforms.uProgress.value = 0;
        busy = false;
        if (pending !== null && pending !== current) {
          const queued = pending;
          pending = null;
          goTo(queued);
        } else {
          pending = null;
        }
      };
      progressFrame = requestAnimationFrame(step);
    }

    apiRef.current = {
      goTo,
      destroy: () => {
        cancelAnimationFrame(frame);
        cancelAnimationFrame(progressFrame);
        observer.disconnect();
        if (canvas.parentNode === host) host.removeChild(canvas);
        gl.getExtension("WEBGL_lose_context")?.loseContext();
      },
    };

    return () => {
      apiRef.current?.destroy();
      apiRef.current = null;
    };
  }, [sourcesKey]);

  useEffect(() => {
    const api = apiRef.current;
    if (!api || list.length === 0) return;
    const next = ((index % list.length) + list.length) % list.length;
    if (next === shownRef.current) return;
    api.goTo(next);
  }, [index, list.length]);

  return (
    <div
      ref={hostRef}
      data-slot="morph"
      className={cn("arctis-morph", className)}
      style={
        {
          borderRadius: radius,
          ...style,
        } as CSSProperties
      }
      {...props}
    />
  );
}

export { Morph };
```
