LiquiKit··17 min read
Making liquid glass work in every browser
How LiquiKit traces light through a curved lens, why glass at rest runs no filter at all, and what it took to make the same glass hold up in Safari and Firefox.
When Apple showed Liquid Glass at WWDC in 2025, I wanted it on the web. Not a frosted panel with a white border, but glass that actually bends what's behind it, the way a drop of water bends the text it's sitting on.
There were already some great experiments built on SVG displacement maps, and they showed it could be done. Most of the ones I found only worked in Chrome, though, and most were a single lens on a demo page rather than a set of controls you could build an app from. I wanted switches, sliders, menus and dialogs made of glass that works in Chrome, Safari and Firefox, and a page full of them that still scrolls without dropping frames.
That became LiquiKit, which I've just released as open source. It's a glass version of every Base UI component that draws something, and it installs with the shadcn CLI. This post is about how the glass works: the optics, the trick that makes resting glass cheap, and the browser bugs that took up most of the work.
The demos on this page run the real engine, copied into this site the same way the CLI would copy it into yours.
Glass bends, it doesn't blur
A frosted panel blurs what's behind it. Glass moves it. Light changes direction when it passes through a curved surface, so the page under a piece of glass shows up a little to the side of where it really is.
Most of that happens at the edge. The top of a piece of glass is close to flat, so light goes more or less straight through and the middle shows the page as it is. The rim curves down to meet the page, and that steep curve is where light bends the most. That bright, warped edge is most of what makes glass look like glass.
So instead of painting an effect, LiquiKit models the glass. Each surface is a rounded rectangle with a curved rim, bevel pixels wide and thickness pixels tall, sometimes sitting on a flat slab base pixels deep. It's made of a material with a refractive index, ior, which is 1.52 by default, about the same as window glass.
For every point on the surface, LiquiKit traces a ray looking straight down. The ray hits the curved top, bends by Snell's law, carries on through the glass and lands on the page. The distance between where it went in and where it landed is how far that bit of the page appears to move.
Largest bend 6.2px, 0.5px in from the edge
The slider handle is the interesting preset. It sits on a thick slab, so right at the outline the light still has a lot of glass to cross. The hardest bend lands at the very edge, and the outermost rays cross over their neighbours, which folds a mirrored copy of the middle into the rim. On screen that reads as the edge of a thick piece of glass. Drag the slab down to zero and the largest bend in the figure drops from 17.5px to under 2, because without it the glass thins to nothing right at the outline.
That preset is fitted to the slider in Chris Feijoo's BeJS talk, Liquid Glass in the Browser: Refraction with CSS & SVG, measured from its displacement map. It's a good talk if you want another angle on all of this.
The model is simplified on purpose, so it can keep up while things move. You always look straight down, so there's no perspective. The ray bends once, at the top surface, and not again on the way out, because the glass sits flat on the page. The page is a flat plane right under it. It's physically based rather than physically exact, and the parts it leaves out are parts you wouldn't notice at the size of a button.
Tracing one line instead of every pixel
Tracing a ray for every pixel of every lens would be slow, and it turns out it isn't needed. The height of the rim depends only on how far a point is from the outline. So the whole trace collapses onto one axis: LiquiKit traces 512 samples across the rim once, then for each pixel it looks up that pixel's distance from the outline and pushes it along the direction the outline faces there.
On my laptop that builds the map for a slider's handle in about a millisecond, and the map for a large card in about six. That matters more than it sounds, because a lens changes shape while you drag it and the map has to keep up.
The top isn't flat either
The rim isn't the only thing that bends, but the top isn't traced the way the rim is. magnification stands in for a curved top instead. Above 1 it enlarges what's underneath, like a glass rod lying along a line of text, and below 1 it shrinks it. A switch's knob is set below 1, because Apple's shrinks what's under it: through it, the track reads at about 0.8 of its height, and you can see the page above and below it. The slider handle and the segmented control's pill are flat, at exactly 1, so the track and labels under them stay their own size.
Turning the trace into an image
Browsers have had a way to move pixels around for a long time: the SVG feDisplacementMap filter. You give it an image, and it moves every pixel of its input by an amount it reads from that image's colour channels. A channel halfway up its range leaves a pixel where it is. Lower moves it one way and higher moves it the other.
LiquiKit writes the trace into one of these maps. Red is how far each pixel moves sideways and green is how far it moves up or down. The spare blue channel holds the outline itself, how much of each pixel the glass covers, so the frost can be cut to exactly the same anti-aliased edge. Everything outside the outline is neutral, so only the area under the glass moves.
Light gets an image of its own. The rim brightens towards grazing angles, the way real glass gets more reflective there, using Schlick's approximation of the Fresnel term. Glints from a key light are a Blinn highlight, a weaker one catches on the opposite rim where the same light bounces once inside the glass, and rims that face down catch less light. All of it comes out as one signed value per pixel, stored as grey and blended over the bent view with hard-light: lighter than mid-grey brightens, darker darkens.
Displacement
Red moves pixels sideways, green moves them up and down, blue is where the glass is.
Light
Above mid-grey lightens, below darkens. Blended hard-light over the bent view.
Light, baked for rest
The same light as white and black with alpha, so CSS can draw it with no filter.
Building the map…
There's one setting that's fake on purpose. dispersion splits colour into a rainbow fringe, strongest at the rim, by running the displacement three times at slightly different strengths, once per colour channel. Real glass does this too, but blue only bends about 1% more than red, which you'd never see on a switch. So the spread is exaggerated, but the order stays honest: blue bends further than red, because glass bends shorter wavelengths more.
Magnifying turned up a less obvious problem. In Chromium and WebKit, a displacement copies the nearest source pixel, so an enlarged image comes out in blocks. A 2× dome turns every pixel into a 2×2 square and every curve into stairs. The obvious fix, a small feGaussianBlur, does nothing below about a pixel, because every engine runs it as box blurs of whole pixels, and the next step up is already a heavy, banded blur. What works is a 3×3 feConvolveMatrix over the bent result, which blends across the blocks the way smooth image scaling would. Run over the page instead, it cost Chromium about 10ms a frame. Run over just the lens, it costs nothing I could measure. Firefox's displacement already comes out smooth, so it's left off there.
Glass at rest runs no filter
At first, every piece of glass in LiquiKit carried a filter all the time. It looked right, and pages with a lot of glass on them felt heavy. Chromium sends every filter to its GPU process again on every frame, and in Safari and Firefox filters run on the CPU.
Apple's own controls don't work like that anyway. A switch's knob at rest is a plain white capsule. The glass only opens under your finger, and it closes again when you let go. The bending is a response to touch, not a permanent decoration. Leaving the glass open all the time is probably the most common way to get this wrong, and it also happens to be the expensive way.
So in LiquiKit, glass at rest runs no filter at all. It's two layers:
- The frost is the browser's own
backdrop-filter: blur(), which runs on the GPU and costs about as much as any frosted panel. - The light is the same light map from above, baked into a transparent image and laid over the glass with plain CSS.
The bake works because of a handy property of hard-light. Blending a grey level L over any colour gives exactly the same result as painting white over it with an opacity of 2L - 1 when L is above mid-grey, or black with an opacity of 1 - 2L when it's below. So the light map converts losslessly into white and black pixels with alpha, and plain CSS can draw it as a background image:
light.ts, simplified
for (let i = 0; i < pixels.length; i += 4) {
const level = pixels[i] / 255;
const ink = level >= 0.5 ? 255 : 0;
pixels[i] = pixels[i + 1] = pixels[i + 2] = ink;
pixels[i + 3] = Math.round(Math.abs(2 * level - 1) * 255);
}You can see the result in the third image of the figure above. The glass bends the page only while it's in use: while it's pressed, dragged or focused, while a popup grows, and for a moment after so it can settle. A pane keeps bending for 900ms after you let go. For the odd piece that should always look like glass, refraction="always" keeps it bending while it's on screen. That's meant for one or two pieces rather than a whole page.
With that, a screen full of resting glass scrolls like a screen full of frosted panels, in every browser.
Safari and Firefox can't see behind an element
Cost wasn't the biggest problem with bending, though. To bend the page behind an element, a filter needs a picture of what's behind it, and backdrop-filter: url(#filter) is the only way to get one. Only Chromium renders it.
You can't even feature-detect it properly. CSS.supports("backdrop-filter", "url(#glass)") only checks that the syntax is valid, so it says yes in engines that can't render it. It happens that only Chromium both renders it and exposes navigator.userAgentData, so the brand list is the test.
For Safari and Firefox, the glass bends a copy instead. You wrap an area in a GlassScope and hand it the background as backdrop:
<GlassScope
backdrop={
<img src="/photo.jpg" alt="" className="size-full object-cover" />
}
>
<Button>Play</Button>
</GlassScope>The scope draws the backdrop behind its children as normal. It also keeps a small pool of boxes, each holding a second copy of the backdrop, laid out at zero size at rest. When a piece of glass starts bending, it borrows a box, moves it under its lens, sizes it to the lens plus however far the filter reaches, and runs its filter on that. When it settles, it hands the box back.
Every part of that came from something that didn't work.
- A copy, not the scope. The first version put the filter on the scope itself. Safari and Firefox paint an element with an SVG filter, and everything inside it, in software, and the backdrop blur of every other pane inside came out wrong. Pressing one button made all the others flicker.
- Small boxes, not one big one. Safari and Firefox run SVG filters on the CPU, pixel by pixel. A card-sized filter held a pressed button to 49fps in Safari and a toggling switch to 36. A box the size of the lens holds both at 60.
- Laid out before it's needed. Showing a hidden copy on the first press cost Safari 77ms in that frame. A box that's already laid out at zero size costs 25 to 40ms, and less over a light backdrop.
The catch is that only the backdrop is copied. Anything you put inside the scope as children won't bend under a pane, and in Safari and Firefox it won't bend at all. And the backdrop gets drawn again under every pane that's bending, so it should be cheap to draw. Photos and gradients are fine. A video would play twice. Inside a scope, panes use the copy in Chromium too, so they look the same everywhere.
| What bends | Chromium | Safari and Firefox |
|---|---|---|
| Buttons, fields and panes in a scope | The scope's copy | The scope's copy |
| Switches, sliders and segmented controls | The page behind them | The scope's copy, with their track drawn on top. Outside a scope, a copy of their own track |
| Menus, popovers and dialogs | The page behind them | Nothing, they stay frosted |
| Buttons, fields and panes inside other glass | Nothing, they keep their frost and rim | Nothing, they keep their frost and rim |
| Switches, sliders and segmented controls inside other glass | A copy of their own track | A copy of their own track |
Chromium has its own problems
Chrome on a Mac, with GPU compositing, doesn't hold an SVG backdrop-filter steady when there's other glass behind it. On any frame where the filter itself isn't rebuilt, which is any frame where something else on the page paints, it drops the filter, and the glass flickers between bent and flat. A tab bar's lens did this every few frames while it moved. It only happens in real Chrome, not in the headless builds that tests run in.
The fix was to stop asking for it. A pane placed inside other glass, like a button on a card, doesn't bend at all. Its bend would only be seen through the frost of the glass below it anyway. A control inside other glass bends a copy of its own track instead, and a pane inside a scope bends the scope's copy. Both are ordinary CSS filters, which Chromium holds just fine.
The other trap is the backdrop root. A backdrop filter only sees the page up to the nearest ancestor with an opacity below 1, a filter, a backdrop filter, a mask, a clip-path or a blend mode. Under one of those, Chromium draws the whole box dark. A fade-in animation that fills forwards does it too, because it keeps that ancestor a backdrop root even once it's sitting at an opacity of 1. So every time a piece of glass opens, it walks up its ancestors looking for one. If it finds one, it falls back for good to whatever Safari and Firefox would use.
A few browser bugs worth knowing
The optics were the fun part. Most of the work went into the gaps between browsers. If you ever bend things with SVG filters, these are the ones I'd want to know about first:
- Safari caches filter output by id. Change a filter without changing its id and Safari keeps drawing the old result, so the glass freezes mid-motion. Every update gets a fresh id.
- Safari loads
feImageasynchronously. A filter can render once against a map that hasn't arrived yet and, if nothing else changes, never render again. A held control would lose its rim until you moved it. LiquiKit waits for the image to decode and asks for one more pass. - An empty
feImageisn't harmless. WebKit drops the whole filter over it, and Chromium draws the lens's edge differently. - WebKit measures a filter from the nearest transformed ancestor, not from the element itself, and from the page when nothing is transformed. The primitives land in the wrong place, and an element whose filter misses it entirely isn't drawn at all. An identity transform on the element fixes it, which is why every lens box carries
transform: translate(0). - Every engine reads
feDisplacementMap'sscaledifferently underobjectBoundingBox. Firefox uses the box's normalised diagonal, Chromium its width, and WebKit its width and height separately. No single number bends all three the same, so the filter's steps work in plain pixels instead. - Firefox resamples between pixels when a primitive's region doesn't line up with them. A lens springing to 60.9px wide comes out soft. Every region is snapped to whole device pixels.
- WebKit still only honours
-webkit-user-select. Without it, dragging a pill across a segmented control's labels selects the text. - Safari won't apply an SVG filter to a playing
<video>. There's no way around this one with filters. It would need the same map fed to a WebGL shader.
Keeping the maps cheap while they move
A lens changes shape all the time. A switch's knob grows when you press it, squashes when you throw it and wobbles when it lands. Building a new map for every frame of that would be wasteful, so there are a few rules:
- Shapes are snapped to half a pixel before a map is built. Without that, a spring settling on a held control misses the cache on every frame and encodes a new PNG at 60fps.
- While the lens moves, the last map is stretched onto the new shape until the shape drifts more than 12% from what the map was drawn for. The exact map is drawn once the shape has held still for 90ms. A one-second slider drag and release builds 7 maps instead of 48.
- A new map only replaces the one in the filter once the browser has decoded it. Swapped in while it was still decoding, Chromium bent garbage inside the lens for a frame or two.
- Buttons and fields build the maps a press will need ahead of time, while the page is idle. Switches, sliders and segmented controls still build theirs on first use.
The maps are also smaller than they look. Nothing on a flat top changes along a straight edge, so a flat lens's map is cut in three along its longer side: the two ends as drawn, and a one-pixel slice between them that the filter stretches back out.
The whole map
What the filter is given
Drawn to the same scale. The outlined piece in the middle is one pixel wide, and the filter stretches it to whatever length the pane is.
That saving matters because of how Chromium handles filters. It sends each one to its GPU process afresh every frame, as a tree rather than a graph, so every path through the filter to an image carries its own copy of that image's pixels. Each step in the filter also costs about 15µs however small it is, so the graph is kept short: the light is one blend rather than two passes, and the lens is laid straight over the page rather than cut out of it first. Together those took a page of 48 panes, scrolling in Chrome on a 120Hz screen, from dropping a third to half of its frames to dropping 2 to 4%. That was before resting glass stopped filtering altogether.
Making it feel like liquid
Getting the optics right makes a still frame look like glass. Making it feel like liquid is all motion.
Everything moves on springs rather than timed curves. The lens already behaves like a physical object, and a spring keeps that consistent: if you interrupt a gesture halfway, the motion carries its velocity through instead of restarting a fixed animation. Opening is a stiffer spring than closing, so a press feels answered straight away, and the glass opens the moment a pointer lands, not after a delay.
When a knob moves fast it squashes, narrower and taller, and it keeps its area while it does. A drop of liquid doesn't gain volume by being thrown. Speed maps onto squash through a saturating curve with one number to tune, the speed at which the effect is mostly in, rather than an exponent to guess at. A capsule sliding along a row, like a segmented control's pill, stretches along its travel instead. Squashed the knob's way, a big lens sent across a tab bar bulged 20px above and below the bar mid-flight, which read as a shake rather than as liquid.
On top of that there's one setting, liquid, from 0 to 1, which is what makes the glass read as liquid rather than rubber. It adds two things:
- A wobble. Pressing a control makes its glass ring on a spring of its own, about 3.5 times a second and well short of critically damped. It lifts off the track taller and narrower, answers back a little wider, and settles in about a third of a second. Width and height swing against each other with the area held, so it jiggles without swelling.
- A lag. While a switch or slider moves, what you see through it trails behind, by up to 3px with
liquidat 1, then springs back when it stops. It's a singlefeOffsetat the start of the filter, so it costs almost nothing.
Neither of them builds a new map. The default is 0.6, which is meant to be felt more than seen, and both turn off when the system asks for reduced motion.
liquid=0Off
liquid=0.6The default
liquid=1All the way
Past the end of a track, the handle pulls against a hyperbolic rubber band. The pull approaches its limit without ever reaching it, so there's no point where the give suddenly stops.
Menus that pour out of their button
The menus are worked out from a screen recording of iOS 26, frame by frame. When you open one, the button's capsule pulls in to a round droplet and flies to where the panel will be, carrying a little past it and back. Then it inflates into the panel a beat behind. The axis it's travelling along swells first and the other follows, later and softer, so the two overshoot by about 5% out of step and it lands like jelly. The content shows through as it fills, blurred and a little small, and sharpens as the glass settles.
A frame-by-frame fit to the recording came out too quick, full size in about 0.2s, and on screen it read as snappy rather than fluid. So LiquiKit's is a little looser than Apple's: the panel arrives in about 0.3s, overshoots at about 0.4s and has settled by about 0.6s. Closing runs it backwards, quicker, and the glass lands in the button with some momentum left, so the button gives about 8% in the direction it was travelling and springs back.
There's one small trick in the closing animation. Base UI unmounts a popup as soon as the animations running on it finish, so LiquiKit runs a do-nothing animation on visibility to hold it until the droplet has settled back into the button. It can't be an opacity animation, because an opacity below 1 would make the popup a backdrop root, and the glass would lose the page behind it.
You can try the menus on liquikit.dev, along with selects, alerts and toasts built from the same morph.
Shipping it as source
LiquiKit isn't an npm package. Like shadcn/ui, it's a registry: the CLI copies the source into your project, where it's yours to read and change. A build script works out each item's dependencies from its imports, so adding a switch brings the glass engine and the shared styles with it, but none of the other components. The engine itself depends only on motion, and Base UI handles the behaviour, keyboard support and accessibility underneath.
components.json
{
"registries": {
"@liquikit": "https://liquikit.dev/r/{name}.json"
}
}npx shadcn@latest add @liquikit/switch @liquikit/menuThat's how the demos on this page got here too. The glass engine is copied into this site untouched. The map figures call the same function the glass uses to build its maps, and the side view uses the same maths.
What it doesn't do yet
Safari can't bend a video, so a video under glass shows through straight there. The optics leave out perspective and the second bend on the way out of the glass. And a few of the workarounds above depend on how browsers behave today, so a browser update could break one. The limitations page keeps track of where the browsers differ.
The code is on GitHub under the MIT License, and the docs are at liquikit.dev. If something looks wrong in your browser, open an issue. And if you build something with it, I'd love to see it.
More about LiquiKit on its project page.