Copied!
Full page

Refractive glass lens

A glass lens follows the pointer and bends the page behind it, splitting red, green and blue apart like a prism, and squashes as it moves.

← Library

Element · Vanilla JS + SVG filter + CSSUpdated November 6, 2026

HeroElementVanilla JS + SVG filter + CSS

Live preview needs JavaScript. The code below works without any dependencies.

Prompt for your AI assistant

Paste this into Claude, Cursor or ChatGPT. The settings update as you move the controls above.

Build "Refractive glass lens" for my site using the Enrichr Library element at https://enrichr.dev/elements/refractive-glass-lens.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/refractive-glass-lens.json (markup, CSS, JavaScript, parameters).
Requirements: vanilla JavaScript and CSS, no new dependencies; keep the behaviour of the source unchanged; respect prefers-reduced-motion; work on touch screens; keep it accessible.

Settings:
- title: Optical glass with *chromatic aberration*
- mode: refract
- warp: 35
- fringe: 6
- noise: 0.018
- size: 192
- shape: circle
- smoothing: 0.16

Verify: reduced motion shows no movement, it works at 375px wide, there are no console errors, and scrolling stays smooth.

The same spec as JSON, for tools that read it directly: /elements/refractive-glass-lens.json.

Code

Mount it on an element that contains the markup. It returns an object with update(options) and destroy().

HTML

<div class="ef-rgl"><div class="ef-rgl__zone"><div class="ef-rgl__scene"><span class="ef-rgl__kicker">Optical filter</span><h2 class="ef-rgl__t"></h2><p class="ef-rgl__p"></p><ul class="ef-rgl__chips" aria-hidden="true"><li>Red</li><li>Green</li><li>Blue</li><li>Glass</li></ul></div><div class="ef-rgl__lens" aria-hidden="true"></div></div></div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-rgl__zone { position: relative; isolation: isolate; overflow: hidden; min-height: 22rem; display: flex; align-items: center; border-radius: 1rem; touch-action: pan-y; container-type: inline-size; background-color: var(--ef-bg, #0b1120); background-image: linear-gradient(rgba(148, 163, 184, 0.09) 1px, transparent 1px), linear-gradient(90deg, rgba(148, 163, 184, 0.09) 1px, transparent 1px); background-size: 40px 40px; color: #e2e8f0; }
.ef-rgl__scene { position: relative; z-index: 1; padding: 2.4rem clamp(1.2rem, 5vw, 3rem); max-width: 46rem; }
.ef-rgl__kicker { display: inline-block; padding: 0.3rem 0.7rem; border: 1px solid rgba(34, 211, 238, 0.4); border-radius: 999px; color: var(--ef-accent, #22d3ee); font-size: 0.75rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; }
.ef-rgl .ef-rgl__t { margin: 1rem 0 0.8rem; font-size: clamp(1.8rem, 6vw, 3.6rem); font-size: clamp(1.8rem, 7.5cqi, 3.6rem); font-weight: 800; line-height: 1.05; letter-spacing: -0.02em; }
.ef-rgl__t em { font-style: normal; background: linear-gradient(90deg, var(--ef-accent, #22d3ee), #a78bfa 55%, #f472b6); -webkit-background-clip: text; background-clip: text; color: transparent; }
.ef-rgl .ef-rgl__p { margin: 0; opacity: 1; max-width: 34rem; color: #94a3b8; font-size: 1.05rem; line-height: 1.55; }
.ef-rgl .ef-rgl__chips { display: flex; flex-wrap: wrap; gap: 0.6rem; margin: 1.4rem 0 0; padding: 0; list-style: none; }
.ef-rgl .ef-rgl__chips li { margin: 0; opacity: 1; padding: 0.55rem 1rem; border-radius: 0.6rem; font-size: 0.85rem; font-weight: 700; color: #0b1120; background: #f87171; }
.ef-rgl .ef-rgl__chips li:nth-child(2) { background: #4ade80; }
.ef-rgl .ef-rgl__chips li:nth-child(3) { background: #60a5fa; }
.ef-rgl .ef-rgl__chips li:nth-child(4) { background: #f8fafc; }
.ef-rgl__lens { position: absolute; left: 0; top: 0; z-index: 3; width: var(--ef-w, 192px); height: var(--ef-h, 192px); border-radius: var(--ef-radius, 50%); pointer-events: none; will-change: transform; background: rgba(255, 255, 255, 0.03); -webkit-backdrop-filter: blur(3px) saturate(1.6); backdrop-filter: blur(3px) saturate(1.6); box-shadow: 0 20px 50px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(255, 255, 255, 0.35), inset 0 0 20px rgba(255, 255, 255, 0.25), inset 0 2px 4px rgba(255, 255, 255, 0.6), inset 0 -4px 8px rgba(0, 0, 0, 0.4); transition: width 0.2s cubic-bezier(0.16, 1, 0.3, 1), height 0.2s cubic-bezier(0.16, 1, 0.3, 1), border-radius 0.2s ease; }
@media (prefers-reduced-motion: reduce) { .ef-rgl__lens { transition: none; } }

JavaScript

// Enrichr Library · https://enrichr.dev
// Refractive glass lens: a glass disc follows the pointer and bends whatever is behind it. An SVG filter used as a
// backdrop-filter warps the backdrop with turbulence noise, then splits it into red, green and blue copies shifted
// sideways (chromatic aberration) and screens them back together. The lens squashes along its direction of travel.
// Chromium browsers get the full refraction; others get a frosted-glass fallback. "Invert" mode swaps in an inverting lens.
// mount(root, { title (wrap a phrase in *stars* to colour it), text, mode ("refract" | "invert"), warp, fringe (px), noise, size (px), shape, smoothing, accent, background })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var zone = root.querySelector(".ef-rgl__zone"), lens = root.querySelector(".ef-rgl__lens");
  var title = root.querySelector(".ef-rgl__t"), text = root.querySelector(".ef-rgl__p");
  var svgOk = !!window.chrome && /Chrome\//.test(navigator.userAgent);
  var NS = "http://www.w3.org/2000/svg", id = "ef-rgl-" + Math.random().toString(36).slice(2, 8);
  function el(tag, attrs, parent) {
    var n = document.createElementNS(NS, tag);
    for (var k in attrs) n.setAttribute(k, attrs[k]);
    if (parent) parent.appendChild(n);
    return n;
  }
  var svg = el("svg", { "aria-hidden": "true", width: "0", height: "0" });
  svg.style.cssText = "position:absolute;width:0;height:0;pointer-events:none";
  var f = el("filter", { id: id, x: "-30%", y: "-30%", width: "160%", height: "160%" }, svg);
  var turb = el("feTurbulence", { type: "fractalNoise", baseFrequency: "0.018", numOctaves: "3", seed: "5", result: "noise" }, f);
  var disp = el("feDisplacementMap", { "in": "SourceGraphic", in2: "noise", scale: "35", xChannelSelector: "R", yChannelSelector: "G", result: "bent" }, f);
  var red = el("feOffset", { "in": "bent", dx: "-6", dy: "0", result: "rOff" }, f);
  el("feColorMatrix", { "in": "rOff", type: "matrix", values: "1 0 0 0 0  0 0 0 0 0  0 0 0 0 0  0 0 0 1 0", result: "r" }, f);
  var blue = el("feOffset", { "in": "bent", dx: "6", dy: "0", result: "bOff" }, f);
  el("feColorMatrix", { "in": "bOff", type: "matrix", values: "0 0 0 0 0  0 0 0 0 0  0 0 1 0 0  0 0 0 1 0", result: "b" }, f);
  el("feColorMatrix", { "in": "bent", type: "matrix", values: "0 0 0 0 0  0 1 0 0 0  0 0 0 0 0  0 0 0 1 0", result: "g" }, f);
  el("feBlend", { "in": "r", in2: "g", mode: "screen", result: "rg" }, f);
  el("feBlend", { "in": "rg", in2: "b", mode: "screen" }, f);
  zone.appendChild(svg);

  var opts = {}, raf = 0, visible = true, io = null, ro = null, placed = false;
  var tx = 0, ty = 0, x = 0, y = 0, sx = 1, sy = 1, ang = 0;

  // Before the pointer arrives, park the lens over the headline so the effect is visible straight away.
  function rest() {
    if (placed) return;
    var z = zone.getBoundingClientRect(), t = title.getBoundingClientRect();
    tx = x = Math.min(z.width * 0.62, t.left - z.left + t.width * 0.55);
    ty = y = t.top - z.top + t.height / 2;
  }
  function paint() {
    lens.style.transform = "translate3d(" + x.toFixed(1) + "px," + y.toFixed(1) + "px,0) translate(-50%,-50%) rotate(" + ang.toFixed(1) + "deg) scale(" + sx.toFixed(3) + "," + sy.toFixed(3) + ")";
  }
  function frame() {
    raf = 0;
    if (!visible) return;
    var k = reduce ? 1 : opts.smoothing;
    var px = x, py = y;
    x += (tx - x) * k;
    y += (ty - y) * k;
    var vx = x - px, vy = y - py, speed = Math.sqrt(vx * vx + vy * vy);
    if (!reduce && speed > 0.5) {
      var s = Math.min(speed * 0.015, 0.35);
      sx = 1 + s; sy = 1 - s;
      ang = Math.atan2(vy, vx) * 180 / Math.PI;
    } else {
      sx += (1 - sx) * 0.3; sy += (1 - sy) * 0.3;
    }
    paint();
    if (Math.abs(tx - x) + Math.abs(ty - y) > 0.3 || Math.abs(1 - sx) > 0.002) raf = requestAnimationFrame(frame);
  }
  function wake() { if (!raf && visible) raf = requestAnimationFrame(frame); }
  function move(e) {
    var r = zone.getBoundingClientRect();
    tx = e.clientX - r.left;
    ty = e.clientY - r.top;
    placed = true;
    wake();
  }
  function heading(s) {
    title.textContent = "";
    String(s).split("*").forEach(function (part, i) {
      if (!part) return;
      if (i % 2) { var em = document.createElement("em"); em.textContent = part; title.appendChild(em); }
      else title.appendChild(document.createTextNode(part));
    });
  }
  function update(next) {
    opts = Object.assign({ title: "Optical glass with *chromatic aberration*", text: "Move the pointer across the text and chips: the lens bends the light and pulls red, green and blue apart.", mode: "refract", warp: 35, fringe: 6, noise: 0.018, size: 192, shape: "circle", smoothing: 0.16, accent: "#22d3ee", background: "#0b1120" }, opts, next || {});
    opts.smoothing = Math.max(0.02, Math.min(1, Number(opts.smoothing)));
    heading(opts.title);
    text.textContent = opts.text;
    zone.style.setProperty("--ef-bg", opts.background);
    zone.style.setProperty("--ef-accent", opts.accent);
    disp.setAttribute("scale", String(Number(opts.warp)));
    turb.setAttribute("baseFrequency", String(Number(opts.noise)));
    red.setAttribute("dx", String(-Number(opts.fringe)));
    blue.setAttribute("dx", String(Number(opts.fringe)));
    var size = Number(opts.size), wide = opts.shape === "pill";
    lens.style.setProperty("--ef-w", (wide ? Math.round(size * 1.6) : size) + "px");
    lens.style.setProperty("--ef-h", size + "px");
    lens.style.setProperty("--ef-radius", { circle: "50%", square: "24px", pill: "9999px", leaf: "40% 10% 40% 10%" }[opts.shape] || "50%");
    var bf = opts.mode === "invert" ? "invert(1) hue-rotate(180deg)" : svgOk ? "url(#" + id + ")" : "";
    lens.style.backdropFilter = bf;
    lens.style.webkitBackdropFilter = bf;
    rest();
    paint();
  }
  update(o);
  zone.addEventListener("pointermove", move);
  zone.addEventListener("pointerdown", move);
  if ("ResizeObserver" in window) { ro = new ResizeObserver(function () { rest(); paint(); }); ro.observe(zone); }
  if ("IntersectionObserver" in window) { io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; if (visible) wake(); }); io.observe(zone); }
  return {
    update: update,
    destroy: function () {
      cancelAnimationFrame(raf); raf = 0;
      if (io) io.disconnect();
      if (ro) ro.disconnect();
      zone.removeEventListener("pointermove", move);
      zone.removeEventListener("pointerdown", move);
      if (svg.parentNode) svg.parentNode.removeChild(svg);
    }
  };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"title":"Optical glass with *chromatic aberration*","mode":"refract","warp":35,"fringe":6,"noise":0.018,"size":192,"shape":"circle","smoothing":0.16});

How it works

The lens is an empty element with backdrop-filter pointing at an inline SVG filter, so the filter runs on whatever is painted behind the lens. Inside the filter, feTurbulence makes a fractal noise map and feDisplacementMap uses it to shift the backdrop's pixels (the warp setting). The bent image is then split three ways: feColorMatrix keeps only red from a copy moved left by the fringe distance, only blue from a copy moved right, and only green from the centre. Two feBlend steps in screen mode add the channels back together, so edges show red and blue fringes like cheap glass. Each frame the lens eases toward the pointer; its speed and direction stretch it along the path of travel and rotate it to match, then it relaxes back to round. Box shadows draw the rim, glare and bevel.

Accessibility and performance

The lens is decorative, hidden from assistive technology and ignores pointer events, so text under it can still be selected and clicked. The content is real text. With reduced motion the lens jumps to the pointer without lag or squash. Refraction makes text under the lens hard to read on purpose, so don't rely on the lens to reveal anything important.

SVG filters in backdrop-filter repaint the area under the lens whenever it moves, which is the expensive part. The movement loop runs only while the lens is still travelling or squashed and pauses off screen. Keep the lens under about 260px and the turbulence octaves at 3 on low-end devices. Full refraction needs a Chromium browser (Chrome, Edge, Brave, Opera); Safari and Firefox do not render SVG filters as backdrop filters, so they get a frosted blur lens instead, and the invert mode works everywhere.

SectionFull page
TechniquesPointer interaction
StackVanilla JS, SVG filter, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
Inspired byRefract Lab by Homeless Romantic Creative on CodePen. Original code, written from scratch.

Find this technique on a real site

Want to see this technique in the wild? Check any site with the free tools, then build your own version from this page.

Check which animation library a site uses · Extract a site's design tokens · Browse site breakdowns

Licence and provenance

Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.

This is original code. The technique is a common one that we studied by measuring award-winning sites with Enrichr; no third-party code, shaders, fonts or images are used.

Rule sweep listA line sweeps across a menu row on hover or focus and exits through the opposite side. Depth parallax cardLayered shapes shift by different amounts as the pointer moves, drifting on touch screens. 3D ring carouselCards on a CSS 3D ring that rotates one step at a time, with buttons, arrow keys and autoplay.

FAQ

What presets match the original lab?

Liquid prism: warp 35, fringe 8, frequency 0.018. Heavy refraction: warp 85, fringe 18, frequency 0.035. Subtle glass: warp 12, fringe 3, frequency 0.005. Invert: choose the Invert lens.

Why does Safari show a blur instead of the prism?

Safari and Firefox accept backdrop-filter with blur, saturate and similar functions but not with a url() to an SVG filter, so the script only switches the SVG filter on in Chromium and keeps the blur as the fallback.

Can the lens cover a whole page?

Yes. Make the zone the full page (or use position: fixed on the lens and read the pointer from the window). Keep the filter region the same; it is sized relative to the lens, not the page.