# Portrait Print with a Liquid Glass Bead That Chases the Pointer and Shows a Second Photograph

> Image reveal in WebGL: a bead of liquid glass chases the cursor across a portrait and shows another photo refracted inside. Free React and HTML code.

A shadcn/ui block from the [@weinc](https://we.inc/components?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/weinc/portrait-print-with-a-liquid-glass-bead-that-chases-the-pointer-and-shows-a-second-photograph).

- Page, with a live preview: https://shadcn.exchange/r/weinc/portrait-print-with-a-liquid-glass-bead-that-chases-the-pointer-and-shows-a-second-photograph
- Screenshot: https://shadcn.exchange/shot/weinc/portrait-print-with-a-liquid-glass-bead-that-chases-the-pointer-and-shows-a-second-photograph/light.png
- Registry item: https://we.inc/r/portrait-print-with-a-liquid-glass-bead-that-chases-the-pointer-and-shows-a-second-photograph.json
- Listed: 2026-10-28, source last changed 2026-10-28

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/weinc/portrait-print-with-a-liquid-glass-bead-that-chases-the-pointer-and-shows-a-second-photograph.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `framer-motion`

## Files

### `components/we-inc/portrait-print-with-a-liquid-glass-bead-that-chases-the-pointer-and-shows-a-second-photograph.tsx`

```tsx
import { useEffect, useRef, useState } from "react";
import type { KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from "react";
import { useReducedMotion } from "framer-motion";

// Two pictures of the same size. The first is the print, the second is what the bead shows.
const looks = [
  {
    name: "Cobalt",
    src: "https://images.pexels.com/photos/30755158/pexels-photo-30755158.jpeg?auto=compress&cs=tinysrgb&w=900",
    alt: "A woman with her hair bound into a tall stack of spheres, in a pale blue oversized shirt against a blue wall, one hand at her jaw",
  },
  {
    name: "Tangerine",
    src: "https://images.pexels.com/photos/39325161/pexels-photo-39325161.jpeg?auto=compress&cs=tinysrgb&w=900",
    alt: "A woman with cropped hair in an orange top and long white wrapped gloves against an orange wall",
  },
];

/** Size of each drop as a share of the print's height. The first leads, the rest trail behind and melt back in. */
const DROPS = [0.15, 0.084, 0.068, 0.054, 0.043, 0.034];
/** How quickly each drop catches up. Lower numbers lag further, so a fast move strings the bead out. */
const PULL = [130, 62, 44, 31, 22, 15];

const VERT = `attribute vec2 a; void main(){ gl_Position = vec4(a, 0.0, 1.0); }`;

const FRAG = `precision highp float;
uniform vec2 u_res;
uniform sampler2D u_a;
uniform sampler2D u_b;
uniform vec2 u_img;
uniform vec3 u_drops[6];
uniform float u_time;
uniform float u_swap;

vec2 cover(vec2 uv, float img){
  float A = u_res.x / u_res.y;
  vec2 t = uv;
  if (img > A) t.x = (uv.x - 0.5) * A / img + 0.5;
  else t.y = (uv.y - 0.5) * img / A + 0.5;
  return clamp(t, 0.0, 1.0);
}
// The bead is a sum of soft fields, one per drop. Where the sum passes 1 there is liquid.
float field(vec2 p, float A, out vec2 grad){
  float f = 0.0;
  grad = vec2(0.0);
  for (int i = 0; i < 6; i++){
    vec2 d = p - vec2(u_drops[i].x * A, u_drops[i].y);
    float d2 = dot(d, d) + 0.00002;
    float r2 = u_drops[i].z * u_drops[i].z;
    f += r2 / d2;
    grad -= 2.0 * r2 * d / (d2 * d2);
  }
  return f;
}
vec3 outside(vec2 uv){ return mix(texture2D(u_a, cover(uv, u_img.x)).rgb, texture2D(u_b, cover(uv, u_img.y)).rgb, u_swap); }
vec3 inside(vec2 uv){ return mix(texture2D(u_b, cover(uv, u_img.y)).rgb, texture2D(u_a, cover(uv, u_img.x)).rgb, u_swap); }

void main(){
  vec2 uv = vec2(gl_FragCoord.x / u_res.x, 1.0 - gl_FragCoord.y / u_res.y);
  float A = u_res.x / u_res.y;
  vec2 p = vec2(uv.x * A, uv.y);
  // A slow wobble so the outline never sits perfectly round.
  p += 0.006 * vec2(sin(p.y * 13.0 + u_time * 1.3), cos(p.x * 11.0 - u_time * 1.1));

  vec2 g;
  float f = field(p, A, g);
  float gl = length(g) + 0.0001;
  vec2 out_ = -g / gl;
  vec2 light = normalize(vec2(-0.55, -0.83));

  // The print, with the bead's shadow lying on it away from the light.
  vec2 gs;
  float fs = field(p + light * 0.022, A, gs);
  vec3 base = outside(uv);
  base *= 1.0 - 0.50 * smoothstep(0.62, 1.12, fs);
  // Light that has passed through the bead lands as a bright crescent just beyond it.
  vec2 gc;
  float fc = field(p + light * 0.006, A, gc);
  base += vec3(1.0, 0.96, 0.9) * 0.20 * smoothstep(0.90, 1.0, fc) * (1.0 - smoothstep(1.0, 1.05, f)) * max(dot(out_, -light), 0.0);

  // Inside: 0 at the rim, 1 where the dome is flat on top.
  float k = clamp((f - 1.0) / 1.6, 0.0, 1.0);
  float steep = pow(1.0 - k, 2.4);
  vec2 lens = out_ * steep;
  vec2 pull = vec2(lens.x / A, lens.y);
  vec3 seen;
  seen.r = inside(uv - pull * 0.060).r;
  seen.g = inside(uv - pull * 0.065).g;
  seen.b = inside(uv - pull * 0.070).b;
  // The far side of a drop glows and the side nearest the light is shaded.
  seen *= 1.0 + 0.55 * steep * dot(out_, -light);
  seen += vec3(1.0) * 0.16 * pow(1.0 - k, 7.0);
  // Two reflections of the lamp: a hard one near the rim and a soft bounce opposite.
  vec3 n = normalize(vec3(lens * 1.5, 1.0));
  float hit = max(dot(n, normalize(vec3(light * 1.05, 0.72))), 0.0);
  seen += vec3(1.0) * smoothstep(0.972, 0.992, hit) * 0.95;
  seen += vec3(1.0) * pow(hit, 9.0) * 0.16;
  // A long soft streak of the studio window curving round the shoulder of the bead.
  float facing = dot(out_, light);
  float band = smoothstep(0.30, 0.46, steep) * (1.0 - smoothstep(0.50, 0.74, steep));
  seen += vec3(1.0) * band * smoothstep(0.45, 0.95, facing) * 0.34;
  float bounce = max(dot(n, normalize(vec3(-light * 1.2, 0.6))), 0.0);
  seen += vec3(1.0, 0.97, 0.92) * smoothstep(0.93, 0.99, bounce) * 0.22;

  float edge = gl * 1.6 / u_res.y;
  float cut = smoothstep(1.0 - edge, 1.0 + edge, f);
  // A hairline of dark where the liquid meets the paper.
  base *= 1.0 - 0.35 * smoothstep(1.0 - edge * 5.0, 1.0, f) * (1.0 - cut);
  gl_FragColor = vec4(mix(base, seen, cut), 1.0);
}`;

export default function PortraitPrintWithALiquidGlassBeadThatChasesThePointerAndShowsASecondPhotograph() {
  const reduce = useReducedMotion();
  const frame = useRef<HTMLButtonElement>(null);
  const canvas = useRef<HTMLCanvasElement>(null);
  const first = useRef<HTMLImageElement>(null);
  const second = useRef<HTMLImageElement>(null);
  /** Where the bead is heading (0 to 1 each way) and when a person last steered it. */
  const aim = useRef({ x: 0.6, y: 0.36, at: -1e6 });
  const flip = useRef(0);
  const [live, setLive] = useState(false);
  const [swapped, setSwapped] = useState(false);

  useEffect(() => {
    flip.current = swapped ? 1 : 0;
  }, [swapped]);

  useEffect(() => {
    const el = frame.current;
    const cv = canvas.current;
    const one = first.current;
    const two = second.current;
    if (reduce || !el || !cv || !one || !two) return;
    const gl = cv.getContext("webgl", { antialias: false, alpha: false });
    if (!gl) return;

    const compile = (type: number, source: string) => {
      const shader = gl.createShader(type)!;
      gl.shaderSource(shader, source);
      gl.compileShader(shader);
      return shader;
    };
    const program = gl.createProgram()!;
    gl.attachShader(program, compile(gl.VERTEX_SHADER, VERT));
    gl.attachShader(program, compile(gl.FRAGMENT_SHADER, FRAG));
    gl.linkProgram(program);
    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return;
    gl.useProgram(program);
    gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
    const at = gl.getAttribLocation(program, "a");
    gl.enableVertexAttribArray(at);
    gl.vertexAttribPointer(at, 2, gl.FLOAT, false, 0, 0);
    const u = (name: string) => gl.getUniformLocation(program, name);
    gl.uniform1i(u("u_a"), 0);
    gl.uniform1i(u("u_b"), 1);
    const uni = { res: u("u_res"), img: u("u_img"), drops: u("u_drops[0]"), time: u("u_time"), swap: u("u_swap") };

    const n = DROPS.length;
    const pos = new Float32Array(n * 2);
    const vel = new Float32Array(n * 2);
    const packed = new Float32Array(n * 3);
    for (let i = 0; i < n; i++) pos.set([aim.current.x, aim.current.y], i * 2);

    let ready = false;
    let visible = true;
    let raf = 0;
    let last = 0;
    let swap = flip.current;
    const started = performance.now();

    const upload = () => {
      if (ready || !one.complete || !two.complete || !one.naturalWidth || !two.naturalWidth) return;
      try {
        [one, two].forEach((img, unit) => {
          gl.activeTexture(gl.TEXTURE0 + unit);
          gl.bindTexture(gl.TEXTURE_2D, gl.createTexture());
          gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
          gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
          gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
          gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
          gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, img);
        });
        gl.uniform2f(uni.img, one.naturalWidth / one.naturalHeight, two.naturalWidth / two.naturalHeight);
        ready = true;
        setLive(true);
      } catch {
        // A picture could not be read into the shader: the still version underneath stays on show.
        ready = false;
      }
    };

    const resize = () => {
      const ratio = Math.min(window.devicePixelRatio || 1, 2);
      cv.width = Math.max(1, Math.round(el.clientWidth * ratio));
      cv.height = Math.max(1, Math.round(el.clientHeight * ratio));
      gl.viewport(0, 0, cv.width, cv.height);
    };

    const loop = (stamp: number) => {
      if (ready) {
        const t = (stamp - started) / 1000;
        const dt = Math.min(last ? (stamp - last) / 1000 : 0.016, 0.033);
        last = stamp;
        const A = cv.width / cv.height;
        // With nobody steering, the bead wanders the print in a slow, uneven loop.
        let tx = aim.current.x;
        let ty = aim.current.y;
        if (stamp - aim.current.at > 2200) {
          tx = 0.5 + 0.25 * Math.sin(t * 0.9 + 0.4) + 0.05 * Math.sin(t * 2.3);
          ty = 0.42 + 0.22 * Math.sin(t * 0.63 + 1.9) + 0.04 * Math.cos(t * 1.9);
        }
        for (let i = 0; i < n; i++) {
          // Each trailing drop orbits a little way off the one ahead, which keeps the merged outline lobed.
          const reach = i === 0 ? 0 : 0.034 + i * 0.006;
          const turn = t * (0.7 + i * 0.23) + i * 2.1;
          const gx = i === 0 ? tx : pos[(i - 1) * 2] + (Math.cos(turn) * reach) / A;
          const gy = i === 0 ? ty : pos[(i - 1) * 2 + 1] + Math.sin(turn) * reach;
          const k = PULL[i];
          const drag = 2 * Math.sqrt(k) * (i === 0 ? 0.62 : 0.8);
          vel[i * 2] += ((gx - pos[i * 2]) * k - vel[i * 2] * drag) * dt;
          vel[i * 2 + 1] += ((gy - pos[i * 2 + 1]) * k - vel[i * 2 + 1] * drag) * dt;
          pos[i * 2] += vel[i * 2] * dt;
          pos[i * 2 + 1] += vel[i * 2 + 1] * dt;
          packed.set([pos[i * 2], pos[i * 2 + 1], DROPS[i]], i * 3);
        }
        swap += (flip.current - swap) * Math.min(1, dt * 7);
        gl.uniform2f(uni.res, cv.width, cv.height);
        gl.uniform3fv(uni.drops, packed);
        gl.uniform1f(uni.time, t);
        gl.uniform1f(uni.swap, swap);
        gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
      }
      raf = visible && !document.hidden ? requestAnimationFrame(loop) : 0;
    };
    const wake = () => {
      if (!raf && visible && !document.hidden) {
        last = 0;
        raf = requestAnimationFrame(loop);
      }
    };

    const ro = new ResizeObserver(resize);
    ro.observe(el);
    const io = new IntersectionObserver(([entry]) => {
      visible = entry.isIntersecting;
      wake();
    });
    io.observe(el);
    document.addEventListener("visibilitychange", wake);
    one.addEventListener("load", upload);
    two.addEventListener("load", upload);
    upload();
    resize();
    wake();

    return () => {
      cancelAnimationFrame(raf);
      ro.disconnect();
      io.disconnect();
      document.removeEventListener("visibilitychange", wake);
      one.removeEventListener("load", upload);
      two.removeEventListener("load", upload);
    };
  }, [reduce]);

  function steer(e: ReactPointerEvent<HTMLButtonElement>) {
    const r = e.currentTarget.getBoundingClientRect();
    aim.current = { x: (e.clientX - r.left) / r.width, y: (e.clientY - r.top) / r.height, at: performance.now() };
  }
  /** Arrow keys roll the bead. Enter and Space, like a click, swap the two pictures. */
  function onKey(e: ReactKeyboardEvent<HTMLButtonElement>) {
    const move: Record<string, [number, number]> = { ArrowLeft: [-0.08, 0], ArrowRight: [0.08, 0], ArrowUp: [0, -0.07], ArrowDown: [0, 0.07] };
    const by = move[e.key];
    if (!by) return;
    e.preventDefault();
    const a = aim.current;
    aim.current = { x: Math.min(0.9, Math.max(0.1, a.x + by[0])), y: Math.min(0.9, Math.max(0.1, a.y + by[1])), at: performance.now() + 4000 };
  }

  const [under, over] = swapped ? [looks[1], looks[0]] : looks;

  return (
    <section className="relative isolate flex min-h-[760px] w-full flex-col items-center justify-center overflow-hidden bg-[#0b0b0d] px-4 py-14">
      <style>{`
        .lgb-still { clip-path: circle(21% at 61% 35%); }
        .lgb-ring { left: 61%; top: 35%; width: 52.5%; aspect-ratio: 1; transform: translate(-50%, -50%); }
      `}</style>
      <div aria-hidden="true" className="absolute inset-0 -z-10" style={{ background: "radial-gradient(60% 50% at 50% 42%, #1b1d24 0%, #0b0b0d 70%)" }} />

      <button
        ref={frame}
        type="button"
        aria-pressed={swapped}
        aria-label={`Swap the two portraits. ${under.name} is the print and ${over.name} shows through the bead. Arrow keys move the bead.`}
        onClick={() => setSwapped((s) => !s)}
        onPointerMove={steer}
        onPointerDown={steer}
        onKeyDown={onKey}
        className="relative block aspect-[4/5] w-[min(82vw,420px)] cursor-pointer touch-pan-y overflow-hidden rounded-[10px] bg-[#16171c] shadow-[0_60px_90px_-40px_rgba(0,0,0,0.9),0_0_0_1px_rgba(255,255,255,0.07)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-4 focus-visible:ring-offset-[#0b0b0d]"
      >
        {/* The still version: the print, and the second picture through a round window. Shown with reduced motion or without WebGL. */}
        <img ref={first} src={looks[0].src} alt={looks[0].alt} crossOrigin="anonymous" width={900} height={1350} className={`absolute inset-0 h-full w-full object-cover ${swapped ? "lgb-still z-10" : ""}`} />
        <img ref={second} src={looks[1].src} alt={looks[1].alt} crossOrigin="anonymous" width={900} height={1350} className={`absolute inset-0 h-full w-full object-cover ${swapped ? "" : "lgb-still z-10"}`} />
        <span aria-hidden="true" className="lgb-ring absolute z-10 block rounded-full shadow-[inset_0_0_0_1px_rgba(255,255,255,0.55),inset_6px_8px_14px_rgba(255,255,255,0.22),8px_12px_22px_rgba(0,0,0,0.45)]" />
        <canvas ref={canvas} aria-hidden="true" className={`absolute inset-0 z-20 h-full w-full transition-opacity duration-500 ${live ? "opacity-100" : "opacity-0"}`} />
      </button>

      <div className="mt-8 flex w-[min(82vw,420px)] items-baseline justify-between gap-4 px-0.5 text-[13px]">
        <p className="text-zinc-300">
          <span className="font-semibold text-white">{under.name}</span>
          <span className="mx-2 text-zinc-500">over</span>
          {over.name}
        </p>
        <p className="text-zinc-400">Click the print to swap</p>
      </div>
    </section>
  );
}
```
