Liquid Glass on the Web: Refraction with SVG Displacement Maps

Refraction

Drag the lens

The trick in one sentence

Glass doesn't add anything to what's behind it. It just shows each pixel from a slightly different place. If you can say, for every pixel, where it should be borrowed from, you have refraction. SVG already has a primitive for exactly that: feDisplacementMap.

Bending one ray

Picture a slice through the edge of a pill-shaped piece of glass. The rim (the bezel) curves up from zero height to the flat top. A ray coming straight down hits that curve, bends according to Snell's law, and lands on the background a little further inward than it would have without glass.
I describe the bezel with a height function from 0 (outer edge) to 1 (flat top). A squircle, (1 - (1 - t)⁴)^¼, gives the softest transition, which is why the demo uses it. Its slope gives the surface normal, and the vector form of Snell's law gives the refracted ray:

optics.ts

// Incident ray points straight down; n is the outward surface normal.
const eta = 1 / ior // air → glass
const k = 1 - eta * eta * (1 - ny * ny)
const f = eta * ny - Math.sqrt(k)
const rx = f * nx
const ry = -eta + f * ny

// Follow the refracted ray down to the background.
const offset = rx * (height / -ry)
A convex bezel always pushes the ray inward. That's convenient, because it means the lens only ever needs pixels that are already underneath it.

One radius is enough

The offset only depends on how far a point is from the edge, not on where along the edge it sits. So I compute it once for 128 samples along a single radius, then reuse that profile for every rim pixel. The direction comes from the shape: for each pixel I find the nearest edge of the rounded rectangle and point the offset along its inward normal.

Encoding the map

A displacement map is a plain RGBA image. Red holds the x shift, green holds the y shift, and 128means “stay put”. I normalize every offset by the largest one so it fits in the 8-bit channel:

optics.ts

// 128 is "no shift". Red is x, green is y.
const m = offsets[sample] / max // normalized 0..1
r = Math.round(128 - nx * m * 127)
g = Math.round(128 - ny * m * 127)
Two details cost me some time:
  • The filter computes P(x + scale · (C − 0.5)). A channel can only move ±0.5 away from neutral, so to shift by the real maximum offset, scale has to be twice that value.
  • Set color-interpolation-filters="sRGB". The default is linearRGB, which quietly remaps your carefully encoded channel values and skews the whole field.

The filter

With the map as a data URL, the filter is short. A second image adds the specular highlight: a white rim whose opacity depends on how directly each edge faces a fixed light from the top left.

liquid-glass-demo.tsx

<filter id="glass" colorInterpolationFilters="sRGB">
  <feImage href={displacementUrl} result="map" />
  <feDisplacementMap
    in="SourceGraphic"
    in2="map"
    scale={maxOffset * 2}
    xChannelSelector="R"
    yChannelSelector="G"
    result="refracted"
  />
  <feImage href={specularUrl} result="specular" />
  <feMerge>
    <feMergeNode in="refracted" />
    <feMergeNode in="specular" />
  </feMerge>
</filter>

Browser support

The natural way to use this is backdrop-filter: url(#glass) on a UI element, and that is where support ends: only Chromium accepts SVG filters there. For the demo above I went the other way. The lens contains its own copy of the scene, shifted to line up with the page behind it, and a regular filterrefracts that copy. It's the classic magnifier trick, and because it avoids the backdrop, it should reach more browsers, though Safari's handling of feImage is worth testing.
It's still an experiment. Changing the shape or size means regenerating the map, so this suits fixed components better than elements that animate their size. The technique comes from Kube's original article ↗, which goes much further, with a switch, slider and music player built the same way.