Return

Refractive Glass

Move the lens. Bend the pixels.

A real optical layer: the lens refracts a synchronized copy of the rendered scene through an SVG displacement map. The base image stays untouched, while the rim, tint, and chromatic separation make the moving circle read as curved glass.

Source

components/interior/refractive-glass.tsx
"use client";

import Image from "next/image";
import {
  type CSSProperties,
  type PointerEvent as ReactPointerEvent,
  useEffect,
  useId,
  useMemo,
  useRef,
  useState,
} from "react";
import { cn } from "@/lib/utils";
import styles from "./refractive-glass.module.css";

const MAP_SIZE = 128;

type GlassStyle = CSSProperties & {
  "--lens-x"?: string;
  "--lens-y"?: string;
  "--stage-height"?: string;
  "--stage-width"?: string;
};

export interface RefractiveGlassProps {
  className?: string;
  imageSrc?: string;
  imageAlt?: string;
}

function smoothstep(edge0: number, edge1: number, value: number) {
  const t = Math.min(1, Math.max(0, (value - edge0) / (edge1 - edge0)));
  return t * t * (3 - 2 * t);
}

/**
 * Build one radial displacement texture and reuse it for every pointer frame.
 * Only one quadrant is computed; its signed vectors are mirrored into the rest.
 */
function createDisplacementMap(size: number) {
  const canvas = document.createElement("canvas");
  const context = canvas.getContext("2d");

  if (!context) return null;

  canvas.width = size;
  canvas.height = size;

  const pixels = context.createImageData(size, size);
  const half = size / 2;

  const writePixel = (x: number, y: number, red: number, green: number) => {
    const offset = (y * size + x) * 4;
    pixels.data[offset] = red;
    pixels.data[offset + 1] = green;
    pixels.data[offset + 2] = 128;
    pixels.data[offset + 3] = 255;
  };

  for (let y = 0; y < half; y += 1) {
    for (let x = 0; x < half; x += 1) {
      const normalizedX = (x + 0.5 - half) / half;
      const normalizedY = (y + 0.5 - half) / half;
      const radius = Math.hypot(normalizedX, normalizedY);
      const bend = radius < 1 ? smoothstep(0.025, 1, radius) ** 1.15 : 0;
      const vectorX = -normalizedX * bend * 0.53;
      const vectorY = -normalizedY * bend * 0.53;

      const left = Math.round(127.5 + vectorX * 255);
      const top = Math.round(127.5 + vectorY * 255);
      const right = Math.round(127.5 - vectorX * 255);
      const bottom = Math.round(127.5 - vectorY * 255);

      writePixel(x, y, left, top);
      writePixel(size - 1 - x, y, right, top);
      writePixel(x, size - 1 - y, left, bottom);
      writePixel(size - 1 - x, size - 1 - y, right, bottom);
    }
  }

  context.putImageData(pixels, 0, 0);
  return canvas.toDataURL("image/png");
}

function Scene({ imageAlt, imageSrc }: Required<Pick<RefractiveGlassProps, "imageAlt" | "imageSrc">>) {
  return (
    <div className={styles.scene}>
      <div className={styles.ambientGlow} />
      <div className={styles.grid} />
      <div className={styles.object}>
        <Image
          alt={imageAlt}
          className={styles.image}
          draggable={false}
          fill
          priority
          sizes="(max-width: 640px) 42vw, 220px"
          src={imageSrc}
        />
      </div>
    </div>
  );
}

export function RefractiveGlass({
  className,
  imageAlt = "A floating three-dimensional Minecraft creeper",
  imageSrc = "/3d-minecraft.png",
}: RefractiveGlassProps) {
  const stageRef = useRef<HTMLDivElement>(null);
  const frameRef = useRef<number | null>(null);
  const hasInteractedRef = useRef(false);
  const currentPointRef = useRef({ x: 0, y: 0 });
  const targetPointRef = useRef({ x: 0, y: 0 });
  const lastFrameTimeRef = useRef<number | null>(null);
  const [mapUrl, setMapUrl] = useState<string | null>(null);
  const filterId = useId().replaceAll(":", "");
  const filterUrl = useMemo(() => `url(#${filterId})`, [filterId]);

  useEffect(() => {
    let cancelled = false;
    queueMicrotask(() => {
      const nextMap = createDisplacementMap(MAP_SIZE);
      if (!cancelled) setMapUrl(nextMap);
    });

    const stage = stageRef.current;
    const syncStageSize = () => {
      if (!stage) return;
      stage.style.setProperty("--stage-width", `${stage.clientWidth}px`);
      stage.style.setProperty("--stage-height", `${stage.clientHeight}px`);
      if (!hasInteractedRef.current) {
        const initialPoint = {
          x: stage.clientWidth * 0.24,
          y: stage.clientHeight * 0.68,
        };
        currentPointRef.current = initialPoint;
        targetPointRef.current = initialPoint;
        stage.style.setProperty("--lens-x", `${initialPoint.x}px`);
        stage.style.setProperty("--lens-y", `${initialPoint.y}px`);
      }
    };
    const resizeObserver = new ResizeObserver(syncStageSize);

    if (stage) {
      syncStageSize();
      resizeObserver.observe(stage);
    }

    return () => {
      cancelled = true;
      resizeObserver.disconnect();
      if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
    };
  }, []);

  const animateLens = (time: number) => {
    const stage = stageRef.current;
    if (!stage) return;

    const elapsed = lastFrameTimeRef.current === null
      ? 16.67
      : Math.min(34, time - lastFrameTimeRef.current);
    const easing = 1 - Math.exp(-elapsed / 46);
    const current = currentPointRef.current;
    const target = targetPointRef.current;

    current.x += (target.x - current.x) * easing;
    current.y += (target.y - current.y) * easing;
    stage.style.setProperty("--lens-x", `${current.x}px`);
    stage.style.setProperty("--lens-y", `${current.y}px`);
    lastFrameTimeRef.current = time;

    if (Math.hypot(target.x - current.x, target.y - current.y) > 0.08) {
      frameRef.current = requestAnimationFrame(animateLens);
      return;
    }

    current.x = target.x;
    current.y = target.y;
    stage.style.setProperty("--lens-x", `${target.x}px`);
    stage.style.setProperty("--lens-y", `${target.y}px`);
    lastFrameTimeRef.current = null;
    frameRef.current = null;
  };

  const moveLens = (event: ReactPointerEvent<HTMLDivElement>) => {
    const bounds = event.currentTarget.getBoundingClientRect();
    const coalescedEvents = event.nativeEvent.getCoalescedEvents?.() ?? [];
    const pointer = coalescedEvents[coalescedEvents.length - 1] ?? event.nativeEvent;
    hasInteractedRef.current = true;
    targetPointRef.current = {
      x: Math.min(bounds.width, Math.max(0, pointer.clientX - bounds.left)),
      y: Math.min(bounds.height, Math.max(0, pointer.clientY - bounds.top)),
    };

    if (frameRef.current === null) frameRef.current = requestAnimationFrame(animateLens);
  };

  const style: GlassStyle = {
    "--lens-x": "112px",
    "--lens-y": "160px",
  };

  return (
    <div
      ref={stageRef}
      aria-label="Move your pointer across the surface to refract the image"
      className={cn(styles.stage, className)}
      onPointerDown={(event) => {
        event.currentTarget.setPointerCapture(event.pointerId);
        moveLens(event);
      }}
      onPointerMove={moveLens}
      role="img"
      style={style}
    >
      <Scene imageAlt={imageAlt} imageSrc={imageSrc} />

      <div aria-hidden className={styles.lens}>
        <div
          className={styles.refraction}
          style={{ filter: mapUrl ? `${filterUrl} saturate(1.12) brightness(1.04) contrast(1.025)` : undefined }}
        >
          <div className={styles.sample}>
            <Scene imageAlt="" imageSrc={imageSrc} />
          </div>
        </div>
        <span className={styles.glassBody} />
        <span className={styles.surfaceGlare} />
        <span className={styles.caustic} />
      </div>

      <svg aria-hidden className={styles.filterDefinitions} focusable="false">
        <defs>
          <filter
            id={filterId}
            colorInterpolationFilters="sRGB"
            height="100%"
            primitiveUnits="objectBoundingBox"
            width="100%"
            x="0"
            y="0"
          >
            {mapUrl ? (
              <>
                <feImage height="1" href={mapUrl} preserveAspectRatio="none" result="map" width="1" x="0" y="0" />
                <feDisplacementMap in="SourceGraphic" in2="map" result="redBend" scale="0.39" xChannelSelector="R" yChannelSelector="G" />
                <feColorMatrix in="redBend" result="red" values="1 0 0 0 0  0 0 0 0 0  0 0 0 0 0  0 0 0 1 0" />
                <feDisplacementMap in="SourceGraphic" in2="map" result="greenBend" scale="0.34" xChannelSelector="R" yChannelSelector="G" />
                <feColorMatrix in="greenBend" result="green" values="0 0 0 0 0  0 1 0 0 0  0 0 0 0 0  0 0 0 1 0" />
                <feDisplacementMap in="SourceGraphic" in2="map" result="blueBend" scale="0.29" xChannelSelector="R" yChannelSelector="G" />
                <feColorMatrix in="blueBend" result="blue" values="0 0 0 0 0  0 0 0 0 0  0 0 1 0 0  0 0 0 1 0" />
                <feBlend in="red" in2="green" mode="screen" result="redGreen" />
                <feBlend in="redGreen" in2="blue" mode="screen" />
              </>
            ) : null}
          </filter>
        </defs>
      </svg>
    </div>
  );
}

Usage

glass-study.tsx
import { RefractiveGlass } from "@/components/interior/refractive-glass";

export function GlassStudy() {
  return (
    <RefractiveGlass
      imageSrc="/3d-minecraft.png"
      imageAlt="A floating three-dimensional Minecraft creeper"
      className="w-full rounded-xl"
    />
  );
}

Props

imageSrc"/3d-minecraft.png"
string

Transparent image rendered in the refractive scene.

imageAlt"A floating…"
string

Accessible description for the image inside the experiment.

classNameundefined
string

Classes applied to the complete interactive surface.