Glass lens
A round lens that follows the pointer over an image and refracts it through a bent rim.
npx shadcn@latest add https://ui.goodtabs.co/r/glass-lens.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/glass-lens
A 148px lens on a full window image. Without a mouse it rests on the golden section.
Loupe
A 88px lens at 1.8 times in a captioned figure, with its own resting place.
Usage
import { GlassLens } from "@/components/ui/glass-lens"
<GlassLens src="/photo.jpg" alt="What the photo shows" radius={96} className="rounded-lg" />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| src | string | The image. Same origin, or served with CORS headers. | |
| alt | string | Alt text for the real image, which stays in the DOM. | |
| radius | number | 96 | Lens radius in CSS pixels. Capped at 38 percent of the short side of the box. |
| zoom | number | 1.6 | Magnification through the middle. |
| rest | readonly [number, number] | [0.618, 0.382] | Where the lens rests, as fractions of the width and height from the top left. |
| lensLabel | string | "Lens position, move it with the arrow keys" | Accessible name of the lens control. |
Source
glass-lens/glass-lens.tsx
"use client";
import * as React from "react";
import { ShaderCanvas } from "@/components/ui/shader-canvas";
import { cn } from "@/lib/utils";
const FRAGMENT = `precision mediump float;
uniform vec2 uResolution;
uniform float uScale;
uniform float uLoaded;
uniform sampler2D uImage;
uniform vec2 uImageSize;
uniform vec2 uLens;
uniform float uRadius;
uniform float uZoom;
uniform vec3 uInk;
uniform vec3 uPaper;
// The same crop as object-cover, centred.
vec2 cover(vec2 uv, vec2 box) {
float excess = (box.x / box.y) / (uImageSize.x / uImageSize.y);
vec2 ratio = excess > 1.0 ? vec2(1.0, 1.0 / excess) : vec2(excess, 1.0);
return uv * ratio + (1.0 - ratio) * 0.5;
}
void main() {
gl_FragColor = vec4(0.0);
if (uLoaded < 0.5) return;
vec2 box = uResolution / uScale;
vec2 p = gl_FragCoord.xy / uScale;
vec2 d = p - uLens;
float dist = length(d);
// The lightest and darkest tones of the theme, whichever way round the theme has them.
vec3 light = max(uInk, uPaper);
vec3 dark = min(uInk, uPaper);
// One light from above, so the shadow only falls straight down.
float fall = 1.0 - smoothstep(-10.0, 44.0, length(d + vec2(0.0, 14.0)) - uRadius);
float shadow = fall * fall * 0.5;
float aa = 0.75 / uScale;
float inside = 1.0 - smoothstep(-aa, aa, dist - uRadius);
if (inside <= 0.0) {
gl_FragColor = vec4(dark * shadow, shadow);
return;
}
// A superellipse dome: flat through the middle, falling away fast at the rim. Snell's law through its
// normal bends the view inward, so the rim folds a mirrored strip of the image into a few pixels.
float r = min(dist / uRadius, 0.995);
vec2 dir = d / max(dist, 0.001);
float edge = pow(r, 8.0);
float height = pow(1.0 - edge, 0.125);
float slope = min(pow(r, 7.0) * pow(1.0 - edge, -0.875), 5.0);
vec3 normal = normalize(vec3(dir * slope, 1.0));
vec3 ray = refract(vec3(0.0, 0.0, -1.0), normal, 0.667);
vec2 shift = ray.xy / max(-ray.z, 0.2) * height * uRadius * 0.55;
vec2 q = uLens + d / uZoom + shift;
vec3 color = texture2D(uImage, cover(q / box, box)).rgb;
// Light catches the top of the rim and the hairline around it, and the lower rim falls off.
float above = 0.5 + 0.5 * dot(dir, vec2(0.0, 1.0));
float glint = pow(max(dot(normal, normalize(vec3(0.0, 0.62, 0.78))), 0.0), 48.0);
float lip = smoothstep(uRadius - 1.5, uRadius - 0.5, dist);
color *= 1.0 - pow(r, 10.0) * (1.0 - above) * 0.45;
color = mix(color, light, glint * 0.55);
color = mix(color, light, lip * mix(0.1, 0.5, above));
float alpha = inside + shadow * (1.0 - inside);
gl_FragColor = vec4(color * inside + dark * shadow * (1.0 - inside), alpha);
}`;
const TOKENS = { uInk: "--foreground", uPaper: "--background" };
/** The golden section on both axes: a resting place that is off centre without feeling lost. */
const REST: readonly [number, number] = [0.618, 0.382];
/** Room kept between the lens and the frame, in CSS pixels, so the focus ring is never clipped. */
const INSET = 8;
/** One arrow press, in CSS pixels. Shift moves four times as far. */
const STEP = 16;
/** Smoothing rate per second, for 1 - e^(-k dt). */
const GLIDE = 12;
type GlassLensProps = Omit<React.ComponentProps<"div">, "children"> & {
/** The image. Same origin, or served with CORS headers, or the lens falls back to CSS. */
src: string;
/** Alt text for the real image, which stays in the DOM. */
alt: string;
/** Lens radius in CSS pixels. Capped at 38 percent of the short side of the box. */
radius?: number;
/** Magnification through the middle of the lens. */
zoom?: number;
/** Where the lens rests, as fractions of the width and height from the top left. */
rest?: readonly [number, number];
/** Accessible name of the lens control. */
lensLabel?: string;
};
/**
* A round lens that follows the pointer over an image and refracts it. On touch and under reduced
* motion it rests at a fixed position. Arrow keys move it when focused, and Home sends it back.
*/
function GlassLens({
src,
alt,
radius = 96,
zoom = 1.6,
rest = REST,
lensLabel = "Lens position, move it with the arrow keys",
className,
...props
}: GlassLensProps) {
const root = React.useRef<HTMLDivElement>(null);
const handle = React.useRef<HTMLDivElement>(null);
const spare = React.useRef<HTMLDivElement>(null);
const spareImage = React.useRef<HTMLImageElement>(null);
const drawFrame = React.useRef<(() => void) | null>(null);
const [uniforms] = React.useState(() => ({ uLens: [0, 0], uRadius: radius, uZoom: zoom }));
const [size, setSize] = React.useState(radius);
const [share, setShare] = React.useState({ x: Math.round(rest[0] * 100), y: Math.round(rest[1] * 100) });
const textures = React.useMemo(() => ({ uImage: src }), [src]);
const [restX, restY] = rest;
// Layout effect, so the first frame ShaderCanvas draws already has the lens in place.
React.useLayoutEffect(() => {
const el = root.current;
const knob = handle.current;
if (!el || !knob) return;
const still = window.matchMedia("(prefers-reduced-motion: reduce)");
const fine = window.matchMedia("(hover: hover) and (pointer: fine)");
let width = 1;
let height = 1;
let lens = radius;
let frame = 0;
let last = 0;
let home = { x: restX, y: restY };
const now = { x: 0, y: 0 };
const goal = { x: 0, y: 0 };
const paint = () => {
const dx = now.x - restX * width;
const dy = now.y - restY * height;
uniforms.uLens[0] = now.x;
uniforms.uLens[1] = height - now.y;
uniforms.uRadius = lens;
uniforms.uZoom = zoom;
drawFrame.current?.();
knob.style.transform = `translate(${dx}px, ${dy}px)`;
// The CSS fallback, while it is mounted. The picture inside runs the other way, scaled by the zoom.
if (spare.current) spare.current.style.transform = `translate(${dx}px, ${dy}px)`;
if (spareImage.current) spareImage.current.style.transform = `translate(${-dx * zoom}px, ${-dy * zoom}px)`;
};
const tick = (time: number) => {
const dt = last ? Math.min((time - last) / 1000, 0.05) : 0;
last = time;
const k = 1 - Math.exp(-GLIDE * dt);
now.x += (goal.x - now.x) * k;
now.y += (goal.y - now.y) * k;
const settled = Math.hypot(goal.x - now.x, goal.y - now.y) < 0.1;
if (settled) {
now.x = goal.x;
now.y = goal.y;
}
paint();
frame = settled ? 0 : requestAnimationFrame(tick);
if (settled) last = 0;
};
const moveTo = (x: number, y: number, jump: boolean) => {
const margin = lens + INSET;
goal.x = Math.min(Math.max(x, margin), Math.max(width - margin, margin));
goal.y = Math.min(Math.max(y, margin), Math.max(height - margin, margin));
if (jump) {
now.x = goal.x;
now.y = goal.y;
paint();
} else if (!frame) frame = requestAnimationFrame(tick);
};
const measure = () => {
const box = el.getBoundingClientRect();
width = Math.max(box.width, 1);
height = Math.max(box.height, 1);
lens = Math.min(radius, Math.min(width, height) * 0.38);
setSize(lens);
moveTo(home.x * width, home.y * height, true);
};
const onKey = (e: KeyboardEvent) => {
const step = e.shiftKey ? STEP * 4 : STEP;
const arrows: Record<string, [number, number]> = {
ArrowLeft: [-step, 0],
ArrowRight: [step, 0],
ArrowUp: [0, -step],
ArrowDown: [0, step],
};
const arrow = arrows[e.key];
if (!arrow && e.key !== "Home") return;
e.preventDefault();
const x = arrow ? goal.x + arrow[0] : restX * width;
const y = arrow ? goal.y + arrow[1] : restY * height;
moveTo(x, y, still.matches);
home = { x: goal.x / width, y: goal.y / height };
// A new share re-renders, and ShaderCanvas redraws its one frame under reduced motion.
setShare({ x: Math.round(home.x * 100), y: Math.round(home.y * 100) });
};
const onMove = (e: PointerEvent) => {
if (e.pointerType === "touch" || still.matches) return;
const box = el.getBoundingClientRect();
moveTo(e.clientX - box.left, e.clientY - box.top, false);
};
const onLeave = () => {
if (!still.matches) moveTo(home.x * width, home.y * height, false);
};
measure();
const resizer = new ResizeObserver(measure);
resizer.observe(el);
knob.addEventListener("keydown", onKey);
if (fine.matches) {
el.addEventListener("pointermove", onMove, { passive: true });
el.addEventListener("pointerleave", onLeave);
}
return () => {
if (frame) cancelAnimationFrame(frame);
resizer.disconnect();
knob.removeEventListener("keydown", onKey);
el.removeEventListener("pointermove", onMove);
el.removeEventListener("pointerleave", onLeave);
};
}, [radius, restX, restY, uniforms, zoom]);
// Both the control and the CSS fallback start at the rest position, and JavaScript only ever offsets them.
const place = {
left: `${restX * 100}%`,
top: `${restY * 100}%`,
width: size * 2,
height: size * 2,
margin: -size,
};
return (
<div
ref={root}
data-slot="glass-lens"
className={cn("[container-type:size] relative isolate aspect-3/2 overflow-hidden bg-muted", className)}
{...props}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={src} alt={alt} decoding="async" className="absolute inset-0 size-full object-cover" />
<ShaderCanvas
fragment={FRAGMENT}
uniforms={uniforms}
tokens={TOKENS}
textures={textures}
maxDpr={2}
frames="demand"
invalidateRef={drawFrame}
>
<div
ref={spare}
className="absolute overflow-hidden rounded-full border border-foreground/30 shadow-lg"
style={place}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
ref={spareImage}
src={src}
alt=""
className="absolute max-w-none object-cover"
style={{
width: `${zoom * 100}cqw`,
height: `${zoom * 100}cqh`,
left: `calc(${size}px - ${restX * zoom * 100}cqw)`,
top: `calc(${size}px - ${restY * zoom * 100}cqh)`,
}}
/>
</div>
</ShaderCanvas>
<div
ref={handle}
role="slider"
tabIndex={0}
aria-label={lensLabel}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={share.x}
aria-valuetext={`${share.x} percent across, ${share.y} percent down`}
data-slot="glass-lens-handle"
className="pointer-events-none absolute rounded-full focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
style={place}
/>
</div>
);
}
export { GlassLens };
components/ui/shader-canvas.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
type UniformValue = number | readonly number[];
type ShaderCanvasProps = React.ComponentProps<"div"> & {
/** Fragment shader source. Receives uTime, uResolution (device px), uScale (device px per CSS px), uPointer and uLoaded, plus your uniforms and tokens. */
fragment: string;
/** Extra uniforms. Read every frame, so a tween can mutate this object in place. */
uniforms?: Record<string, UniformValue>;
/** Theme tokens passed as vec3 uniforms, for example { uInk: "--foreground" }. Re-read on theme change. */
tokens?: Record<string, string>;
/** Images passed as sampler2D uniforms, for example { uImage: "/photo.jpg" }. uImageSize-style sizes follow: uImage gets uImageSize. */
textures?: Record<string, string>;
/** Upper limit on device pixels per CSS pixel. */
maxDpr?: number;
/** The moment, in seconds, drawn as the still frame under reduced motion. */
stillAt?: number;
/** "demand" draws only when `invalidateRef` is called, for pieces that rest. The default never stops while visible. */
frames?: "continuous" | "demand";
/** Receives a function that draws one frame now. Pair it with frames="demand". */
invalidateRef?: React.RefObject<(() => void) | null>;
/** "track" feeds a smoothed pointer position to uPointer on devices with a mouse. */
pointer?: "none" | "track";
};
const VERTEX = `attribute vec2 aPosition;
void main() { gl_Position = vec4(aPosition, 0.0, 1.0); }`;
/** Resolves a token through the cascade, so a scoped .light or .dark wrapper is respected. */
function readToken(host: HTMLElement, token: string): [number, number, number] {
const probe = document.createElement("span");
probe.style.color = `var(${token})`;
probe.style.display = "none";
host.appendChild(probe);
const color = getComputedStyle(probe).color;
probe.remove();
const paint = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
if (!paint) return [0, 0, 0];
paint.fillStyle = color;
paint.fillRect(0, 0, 1, 1);
const [r, g, b] = paint.getImageData(0, 0, 1, 1).data;
return [r / 255, g / 255, b / 255];
}
function compile(gl: WebGLRenderingContext, type: number, source: string) {
const shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return shader;
console.error(gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
/**
* A raw WebGL fragment shader on a full-bleed canvas. It pauses off screen, draws one still frame
* under reduced motion, follows the theme, and leaves its children showing when WebGL is missing.
* Children are the static fallback, so give it something that reads on its own.
*/
function ShaderCanvas({
fragment,
uniforms,
tokens,
textures,
maxDpr = 1.5,
stillAt = 2,
frames = "continuous",
invalidateRef,
pointer = "none",
className,
children,
...props
}: ShaderCanvasProps) {
const host = React.useRef<HTMLDivElement>(null);
const canvas = React.useRef<HTMLCanvasElement>(null);
const latest = React.useRef({ uniforms, tokens });
const textureKey = JSON.stringify(textures ?? {});
const redraw = React.useRef<() => void>(() => {});
const [ready, setReady] = React.useState(false);
// Bumped when a lost context comes back, which rebuilds everything from scratch.
const [generation, setGeneration] = React.useState(0);
React.useEffect(() => {
latest.current = { uniforms, tokens };
redraw.current();
});
React.useEffect(() => {
const root = host.current;
const el = canvas.current;
if (!root || !el) return;
// WebGL clears its buffer once a frame is presented. A canvas that draws rarely has to keep it,
// or the picture is gone the next time the browser re-composites the page.
const motion = window.matchMedia("(prefers-reduced-motion: reduce)");
const gl = el.getContext("webgl", {
preserveDrawingBuffer: motion.matches || frames === "demand",
alpha: true,
antialias: false,
depth: false,
stencil: false,
premultipliedAlpha: true,
powerPreference: "low-power",
});
if (!gl) return;
const vertex = compile(gl, gl.VERTEX_SHADER, VERTEX);
const frag = compile(gl, gl.FRAGMENT_SHADER, fragment);
const program = gl.createProgram();
if (!vertex || !frag || !program) return;
gl.attachShader(program, vertex);
gl.attachShader(program, frag);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error(gl.getProgramInfoLog(program));
return;
}
gl.useProgram(program);
// One oversized triangle covers the clip square with no diagonal seam.
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
const position = gl.getAttribLocation(program, "aPosition");
gl.enableVertexAttribArray(position);
gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
const locations = new Map<string, WebGLUniformLocation | null>();
const at = (name: string) => {
if (!locations.has(name)) locations.set(name, gl.getUniformLocation(program, name));
return locations.get(name) ?? null;
};
const set = (name: string, value: UniformValue) => {
const loc = at(name);
if (!loc) return;
if (typeof value === "number") gl.uniform1f(loc, value);
else if (value.length === 2) gl.uniform2f(loc, value[0], value[1]);
else if (value.length === 3) gl.uniform3f(loc, value[0], value[1], value[2]);
else if (value.length === 4) gl.uniform4f(loc, value[0], value[1], value[2], value[3]);
};
let colors: Record<string, [number, number, number]> = {};
const readTheme = () => {
colors = Object.fromEntries(
Object.entries(latest.current.tokens ?? {}).map(([name, token]) => [name, readToken(root, token)]),
);
};
readTheme();
// Textures load after the first frame. A sampler with nothing bound reads as black, so shaders that
// use one should fade in on uLoaded, which goes from 0 to 1 once every image has arrived.
const sources = Object.entries(JSON.parse(textureKey) as Record<string, string>);
const images: WebGLTexture[] = [];
let loaded = sources.length === 0 ? 1 : 0;
let disposed = false;
let pending = sources.length;
sources.forEach(([name, src], unit) => {
const texture = gl.createTexture();
if (!texture) return;
images.push(texture);
const image = new Image();
image.crossOrigin = "anonymous";
image.onload = () => {
if (disposed) return;
gl.activeTexture(gl.TEXTURE0 + unit);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
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);
const sampler = at(name);
if (sampler) gl.uniform1i(sampler, unit);
set(`${name}Size`, [image.naturalWidth, image.naturalHeight]);
pending -= 1;
if (pending === 0) loaded = 1;
draw();
};
image.src = src;
});
const target = { x: 0.5, y: 0.5 };
const eased = { x: 0.5, y: 0.5 };
let time = stillAt;
let visible = false;
let frame = 0;
let last = 0;
const draw = () => {
gl.viewport(0, 0, el.width, el.height);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
set("uTime", time);
set("uResolution", [el.width, el.height]);
set("uScale", el.width / Math.max(el.clientWidth, 1));
set("uPointer", [eased.x, eased.y]);
set("uLoaded", loaded);
for (const [name, value] of Object.entries(colors)) set(name, value);
for (const [name, value] of Object.entries(latest.current.uniforms ?? {})) set(name, value);
gl.drawArrays(gl.TRIANGLES, 0, 3);
};
redraw.current = () => {
if (motion.matches) draw();
};
const loop = (now: number) => {
frame = 0;
const dt = last ? Math.min((now - last) / 1000, 0.05) : 0;
last = now;
time += dt;
// Frame-rate independent smoothing: 1 - e^(-k dt).
const k = 1 - Math.exp(-6 * dt);
eased.x += (target.x - eased.x) * k;
eased.y += (target.y - eased.y) * k;
draw();
schedule();
};
const schedule = () => {
if (frames === "demand" || frame || !visible || motion.matches || document.hidden || gl.isContextLost()) return;
frame = requestAnimationFrame(loop);
};
const stop = () => {
if (frame) cancelAnimationFrame(frame);
frame = 0;
last = 0;
};
const resize = () => {
const scale = Math.min(window.devicePixelRatio || 1, maxDpr);
const width = Math.max(1, Math.round(el.clientWidth * scale));
const height = Math.max(1, Math.round(el.clientHeight * scale));
if (el.width !== width || el.height !== height) {
el.width = width;
el.height = height;
}
draw();
};
const resizer = new ResizeObserver(resize);
resizer.observe(el);
const viewport = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
if (!visible) return stop();
draw();
schedule();
});
viewport.observe(el);
// Smooth scrolling libraries flip classes on html while scrolling, so react only to the theme itself.
const themeKey = () => {
const html = document.documentElement;
return `${html.classList.contains("dark")} ${html.classList.contains("light")} ${html.style.colorScheme}`;
};
let lastTheme = themeKey();
const theme = new MutationObserver(() => {
if (themeKey() === lastTheme) return;
lastTheme = themeKey();
readTheme();
draw();
});
theme.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style"] });
const onVisibility = () => (document.hidden ? stop() : schedule());
const onMotion = () => {
stop();
if (motion.matches) {
time = stillAt;
draw();
} else schedule();
};
const onPointer = (e: PointerEvent) => {
const box = el.getBoundingClientRect();
target.x = (e.clientX - box.left) / box.width;
target.y = 1 - (e.clientY - box.top) / box.height;
};
const fine = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
const tracks = pointer === "track" && fine;
document.addEventListener("visibilitychange", onVisibility);
motion.addEventListener("change", onMotion);
if (tracks) window.addEventListener("pointermove", onPointer, { passive: true });
// preventDefault asks the browser to restore the context. Until then the static fallback shows.
const onLost = (e: Event) => {
e.preventDefault();
stop();
setReady(false);
};
const onRestored = () => setGeneration((n) => n + 1);
el.addEventListener("webglcontextlost", onLost);
el.addEventListener("webglcontextrestored", onRestored);
if (invalidateRef) invalidateRef.current = draw;
resize();
setReady(true);
return () => {
disposed = true;
if (invalidateRef) invalidateRef.current = null;
stop();
redraw.current = () => {};
resizer.disconnect();
viewport.disconnect();
theme.disconnect();
document.removeEventListener("visibilitychange", onVisibility);
motion.removeEventListener("change", onMotion);
if (tracks) window.removeEventListener("pointermove", onPointer);
el.removeEventListener("webglcontextlost", onLost);
el.removeEventListener("webglcontextrestored", onRestored);
images.forEach((texture) => gl.deleteTexture(texture));
gl.deleteBuffer(buffer);
gl.deleteProgram(program);
gl.deleteShader(vertex);
gl.deleteShader(frag);
};
}, [fragment, frames, generation, invalidateRef, maxDpr, pointer, stillAt, textureKey]);
return (
<div
ref={host}
data-slot="shader-canvas"
aria-hidden="true"
className={cn("absolute inset-0", className)}
{...props}
>
{ready ? null : children}
<canvas ref={canvas} className="absolute inset-0 size-full" />
</div>
);
}
export { ShaderCanvas };
For AI agents
Hand your agent this page, the markdown at /components/glass-lens/prompt.md, or the Copy AI prompt button above.
When to use
- A detailed image people want to look into, such as a texture or a plan
- A landing page piece where the pointer is the invitation
- Beside copy that names a detail worth finding
When not to use
- As a product zoom that must show exact pixels: the rim distorts on purpose
- Several times on one page, since each lens holds a WebGL context
- On small thumbnails, where the lens covers most of the image
Agent notes
- It defaults to a 3:2 box with container-type: size, so the box needs a definite height. Pass aspect-auto and a height to fill something else
- The lens is a role=slider control: arrow keys move it by 16px, Shift by 64px and Home sends it back to rest. Name it in another language with lensLabel
- Only a fine pointer moves it, so touch keeps native scrolling. Under reduced motion it rests and arrow keys jump it with no glide
- Without WebGL or JavaScript a CSS lens shows the same crop at the rest position. It installs components/ui/shader-canvas.tsx and uses no GSAP
The maths
- The dome is the superellipse h = (1 - r⁸)^(1/8). Its slope is under 0.1 at 70 percent of the radius and reaches 1.8 at 95, so the bend stays in a thin rim.
- Refraction is Snell's law with n = 1.5, a typical index for glass, so the shader passes eta = 1 / 1.5 = 0.667.
- The rest position is 0.618 and 0.382 of the box, the golden section on each axis.
- Glide is 1 - e^(-12 dt), a time constant of 83ms that holds at any refresh rate.