Refractive Glass
Move the lens. Bend the pixels.

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.tsximport { 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"
stringTransparent image rendered in the refractive scene.
imageAlt"A floating…"
stringAccessible description for the image inside the experiment.
classNameundefined
stringClasses applied to the complete interactive surface.