✍️
My Blog
/
Building Liquid Glass on the web
Search

Building Liquid Glass on the web

Tags
Web Dev
Tech
Published
September 10, 2026
Author
I wanted to build “cupertinocn”, a set of shadcn components that copy the iOS look. The part everyone recognizes about iOS 26 is Liquid Glass: controls that look like thick, clear glass, and bend whatever is behind them at the rim. A tab bar pill slides over the labels and they fold and stretch along its edge. I wanted that in the browser, and not as a Chrome-only demo.
TLDR: You can test the final thing at https://vantezzen.github.io/cupertinocn/
The technique I started from is Aave's. Their article Building Glass for the Web describes how they built glass for their app's web version with an SVG feDisplacementMap and a map image they generate per shape. It works in Chromium, Safari and Firefox, the code is inlined in the page, and the write-up is good. My first port of it looked like this:
notion image
The slider thumb is supposed to be a lens over the track. Instead it's pixelated, the fill ends in a dark blob, and the containers on the demo page didn't bend anything at all in Chrome. Fixing that took me through a series of browser behaviours I haven't seen written down anywhere. This post is the path from that screenshot to something that works.

How the glass is made

feDisplacementMap is an SVG filter primitive that takes two images: the content, and a map. For every output pixel it looks up the map's red and green channel and shifts where it samples the content from, by scale × (channel − 0.5) in x and y. A flat gray map (128 everywhere) does nothing. A gradient in the map drags content around.
Aave's insight is that the whole glass effect is one such map, and you can compute it. The glass is a spherical dome. The slope is zero in the middle and grows toward the rim, so the middle shows the content unchanged and the rim is where things bend. An error function window narrows the visible bend to the outer few pixels. And every shift points inward, toward the center, so the rim shows a compressed copy of what's inside the glass. That inward fold is the thing your eye reads as thick glass.
Here's the map for a 93×56 pill, blown up. Gray in the middle, saturating at the edge, and the padding around it is part of the image so the filter region can extend past the element:
notion image
Three displacement passes with slightly different scales, one per color channel, add the prismatic fringe. Recombining them has a trap I fell into later: mask each pass to one channel and add them with feComposite operator="arithmetic", and you add their alpha as well. On an opaque backdrop nobody notices. On the tab bar, whose copy is 40% white, the lens turned into a solid gray slab. Averaging the passes instead (k = 0.5/0.5, then 2/3 and 1/3) keeps alpha exact, and a feComponentTransfer with slope 3 scales the colors back up, which is exact in straight color.
The blue channel of the map is unused by the displacement, so it carries a specular term: a glow plus an edge band that's brightest where the rim faces a light at 45°. I pull that channel out as a white image with alpha and paint it as the rim highlight, which means the highlight follows the exact shape in every browser, whether the filter runs or not:
notion image
Aave's page is a Next.js app, so their generator is in the bundle. Reading it gave me the constants I would otherwise have guessed: sphere radius R = (half² + a²) / 2a per axis with dome height a = curvature × min(W, H), slope x / sqrt(R² − x²) normalized so the mean slope across the half-size is 0.5, edge gain 0.5 (1 + erf(d / (depth √2))) with erf(x) ≈ tanh(1.7724538509 x), and chroma factors of 1 + 0.2c for red and 1 + 0.1c for green. Their switch preset is a 90×60 lens with a 2 px edge window and a shift of a quarter of the width.
There are two ways to feed content into the filter, and the difference matters for the rest of this post:
  • backdrop-filter: url(#lens) bends the live page behind the element. Only Chromium applies SVG filters to a backdrop. WebKit drops the whole declaration, including any blur() next to it.
  • filter: url(#lens) on a copy of the content. Aave calls this the refractionTarget. The switch thumb refracts a copy of its track fill, the slider thumb a copy of the fill, the toggle group a highlighted copy of its own options. The copy is positioned so it stays aligned with the real thing as the lens moves. This runs in all three engines.
cupertinocn uses the backdrop for containers (toolbars, buttons, popovers) and copies for anything that moves over known content (thumbs, indicators).

Why the first version was pixelated

Two reasons, and both are about what happens between the map and the screen.
I rendered the map at one pixel per CSS pixel, the same size as the element. Aave renders a 256 or 512 pixel map no matter how small the lens is. On a 3× display the filter has to stretch my 40×34 map over 120×102 device pixels, and the bend moves in visible steps. Oversampling the map, up to four pixels per CSS pixel and capped at 512 on the long edge, fixed the staircase.
The second reason is subtler. My map had a hard edge: full displacement inside the shape, neutral gray outside, switching within one pixel. The element is already clipped to the shape by CSS, so the outside values are never seen. But the filter samples the map bilinearly, and right at the boundary it interpolates between "shift 12 px" and "shift 0", producing a one-pixel ring of pixels that sample from arbitrary places. Aave has the same step in their map and hides it with a rounded-rectangle mask image in the filter. I removed the boundary instead. The map is smooth everywhere, and the CSS clip does the shaping.
Slider lens after the fix
The dark blob was a third thing. The copy of the track under the thumb was painted in the same translucent gray as the real track, so through the lens you saw both and the interior went dark. On top of that, the thumb stretches with drag velocity via transform: scale, which stretched the copy inside it while the real fill stayed put. The copy now paints the track in an opaque equivalent of its color, so it covers the real track instead of darkening it, and the stretch resizes the thumb instead of scaling it. If you use Base UI's slider with thumbAlignment="edge", one detail: the indicator ends under the thumb center, not at progress × width, so an aligned copy has to end there too.

Nothing bent in Chrome

With the map fixed, the thumbs looked right. The containers didn't. The demo page has a toolbar and a few buttons over a gradient, and in Chrome they showed a blur and a wash and no bend whatsoever. I had earlier screenshots that seemed to show bending at the edges, so my first assumption was a regression.
It wasn't. I had been fooling myself with the test pattern.
The dome displacement is separable: the horizontal shift depends only on x, the vertical shift only on y. Put a grid behind it and horizontal lines are unchanged by horizontal shifts and vertical lines by vertical shifts. Only the corners can show anything. The demo background was a gradient with a faint grid. My first isolated test page used diagonal stripes, which are invariant to any shift along the diagonal. My second used a 16 px checkerboard, which hides a 15 px shift almost perfectly. Every one of those had shown "bends" that were the background.
Concentric rings are not invariant to anything. A uniform shift shows as a discontinuity at the element's edge, and a real bend changes the curvature. Everything below is tested over rings.

feImage is blank inside a backdrop filter

Here are seven ways of running the lens as backdrop-filter: url(...) over rings:
Seven backdrop-filter variants over rings
Boxes 1 through 6 feed the displacement with an feImage map in some form. Box 7 uses feTurbulence as the map. Only box 7 warps the rings. The others show the rings shifted sideways by a constant amount and otherwise intact. Box 6, which composites the image over a gray flood first, shows no shift at all.
That pattern has one explanation: the image never arrives. feImage produces a transparent result, the displacement reads zero for red and green, and shifts every pixel by −scale / 2. Compositing over gray turns zero into 0.5 and the shift disappears. Box 5 runs the identical filter as a normal filter on an element and warps correctly, so the filter is fine. It's the backdrop path that drops the image.
I tried everything I could think of to get an image through:
feImage variants
PNG data URL. SVG data URL. A file URL. href="#rect" pointing at an SVG element. Waiting for img.decode() before assigning the href. Toggling the backdrop-filter off and back on after the image had loaded. Explicit x, y, width, height. userSpaceOnUse. objectBoundingBox with feTile. None of them render. The element reference and the file URL make the whole backdrop filter vanish, blur and all.
I had been shipping a backdrop that was shifted diagonally by up to 26 px and calling it refraction. If you've seen a Chromium-only "liquid glass" demo that feeds backdrop-filter with a PNG map, on current Chrome it's doing the same thing.

Building the map out of filter primitives

Turbulence worked. So the primitives that generate their own pixels run in the backdrop path; it's only images that don't. That raised the question of whether the dome map could be generated inside the filter, with no image at all.
The shape is the hard part. There's no "rounded rectangle" primitive. But the 0.5 isoline of a Gaussian-blurred rectangle is a rounded rectangle, so blur a white rect on black and push it through a steep linear transfer, and you get a pill. Blur that again for the soft edge window. Then differentiate: a 3×3 Sobel kernel per axis gives the gradient, which is exactly the bitmap version's erf window (erf is a blurred step). Red gets the x gradient, green the y gradient, both biased by 0.5:
<filter filterUnits="userSpaceOnUse" primitiveUnits="userSpaceOnUse" x="-34" y="-34" width="161" height="124"> <!-- shape: white rect over black, blurred, thresholded --> <feFlood flood-color="#000" result="bg"/> <feFlood flood-color="#fff" x="0" y="0" width="93" height="56" result="rect"/> <feComposite in="rect" in2="bg" operator="over" result="mask"/> <feGaussianBlur in="mask" stdDeviation="15" result="soft"/> <feComponentTransfer in="soft" result="shape"> <feFuncR type="linear" slope="40" intercept="-19.5"/> <feFuncG type="linear" slope="40" intercept="-19.5"/> <feFuncB type="linear" slope="40" intercept="-19.5"/> <feFuncA type="linear" slope="0" intercept="1"/> </feComponentTransfer> <!-- edge window: narrow, so the fold sits in a thin band along the rim --> <feGaussianBlur in="shape" stdDeviation="4" result="edgeSoft"/> <feComponentTransfer in="edgeSoft" result="edge"> <feFuncA type="linear" slope="0" intercept="1"/> </feComponentTransfer> <!-- gradient: one Sobel kernel per axis, biased to 0.5 --> <feConvolveMatrix in="edge" order="3" kernelMatrix="1 0 -1 2 0 -2 1 0 -1" divisor="0.3" bias="0.5" preserveAlpha="true" edgeMode="duplicate" result="gx"/> <feConvolveMatrix in="edge" order="3" kernelMatrix="1 2 1 0 0 0 -1 -2 -1" divisor="0.3" bias="0.5" preserveAlpha="true" edgeMode="duplicate" result="gy"/> <feColorMatrix in="gx" values="1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0" result="r"/> <feColorMatrix in="gy" values="0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 1 0" result="g"/> <feComposite in="r" in2="g" operator="arithmetic" k2="1" k3="1" result="map"/> <!-- blur and saturation have to live in here too, see below --> <feGaussianBlur in="SourceGraphic" stdDeviation="2" result="blurred"/> <feColorMatrix in="blurred" type="saturate" values="1.4" result="source"/> <feDisplacementMap in="source" in2="map" scale="63" xChannelSelector="R" yChannelSelector="G"/> </filter>
Rendering each intermediate result as the filter's output shows the pipeline working:
Stages: mask, soft, shape, edge, gx, gy, map
It did not work on the first try. Three things went wrong on the way, and each one taught me something about how Chrome runs backdrop filters.
The rectangle landed in the wrong place. My first version used the default objectBoundingBox units, with the flood rectangle at x="12%" y="20%". The result was a uniformly gray map. Visualizing it showed the white rectangle wasn't anywhere in the element; in the backdrop path, those percentages resolve against some other box. Switching the filter to userSpaceOnUse with pixel coordinates relative to the element fixed it:
userSpaceOnUse subregions land on the element, objectBoundingBox ones do not
A dark ring hugged the region boundary. Blurring near the edge of the filter region pulls in transparent pixels from outside it, so alpha drops near the boundary. Convolution and the displacement map both read unpremultiplied color, which divides by that small alpha and blows the values up. The feFuncA slope="0" intercept="1" after each blur forces alpha back to one and the ring goes away.
The gradient's strength depended on the display. feConvolveMatrix has no unit. In the backdrop path it runs on device pixels, so the Sobel of a blurred edge gets weaker on a 2× screen because the same edge is twice as many pixels wide. The divisor has to be k / (σ × devicePixelRatio). With that in, DPR 1 and 2 produce the same map.

clip-path on an ancestor turns the backdrop filter off

With the procedural filter working in a test page, I wired it into the component and saw nothing again. Not even blur this time, which was the clue. I toggled one CSS property at a time on the live surface:
Ten variants; only removing clip-path restores the backdrop filter
Only no-clip does anything. The surface had a clip-path because its backdrop layer was larger than the shape, padded so the rim would have content to sample past the edge, and the clip trimmed it back. Chrome responds to an ancestor clip-path by not applying the backdrop filter at all. overflow: hidden, contain: paint, transforms, z-index: -1, isolation: isolate are all fine.
I later found a second way to switch it off by accident: a mix-blend-mode on a sibling layer inside the same surface. I had put soft-light on the press highlight to make it gentler, and every container went back to plain transparency. A blended child makes Chrome render the parent as an isolated group, and the backdrop filter next to it reads that group, which is empty. Every layer inside the surface stays on plain alpha now.
The padding wasn't needed anyway. Inward sampling never reads past the edge. The layer is now the element itself, its own border-radius clips it, and the filter region extends past the element instead (x="-34" above), which Chrome honors in userSpaceOnUse.

A reference filter next to blur() loses the reference

Last one. The surface's CSS was backdrop-filter: url(#lens) blur(4px) saturate(1.4). With the extended region, Chrome renders the blur and drops the bend:
Bend alone works; bend plus blur() as separate functions loses the bend; blur inside the filter keeps both
Box A is url(#lens) alone and bends. Box B adds blur(2px) saturate(1.4) as separate filter functions and shows only blur. Box E moves both inside the filter as feGaussianBlur on SourceGraphic and feColorMatrix type="saturate", and keeps the bend. That's the last three primitives in the snippet above, and it's also how Aave's filter is built, which I should have taken as a hint earlier.
On and off, over rings, and on the demo page:
Backdrop lens on
Backdrop lens off
One more round of tuning after that. My first working version used the same edge width as the bitmap lens, about a quarter of the shorter side, and it read as a soft ripple inside the container rather than a glass edge. The look I was after is a thin band along the rim with a strong shift through it, so the backdrop mode now uses an edge window at 0.3 of that depth and a shift 1.25 times larger, with 2 px of blur instead of 4 so the compressed reflection stays crisp.
Containers on the demo page in Chrome

The tab bar lens that only worked on the first tab

The copy-based lenses had their own mystery. The tab bar indicator is a Base UI Tabs.Indicator carrying a glass surface. Inside the filtered layer sits a second, inert copy of the whole tab list, styled in the selected state, translated by −indicator-x so the copy of the tab under the lens lines up with the real tab. The real tab under the lens turns transparent. The result is that the selected label exists only inside the glass, and gets bent by it.
In Chrome, the Home tab showed heavy reflections at rest. The Library tab, with the same map, the same filter id, and the same geometry, showed none. And every reflection on Home leaned to the left, which a symmetric lens over a centered label can't produce.
A dot grid is the fastest way to see a displacement field. Painted on the filtered layer's own background, the field is a symmetric ring at every tab. Painted on the copy, which is 372 px wide against a 93 px lens, the field vanishes at Library and turns into left-biased blobs at Home:
Dot grid on the layer (top) versus on the wide copy (bottom)
Chrome computes the reference box for a CSS filter from the element's visual overflow, not its border box. The percentage-based filter region and the unsized feImage are laid out against that. With a copy four times wider than the lens, the map was being stretched across the entire copy. At Home the copy extends 4 px left and 275 px right of the lens, so the map's left rim landed on the visible part and produced those blobs. At Library the copy extends 190 px in both directions and neither rim came anywhere near the pill.
Every reflection I had seen on the Home tab, including the one I had tuned toward, was the left edge of a map four times too wide landing by accident. WebKit uses the border box and had been right the whole time.
The fix is one CSS rule on the filtered layer: overflow: hidden; contain: paint. The layer's overflow becomes its box, the reference box follows, and the lens bends the same at every tab.

Tuning, and deciding when the glass should bend

Once the lens was correct, tuning was a real trade-off rather than guesswork. The label of a tab sits about 6 px from the bottom of the pill. Any edge window wider than that reaches the label and shears it, because the dome slope is non-zero away from center and the window's gain ramps across the letter height. A narrow window with a strong shift is what gives the look from the iOS screenshots: the content stays intact and the reflection is compressed into the rim.
Home and Week indicators at rest, Chrome left, WebKit right
Even so, a resting pill that mirrors its own label along the bottom doesn't look like iOS. On iOS the selected pill is clear at rest and only bends things while it travels over them. So the bend is now tied to motion, and I wanted that without adding per-frame work to the gesture code, which already runs springs for position and stretch.
Registered custom properties do this in a few lines:
@property --glass-bend { syntax: "<number>"; inherits: true; initial-value: 1; } .cn-glass-surface { transition: --glass-bend 260ms var(--ios-ease); } .cn-tab-indicator { --glass-bend: 0; } [data-scrubbing] > .cn-tab-indicator { --glass-bend: 1; }
The gesture code already sets data-scrubbing on the list while a finger is down. Because --glass-bend is registered as a number, the browser animates it. The glass surface listens for transitionrun and transitionend with propertyName === "--glass-bend", samples the value each frame while the transition runs, and multiplies the scale of the displacement passes by it. Chrome and WebKit both dispatch those events for registered properties. Containers keep the default of 1 and never notice.
Rest, scrub, after release, in Chrome and WebKit
At rest the displacement is zero, mid-drag it's full, and after release it fades over a quarter second while the pill springs to its tab. With the alpha-correct recombination from earlier, the color fringe is on for the indicators too, so the folded label splits into red, green and blue along the rim while it moves.

What I know now about SVG filters in browsers

Everything verified with Google Chrome 152 (headed, GPU on) and Playwright's WebKit 26:
Behaviour
Chromium
WebKit
backdrop-filter: url(#f)
applied
whole declaration dropped
feImage inside a backdrop filter
blank
n/a
objectBoundingBox subregions in a backdrop filter
wrong box
n/a
userSpaceOnUse subregions, region past the element
correct
n/a
backdrop-filter under an ancestor clip-path
dropped, blur included
n/a
backdrop-filter with a mix-blend-mode sibling in the same surface
dropped, blur included
n/a
url(#f) blur() with an extended region
reference part lost
n/a
feConvolveMatrix in a backdrop filter
device pixels
n/a
filter: url(#f) reference box on HTML
visual overflow
border box
feImage with x/y/width/height under filter: url()
fine
renders nothing
Filter output cache
by content
by filter id; a new map needs a new id
Two of these cost me the most time and are worth remembering even if you never build glass. Pick a test pattern that can't hide the failure mode you're looking for; rings, not stripes. And in Chromium, a backdrop filter can't read an image, so any technique that needs a bitmap inside backdrop-filter has to be rebuilt from primitives that generate their own pixels.
The full implementation is in cupertinocn: glass-optics.ts holds the map generator, the specular channel, and the constants; liquid-glass.tsx holds both filter modes and the bend hook. Aave's article remains the best explanation of the idea itself, and their generator is worth reading in the bundle if you want the numbers.
Table of Contents
How the glass is madeWhy the first version was pixelatedNothing bent in ChromefeImage is blank inside a backdrop filterBuilding the map out of filter primitivesclip-path on an ancestor turns the backdrop filter offA reference filter next to blur() loses the referenceThe tab bar lens that only worked on the first tabTuning, and deciding when the glass should bendWhat I know now about SVG filters in browsers
Copyright 2026 vantezzen