Liquid Glass
The one thing that matters
Blur is not refraction. A blur softens the backdrop in place. Glass moves it —
light bends at the rim and passes straight through the middle. If you ship
backdrop-filter: blur() and call it liquid glass, it reads as flat frosted plastic
and the user will tell you so.
The optical model to reproduce (from real glass renderers, e.g. WebGL/SDF studios):
| property | what it does | how to get it in CSS/SVG |
|---|---|---|
| Refraction | backdrop displaced at the rim, flat in the centre | feDisplacementMap + a normal map |
| Dispersion | R/G/B bend by different amounts → colour fringing | 3 displacement passes, recombine one channel each |
| Fresnel / specular | edges catch light | gradient on the top few % of the element |
| Blur | softening | feGaussianBlur, moderate — shapes must survive |
Only the first is non-negotiable. Without displacement it isn't glass.
Working implementation
The normal map encodes surface slope: R = horizontal, G = vertical, mid-grey (128) =
flat. Neutral through the middle, ramping to extremes only in a bevel band — that is
what makes the centre optically flat and the rim bend, like a lens. Use
preserveAspectRatio="none" so per-axis gradients stretch correctly to any element size.
<svg aria-hidden="true" style="position:absolute;width:0;height:0">
<filter id="liquidGlass" x="0%" y="0%" width="100%" height="100%"
color-interpolation-filters="sRGB" filterUnits="objectBoundingBox">
<feImage result="normalMap" preserveAspectRatio="none"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100' preserveAspectRatio='none'%3E%3Cdefs%3E%3ClinearGradient id='h' x1='0' y1='0' x2='1' y2='0'%3E%3Cstop offset='0' stop-color='rgb(255,0,0)'/%3E%3Cstop offset='.035' stop-color='rgb(205,0,0)'/%3E%3Cstop offset='.10' stop-color='rgb(128,0,0)'/%3E%3Cstop offset='.90' stop-color='rgb(128,0,0)'/%3E%3Cstop offset='.965' stop-color='rgb(51,0,0)'/%3E%3Cstop offset='1' stop-color='rgb(0,0,0)'/%3E%3C/linearGradient%3E%3ClinearGradient id='v' x1='0' y1='0' x2='0' y2='1'%3E%3Cstop offset='0' stop-color='rgb(0,255,0)'/%3E%3Cstop offset='.035' stop-color='rgb(0,205,0)'/%3E%3Cstop offset='.10' stop-color='rgb(0,128,0)'/%3E%3Cstop offset='.90' stop-color='rgb(0,128,0)'/%3E%3Cstop offset='.965' stop-color='rgb(0,51,0)'/%3E%3Cstop offset='1' stop-color='rgb(0,0,0)'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='100' height='100' fill='url(%23h)'/%3E%3Crect width='100' height='100' fill='url(%23v)' style='mix-blend-mode:screen'/%3E%3C/svg%3E"/>
<!-- dispersion: same map, three scales, one channel kept from each -->
<feDisplacementMap in="SourceGraphic" in2="normalMap" scale="42" xChannelSelector="R" yChannelSelector="G" result="rR"/>
<feDisplacementMap in="SourceGraphic" in2="normalMap" scale="48" xChannelSelector="R" yChannelSelector="G" result="rG"/>
<feDisplacementMap in="SourceGraphic" in2="normalMap" scale="54" xChannelSelector="R" yChannelSelector="G" result="rB"/>
<feColorMatrix in="rR" values="1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0" result="cR"/>
<feColorMatrix in="rG" values="0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 1 0" result="cG"/>
<feColorMatrix in="rB" values="0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 1 0" result="cB"/>
<feBlend in="cR" in2="cG" mode="screen" result="rg"/>
<feBlend in="rg" in2="cB" mode="screen" result="dispersed"/>
<feGaussianBlur in="dispersed" stdDeviation="9" result="soft"/>
<feColorMatrix in="soft" type="saturate" values="1.9"/>
</filter>
</svg>
color-interpolation-filters="sRGB" is required — the default linearRGB skews the
displacement values and the bevel comes out wrong.
The mid-grey convention means the map pulls content inward from the interior at every edge, which is correct lens behaviour (magnification at the rim) and never samples outside the filter region.
Performance: the trap that will bite you
backdrop-filter re-computes every frame the element moves through the viewport.
Measured on a real page: 20% of frames dropped, 100ms spikes. And it is not the
filter's complexity — cutting 3 displacement maps to 1 and blur from 9 to 4 only moved
20% → 13%. Plain blur() alone was still 10%. Tuning cannot fix this.
Ask: does the backdrop actually move relative to the element? If the glass and what
it refracts scroll together (a card over a heading, a panel over its own section), the
refraction never changes — so don't recompute it. Put a static copy of the backdrop
content inside the element and apply the filter as a normal filter:
.card {
position: relative;
overflow: hidden;
isolation: isolate;
background: var(--page-bg); /* opaque: hides the real backdrop behind */
}
.card-glass { /* holds a copy of what's behind */
position: absolute; inset: 0; z-index: 0;
overflow: hidden; pointer-events: none;
filter: url(#liquidGlass); /* filter on THIS box, so the normal map
spans the card and the bevel lands on
the card's edges — not on the text */
}
.card::after { /* tint + specular ABOVE the refraction */
content: ""; position: absolute; inset: 0; z-index: 1;
border-radius: inherit; pointer-events: none;
background: linear-gradient(180deg, …);
}
.card > *:not(.card-glass) { position: relative; z-index: 2; }
Align the copy with JS on load, on document.fonts.ready, and on resize (debounced).
Result: rasterised once, then just composited — filter cost drops to ~1–2%.
Other performance facts, all measured:
will-change: transformon the glass layer made it dramatically worse (18% → 41%). Do not reach for layer promotion here.- A fixed, full-viewport
mix-blend-modeoverlay (film grain, vignette) is often a bigger cost than the glass — it forces the compositor to re-blend the whole screen every frame. One page measured 34% dropped frames; removing just the blend mode (keeping the overlay at low opacity) took it to 3%. Check for this before blaming the glass. backdrop-filteron a sticky element is always on screen, so it pays the cost on every frame of every scroll. Keep those cheap.
Text on glass: measure, don't eyeball
Transparent glass over bright content destroys small-text contrast. Real numbers from a build: labels at 1.77 / 2.69 / 2.41 : 1 against a 4.5 requirement.
Making the text whiter makes it worse. White on a 188-luminance backdrop is 1.92:1. The material has to be darker. Ramp the tint to full strength before the first line of text — as a fraction of element height, not a fixed offset:
background: linear-gradient(180deg,
rgba(255,255,255,.14) 0%, /* specular sliver — this is the "edge" */
rgba(255,255,255,.03) 2%,
rgba(11,11,14,.63) 6%, /* tint fully in before the label band */
rgba(10,10,12,.62) 58%,
rgba(8,8,10,.70) 100%);
Verify by rendering with the text hidden and sampling the real backdrop:
page.add_style_tag(content=".label,.price{visibility:hidden}")
crop = screenshot_region_of(element)
bg = np.percentile(crop.reshape(-1,3), 90, axis=0) # 90th pct = worst case
ratio = wcag_contrast(text_rgb, bg) # need 4.5 small / 3.0 large
Sample several frames if the backdrop is a video or animation — take the worst.
Style rules
- No borders. Real glass has a light-catching specular edge, not a 1px stroke.
A
border: 1px solidreads as a UI panel. Use the gradient's top 2%. - Dark UI = dark-tinted glass. Do not let it blow out to white.
- Push saturation hard (180–200%) — glass concentrates light. Blur moderately; heavy blur erases the shapes that prove it is glass.
- Keep gutters between adjacent glass panels tight (~10px). A wide gap shows an unblurred sliver of the backdrop and reads as a rendering glitch.
Masking pitfalls
mask-imagecreates a stacking context. Puttingmix-blend-modeon a child of a masked element traps the blend and the backdrop composites straight over your content. Put the blend on the masked element itself.mask-repeatdefaults torepeat. If glyphs or content overflow the element box (e.g.line-height< 1 pushes descenders out), the gradient tiles and snaps back to opaque at the boundary — a hard band slicing through your content.no-repeatdoes not fix it either: outside the mask area alpha is 0, so the overflow disappears instead. Either size the mask to cover the overflow, or drop the mask.- Radial mask radii must not exceed the distance to the nearest edge, or the mask is
still partly opaque where the element clips — which draws the exact hard rectangle you
were trying to avoid.
radial-gradient(50% 50% at 50% 50%, …)reaches zero on all four edges.
Proving it actually refracts
Do not trust a screenshot. Put a striped test pattern behind the glass and compare the stripe period at the rim versus the centre:
rim: 30px → 60px (backdrop stretched by the lens)
centre: 35px → 35px (optically flat)
Unchanged centre + stretched rim = real refraction. Uniform change = you only blurred it. A quick check that the blur is doing something: mean absolute horizontal pixel difference should drop sharply through the glass (measured 3.31 → 0.74 per px).
Fallbacks
Wrap the enhancement so unsupported browsers keep something that still looks right:
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
.card { background: <opaque gradient>; } /* translucent + sharp backdrop = broken */
}
Safari's support for SVG (url()) filters in backdrop-filter is the shakiest part
of this whole technique — CSS.supports() can report true without rendering correctly.
Test on real Safari before shipping, and prefer the static-copy approach above (a plain
filter, which is far better supported) when the backdrop permits it.
Skip the refraction entirely on small screens if the layout means there is little backdrop to bend — it is pure raster cost for no visible effect.