How it works
A lens from a distance field
Real glass bends light where its surface is curved. This lens computes that bend for every pixel once, stores it in an image, and lets an SVG filter apply it to whatever is behind the element.
The idea
Picture a slab of glass with a flat top and rounded edges, like a thick phone screen protector. Looking straight down through the flat part, nothing moves. Near the edge the surface tilts, so the light rays from the backdrop are bent on their way to your eye and the backdrop seems to be pulled in from further inside. The bezel is that curved band along the edge.
The effect is built in four steps:
- Distance field: for each pixel, how far it is from the edge and which way the edge faces.
- Profile and normal: turn the distance into a height on the bezel and its slope.
- Refraction: turn the slope into a displacement with Snell's law and store it in the red and green channels of an image.
- Filter:
feDisplacementMapmoves the backdrop's pixels by that image, insidebackdrop-filter.
1. Signed distance field of a rounded rectangle
A signed distance field (SDF) gives, for any point, the distance to the nearest
edge of a shape: negative inside, zero on the edge, positive outside. For a rounded
rectangle it is short. Move the point into the first quadrant around the center (|p|), subtract the half-size shrunk by the corner radius, and you get q, the
distance from a smaller, sharp-cornered rectangle whose corners are the centers of the
corner arcs:
q = |p| − (halfSize − r)
d = length(max(q, 0)) + min(max(qx, qy), 0) − r
If both components of q are positive, the point is diagonal to a corner and the
distance is to the arc. Otherwise the nearer straight side wins. The gradient of d, the direction in which it grows fastest, is the outward
normal of the nearest edge: (±1, 0) or (0, ±1) along the sides, q / |q| around the corners (with the signs of p restored). It tells
each pixel which way "out" is, so the displacement can point the other way.
2. Height profile and slope
Inside the bezel, u = −d / bezel runs from 0 at the outer edge to 1 where the
flat top starts. The Curvature setting picks the shape of the cross-section, height(u), with v = 1 − u:
- circle
√(1 − v²), a quarter circle. Vertical at the very edge, so the strongest bend is crammed into the outermost pixels.- squircle
(1 − v⁴)^¼. Also vertical at the edge, but it stays steep for longer before it flattens out, so the bend spreads further inwards. This is closest to the soft-edged look of Apple's Liquid Glass.- linear
u, a 45° chamfer. The slope is the same everywhere, so the whole bezel is shifted by the same amount, with a visible seam where it meets the flat top.
The derivative height′(u) is the slope of the surface. Together with the SDF
gradient it gives the surface normal: tilted towards the outside by θ = atan(slope).
3. From normal to displacement
A ray from the backdrop travels straight up and enters the tilted surface at the angle θ to its normal. Snell's law says it continues at θt = asin(sin θ / n), with n = 1.5 for glass. It is bent
by θ − θt, so after passing through the glass it has drifted sideways by about tan(θ − θt) times the thickness. That drift is the displacement. It is zero on the
flat top, grows with the slope, and stays finite even for a vertical wall (θ = 90° gives a bend
of about 48°).
The map stores it per pixel, normalized so the steepest point of the profile reaches the extremes:
// For every pixel (x, y) of a W × H element:
const { distance, nx, ny } = roundedRectSdf(x + 0.5, y + 0.5, W, H, radius);
const inset = -distance; // px from the edge, inwards
if (inset > 0 && inset < bezel) {
const { height, slope } = profileAt(profile, inset / bezel);
const bend = refractionOffset(slope) / refractionOffset(profileAt(profile, 0).slope);
R = 128 - nx * bend * 127; // x offset, pointing inwards
G = 128 - ny * bend * 127; // y offset
B = height * 255; // for the highlight
} else {
R = G = 128; // flat: no offset
B = inset >= bezel ? 255 : 0;
} A value of 128 means "no offset". The direction is minus the normal, so each pixel
near the edge shows the backdrop from a little further inside: the edge magnifies, like the
rim of a convex lens. A nice side effect: samples are never fetched from outside the
element, so there is no transparent seam at the border. The blue channel keeps the height
for the highlight. The bytes go through a <canvas> (putImageData, then toDataURL) to become a PNG data URL.
4. The filter
- feImage
- Loads the map. It is placed in user-space px (
x,y,width,height) withpreserveAspectRatio="none", which is why the map has to be regenerated when the element changes size. The component watches its own border box with aResizeObserverand rebuilds the map (a few milliseconds for a typical lens). Changing refraction, fringe or light does not need a new map; those are attributes. - feDisplacementMap
- Moves each pixel of the backdrop by
scale × (R − ½, G − ½). Since the map's channels reach about ±½, the scale is twice the wanted maximum offset in px:scale="80"for 40 px. - chromatic fringe
- Real glass bends short wavelengths more than long ones (dispersion). The filter runs three
displacements with slightly different scales, keeps one color channel of each with
feColorMatrix, and adds them back together withfeComposite operator="arithmetic" k2="1" k3="1"(result = in + in2). Each copy is made opaque first (its alpha row is0 0 0 0 1), andoperator="in"against the displaced backdrop (before the channel split) puts the backdrop's alpha back afterwards. Otherwise the three alphas would add up and turn translucent pixels opaque and dark. Because the flat center has zero displacement whatever the scale, the fringe only appears on the bezel. Physically blue should bend most; here red does, which looks the same at this size and keeps the warm fringe on the outside. - feSpecularLighting
- Lighting primitives treat the alpha channel of their input as a height map. A
feColorMatrixcopies the map's blue channel into alpha, a smallfeGaussianBlursmooths its 8-bit steps (the lighting estimates slopes from neighboring pixels and would exaggerate them), andsurfaceScale(set to the bezel width) turns heights from 0–1 back into px, so the lit slopes match the bezel. AfeDistantLightshines from the Light angle at 30° above the surface. The flat top faces away from it enough to stay dark withspecularExponent="24"; only the part of the bezel tilted towards the light catches it. The result is added on top with another arithmetic composite, weighted by Highlight.
<svg width="0" height="0" aria-hidden="true">
<!-- width/height: the element's size in px; the map is generated at that size -->
<filter id="lens" x="0" y="0" width="W" height="H"
filterUnits="userSpaceOnUse" primitiveUnits="userSpaceOnUse"
color-interpolation-filters="sRGB">
<feImage href="data:image/png;base64,…" x="0" y="0" width="W" height="H"
preserveAspectRatio="none" result="map" />
<feDisplacementMap in="SourceGraphic" in2="map" scale="92"
xChannelSelector="R" yChannelSelector="G" result="shift-r" />
<feColorMatrix type="matrix" values="1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1" result="r" />
<feDisplacementMap in="SourceGraphic" in2="map" scale="80"
xChannelSelector="R" yChannelSelector="G" result="shift-g" />
<feColorMatrix type="matrix" values="0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 1" result="g" />
<feDisplacementMap in="SourceGraphic" in2="map" scale="68"
xChannelSelector="R" yChannelSelector="G" result="shift-b" />
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 1" result="b" />
<feComposite in="r" in2="g" operator="arithmetic" k2="1" k3="1" />
<feComposite in2="b" operator="arithmetic" k2="1" k3="1" />
<!-- the copies are opaque; take the alpha back from the backdrop -->
<feComposite in2="shift-g" operator="in" result="refracted" />
<feColorMatrix in="map" type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0" />
<feGaussianBlur stdDeviation="1.5" />
<feSpecularLighting surfaceScale="36" specularExponent="24"
lighting-color="#fff" result="light">
<feDistantLight azimuth="225" elevation="30" />
</feSpecularLighting>
<feComposite in="light" in2="refracted" operator="arithmetic"
k2="0.6" k3="1" />
</filter>
</svg> The filter goes last in the backdrop-filter chain, after a light blur() and saturate(). Blurring the backdrop before it is
displaced helps hide the map's 8-bit precision: neighboring pixels on the bezel are shifted
in small jumps, and on a soft image those jumps don't show as seams the way they do on sharp
lines. With zero blur you can sometimes see faint banding on the bezel.
.lens {
border-radius: 48px;
backdrop-filter: blur(0.5px) saturate(1.4) url(#lens);
} Browser support
The refraction needs backdrop-filter: url(#…), which only Chromium-based
browsers render. It was checked in Chromium for this page. Firefox drops the whole backdrop
filter when it contains url() (see the frosted glass page); Safari is untested. Feature detection can't tell, because CSS.supports() only
checks the syntax, so the component checks for Chromium after mount and otherwise falls back to
blur, tint and a rim drawn with inset shadows.
Cost: the filter runs on every frame in which anything behind the lens changes, such as the animated backdrop here. The fringe triples the displacement work. The map itself is cheap to build, but it is rebuilt on every resize, so avoid animating the lens's size.
Limitations: the map has 8 bits per channel, so offsets come in steps of refraction / 127 px. The bezel is modeled as a single surface (light enters the curved
top and leaves through a flat bottom), and the bend is a small-angle approximation of where the
ray ends up, not a ray tracer. It is a convincing look, not an optics simulation.