Skip to content
goodtabs UI
BrowseBrowse components

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

PropTypeDefaultDescription
srcstringThe image. Same origin, or served with CORS headers.
altstringAlt text for the real image, which stays in the DOM.
radiusnumber96Lens radius in CSS pixels. Capped at 38 percent of the short side of the box.
zoomnumber1.6Magnification through the middle.
restreadonly [number, number][0.618, 0.382]Where the lens rests, as fractions of the width and height from the top left.
lensLabelstring"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.