Render paths
By default, glass is an SVG filter over live DOM: an feDisplacementMap displaces pixels the browser has already rendered, so the content stays selectable and clickable. It works in every browser, Safari and Firefox included, with no fallback.
WebGL handles content with no live DOM to filter. Frost is a CSS blur, used only when WebGL is unavailable.
Press and hold to send emojis up through all three at once: the same stream, treated three ways.
Displaced by one SVG filter. The link still works.
How it works
The refraction is a single feDisplacementMap, applied to the live element with filter: url(). It shifts pixels the browser has already rendered, which is why the content stays selectable and clickable. Chrome, Safari, and Firefox all produce the same output, so there is no fallback to maintain. (Most implementations use backdrop-filter: url() instead, which only Chromium supports.)
<filter id="glass" primitiveUnits="userSpaceOnUse">
<feImage href="lens.png" result="map" />
<feDisplacementMap in="SourceGraphic" in2="map"
scale="24" xChannelSelector="R" yChannelSelector="G" />
</filter>
.card { filter: url(#glass); } /* bends live DOM */ Performance and Safari notes.
- A new filter id on every change. Safari caches filter output by id, and a reused id freezes a moving lens; a fresh id on each rebuild keeps it live.
- Moving reuses the map. Dragging just repositions the
<feImage>; the map regenerates only when the lens changes shape. - The map is four-way symmetric. One quadrant is computed and mirrored into the other three, about a quarter of the work.
- The specular highlight is lens-sized, not computed across the whole filter region.
WebGL is used only when there is no live DOM to filter. That applies to a canvas (the QR below) and to a <video>, which Safari won't filter. Frost is a backdrop-filter: blur(), used only when WebGL is unavailable.
Animating through the glass without rebuilding the map. Generating the map is the costly step: a per-pixel signed-distance field and a dome integral, computed once up front. Rebuilding it every frame would stutter, so the optics stay fixed and only the source the filter reads gets animated. The browser re-runs the same displacement over fresh pixels each frame and never touches the map. The render-path cards above work exactly this way: the emojis are particles on a <canvas>, and only the canvas redraws per frame while each path (SVG, WebGL, Frost) re-processes it untouched. It's how Aave's reaction button bends a stream of emojis at 60fps.
lens.style.filter = 'url(#glass)'; // static map: built once, never rebuilt
function frame() {
drawParticles(ctx); // cheap 2D redraw of the source
requestAnimationFrame(frame); // the glass re-refracts it, free
}